diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..ecee45a6d --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/Cargo.lock b/Cargo.lock index 4edd4cb92..e041aafbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.9.0" +version = "0.10.0" dependencies = [ "arboard", "craft-print", @@ -1518,7 +1518,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.9.0" +version = "0.10.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.9.0" +version = "0.10.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.9.0" +version = "0.10.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.9.0" +version = "0.10.0" dependencies = [ "js-sys", "serde", @@ -1563,7 +1563,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.9.0" +version = "0.10.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1584,7 +1584,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.9.0" +version = "0.10.0" dependencies = [ "gridcraft-core", "serde", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.9.0" +version = "0.10.0" dependencies = [ "gridcraft-core", "regex", @@ -1601,7 +1601,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.9.0" +version = "0.10.0" dependencies = [ "gridcraft-engine", "log", @@ -1610,7 +1610,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.9.0" +version = "0.10.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1622,18 +1622,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.9.0" +version = "0.10.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.9.0" +version = "0.10.0" [[package]] name = "gridcraft-ui-egui" -version = "0.9.0" +version = "0.10.0" dependencies = [ "craft-ui", "egui", @@ -1655,7 +1655,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.9.0" +version = "0.10.0" dependencies = [ "eframe", "egui", @@ -1672,7 +1672,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.9.0" +version = "0.10.0" dependencies = [ "aes", "gridcraft-core", @@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.9.0" +version = "0.10.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index d1106f39c..e8a6dc67e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.9.0" +version = "0.10.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/README.md b/README.md index ef9b906fa..2c224559c 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ cd apps/gridcraft-web && trunk build --release # → dist/web (serve it with cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sample=sales opens a sample) ``` -You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the +You need [trunk](https://trunk-rs.github.io/trunk/) and the `wasm32-unknown-unknown` target. Open uses the browser's file picker (dropping files works too) and Save downloads the workbook. ### Ribbon access keys diff --git a/ROADMAP.md b/ROADMAP.md index de39b787a..55e987821 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| +| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) | | **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 275 / 291 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | @@ -93,7 +94,9 @@ The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Po and German (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82, German added 10-11), the interface follows the system language, and CJK, Thai and Hebrew text falls back to system fonts -(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only. +(#38). Japanese (10-17) and Brazilian Portuguese also translate the dialog titles and labels, +message boxes, Backstage/File-view items, template names and native menus: 943 strings each, ~43%. +The remaining dialogs, menus and messages are English; formulas and number formats are en-US only. Localized German formulas and numbers are in PR #163. Detail: [localization-parity.md](docs/localization-parity.md). | Language | Code | Status | UI strings | @@ -104,14 +107,14 @@ Localized German formulas and numbers are in PR #163. Detail: [localization-pari | Hindi | hi | none | 0% | | Arabic | ar | none (no RTL layout, no shaping) | 0% | | French | fr | none | 0% | -| Portuguese | pt-BR | menus only | ~12% | +| Portuguese | pt-BR | menus, File view, dialogs | ~43% | | Indonesian | id | none | 0% | -| Japanese | ja | menus only | ~12% | +| Japanese | ja | menus, File view, dialogs, messages | ~43% | | German | de | menus only | ~12% | | Korean | ko | menus only | ~12% | | Vietnamese | vi | none | 0% | -Other shipped languages: 2 (Russian and German, menus only, ~12%). +Other shipped languages: 3 (Russian and German, menus only, ~12%; Dutch, menus, File view and dialog titles, ~24%). ## Upcoming @@ -132,6 +135,30 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | +| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step | +| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch | +| 2026-10-19 | Long flat operator chains (`=A1+A2+…`) up to Excel's 8,192-character limit (4,096 operands): entering, filling, inserting rows, undo, Evaluate Formula and XLSX save no longer overflow the stack; chains past 64 operands are saved as plain formulas, not dynamic arrays; Spanish formula entry accepts chains past 256 operands (#916) | +| 2026-10-19 | Linux: "Use system setting" for the theme keeps following the desktop when the XDG Desktop Portal stops answering: each read gives up after 2 s and the next poll reconnects, where one unanswered call stalled the watcher for the rest of the session | +| 2026-10-16 | Memory: undo steps share a sheet's picture bytes and the workbook's VBA project instead of copying them: 50 edits on a sheet with a 20 MB picture keep 22 MB instead of 992 MB | +| 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes | +| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change | +| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS | +| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format | +| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) | +| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) | +| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) | +| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values | +| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values | +| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. | +| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse | +| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) | +| 2026-10-13 | Multi-level category axes: a category range two or more columns wide beside a column of values (year beside month, Excel's `multiLvlStrRef`) draws its innermost labels across the plot and the outer levels in rows under them with group separators (columns beside the labels in bar charts), instead of flattening the two columns into twice as many categories; saving writes the reference back as `multiLvlStrRef` with Excel's level caches, so the axis survives a round trip | +| 2026-10-13 | Charts: a huge trendline forecast no longer collapses the chart: on a category axis it adds at most four times the data in empty slots (at least 32), the line is sampled over the plot only, and forecasts read from XLSX or set by command are clamped, NaN and infinity becoming 0 (#1148) +| 2026-10-13 | XLSX export writes a picture used on several sheets once, as one `xl/media` part every drawing points to (it wrote a copy per drawing: a 16-sheet workbook with one 112 KB logo grew from 0.4 to 1.9 MB) (#985) | +| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) | +| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) | +| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained | | 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | @@ -194,6 +221,22 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips | +| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row | +| 2026-10-19 | minor | Progress log: long flat operator chains (#916) | +| 2026-10-19 | trivial | Progress log: desktop theme portal read timeout | +| 2026-10-16 | minor | Progress log: undo steps share picture and macro bytes | +| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook | +| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) | +| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice | +| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row | +| 2026-10-14 | minor | Progress log: spill anchor reading its old spill area | +| 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict | +| 2026-10-13 | trivial | Progress log: More Colors… and Recent Colors in the colour menus (#928) | +| 2026-10-13 | minor | Multi-level category axes (read, drawn, written); progress log row | +| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) | +| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) | +| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries | | 2026-10-21 | minor | Macro engine and `macro.*` commands; catalog 274 / 290 → 275 / 291 (View › Macros › Record Macro); progress log row | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index 2b8529876..74d2fca71 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -139,9 +139,10 @@ impl RoundTrip { #[derive(Clone, Debug, Default, PartialEq)] pub struct FileReport { pub path: String, - /// GridCraft couldn't open the file: its message. + /// Opening the file or discovering the requested inputs failed: its message. pub error: Option, - pub warnings: usize, + /// Import warnings, in the order the importer produced them. + pub warnings: Vec, pub calc: CalcReport, pub round_trip: RoundTrip, } @@ -204,67 +205,112 @@ pub fn main(args: &[String]) -> ExitCode { Err(_) => return usage(format!("--max-report needs a whole number, not `{n}`")), } } - let files = collect_inputs(&a.positional); - let reports: Vec = files - .iter() - .map(|p| { - let path = p.to_string_lossy().into_owned(); - match gridcraft_engine::io::read_file(&path) { - Ok(bytes) => check_bytes(&path, &bytes, &opts), - Err(e) => FileReport { path, error: Some(e.to_string()), ..Default::default() }, - } - }) - .collect(); + let Discovery { files, failures: mut reports } = collect_inputs(&a.positional); + reports.extend(files.iter().map(|p| { + let path = p.to_string_lossy().into_owned(); + match gridcraft_engine::io::read_file(&path) { + Ok(bytes) => check_bytes(&path, &bytes, &opts), + Err(e) => FileReport { path, error: Some(e.to_string()), ..Default::default() }, + } + })); if a.has("json") { outln!("{}", pretty(&report_json(&reports, &opts))); } else { print_human(&reports, &opts); } - if reports.iter().all(FileReport::is_clean) { ExitCode::SUCCESS } else { ExitCode::FAILURE } + if !files.is_empty() && reports.iter().all(FileReport::is_clean) { ExitCode::SUCCESS } else { ExitCode::FAILURE } +} + +#[derive(Default)] +struct Discovery { + files: Vec, + failures: Vec, +} + +impl Discovery { + fn fail(&mut self, path: &Path, error: String) { + self.failures.push(FileReport { path: path.to_string_lossy().into_owned(), error: Some(error), ..Default::default() }); + } + + /// False means the file budget is exhausted: the remaining scan is incomplete. + fn add_file(&mut self, path: PathBuf) -> bool { + if self.files.len() >= MAX_FILES { + self.fail(&path, format!("file limit of {MAX_FILES} reached; this and remaining inputs were not checked")); + return false; + } + self.files.push(path); + true + } } /// Files named on the command line are checked whatever their extension; directories contribute /// their `.xlsx`/`.xlsm` files, recursively, in name order. Hidden files and Excel lock files /// (`~$…`) are skipped. -fn collect_inputs(inputs: &[String]) -> Vec { - let mut out = Vec::new(); +fn collect_inputs(inputs: &[String]) -> Discovery { + let mut out = Discovery::default(); for i in inputs { let p = PathBuf::from(i); if p.is_dir() { - walk(&p, 0, &mut out); - } else { - // A missing path is reported as a file that failed to open. - out.push(p); - } - if out.len() >= MAX_FILES { - out.truncate(MAX_FILES); + if !walk(&p, 0, &mut out) { + break; + } + } else if !out.add_file(p) { break; } } + if out.files.is_empty() + && out.failures.is_empty() + && let Some(input) = inputs.first() + { + out.fail(Path::new(input), "no workbook files were found in the requested inputs".into()); + } out } -fn walk(dir: &Path, depth: usize, out: &mut Vec) { - if depth > MAX_DEPTH || out.len() >= MAX_FILES { - return; +/// Returns false only when the global file budget stops traversal. +fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool { + if depth > MAX_DEPTH { + out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked")); + return true; + } + let rd = match std::fs::read_dir(dir) { + Ok(rd) => rd, + Err(e) => { + out.fail(dir, format!("cannot read directory: {e}")); + return true; + } + }; + let mut entries = Vec::new(); + for entry in rd { + match entry { + Ok(entry) => entries.push(entry.path()), + Err(e) => out.fail(dir, format!("cannot read directory entry: {e}")), + } } - let Ok(rd) = std::fs::read_dir(dir) else { return }; - let mut entries: Vec = rd.flatten().map(|e| e.path()).collect(); entries.sort(); for p in entries { let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); if name.starts_with('.') || name.starts_with("~$") { continue; } - if p.is_dir() { - walk(&p, depth + 1, out); - } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) { - out.push(p); - } - if out.len() >= MAX_FILES { - return; + let metadata = match p.metadata() { + Ok(metadata) => metadata, + Err(e) => { + out.fail(&p, format!("cannot inspect input: {e}")); + continue; + } + }; + if metadata.is_dir() { + if !walk(&p, depth + 1, out) { + return false; + } + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) + && !out.add_file(p) + { + return false; } } + true } /// Checks one file's bytes. Never fails: problems are recorded in the report. A panic escaping @@ -287,7 +333,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport { return report; } }; - report.warnings = warnings.len(); + report.warnings = warnings; let mut fresh = cached.clone(); let mut calc = Calc::new(); calc.recalc_all(&mut fresh); @@ -1270,6 +1316,30 @@ mod tests { use super::*; + #[test] + fn discovery_reports_a_directory_read_error() { + let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id())); + std::fs::write(&path, "not a directory").unwrap(); + let mut discovery = Discovery::default(); + assert!(walk(&path, 0, &mut discovery)); + assert!(discovery.files.is_empty()); + assert_eq!(discovery.failures.len(), 1); + assert_eq!(discovery.failures[0].path, path.to_string_lossy()); + assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory")); + let _ = std::fs::remove_file(path); + } + + #[test] + fn discovery_reports_omissions_only_after_the_file_limit() { + let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() }; + assert!(discovery.add_file(PathBuf::from("last.xlsx"))); + assert!(discovery.failures.is_empty()); + assert!(!discovery.add_file(PathBuf::from("omitted.xlsx"))); + assert_eq!(discovery.files.len(), MAX_FILES); + assert_eq!(discovery.failures[0].path, "omitted.xlsx"); + assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit")); + } + fn at(a1: &str) -> CellRef { CellRef::parse(a1).unwrap() } @@ -1600,6 +1670,15 @@ mod tests { assert_eq!(package_of(&source), ["lost relationship madeUp"]); } + #[test] + fn json_lists_warning_messages() { + let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() }; + let without = FileReport { path: "b.xlsx".into(), ..Default::default() }; + let v = report_json(&[with, without], &Options::default()); + assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"])); + assert_eq!(v["files"][1]["warnings"], json!([])); + } + #[test] fn unopenable_files_are_reported() { let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default()); diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 152ddbcbe..0b763e378 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -36,7 +36,7 @@ use serde_json::{Value, json}; const USAGE: &str = "usage: gridcraft-cli info [--json] sheets, used ranges, tables, charts, names - gridcraft-cli convert [--sheet NAME] xlsx/csv/tsv/json/html by extension + gridcraft-cli convert [--sheet NAME] xlsx/csv/tsv/json/html/pdf by extension gridcraft-cli eval [--in FILE] [--sheet S] [--cell A1] [--json] gridcraft-cli cat [--range A1:D20] [--sheet S] [--formulas] [--csv] gridcraft-cli check ... [--json] [--tolerance 1e-9] [--max-report N] @@ -271,11 +271,17 @@ fn convert(args: &[String]) -> Result<(), String> { let input = a.pos(0, "")?; let out = a.pos(1, "")?; a.no_extra(2)?; - if gridcraft_engine::io::FileKind::from_path(out).is_none() { - return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json or .html)")); + let pdf = out.to_ascii_lowercase().ends_with(".pdf"); + if !pdf && gridcraft_engine::io::FileKind::from_path(out).is_none() { + return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json, .html or .pdf)")); } let mut s = open(input)?; activate_sheet(&mut s, a.opt("sheet"))?; + if pdf { + let result = exec(&mut s, "file.exportPdf", json!({"path": out}))?; + eprintln!("wrote {out} ({} bytes, {} pages)", result["bytes"], result["pages"]); + return Ok(()); + } let d = s.doc().map_err(|e| e.to_string())?; let bytes = gridcraft_engine::io::save_file_bytes(&d.wb, out).map_err(|e| e.to_string())?; gridcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?; @@ -583,4 +589,21 @@ mod tests { assert!(Args::parse(&["--nope".to_string()], &[], &[]).is_err()); assert!(Args::parse(&["--range".to_string()], &["range"], &[]).is_err()); } + + #[test] + fn convert_xlsx_to_pdf_keeps_links() { + let dir = std::env::temp_dir().join(format!("gridcraft-cli-pdf-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let input = dir.join("links.xlsx"); + let output = dir.join("links.pdf"); + let mut s = blank(); + s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org"})).unwrap(); + let bytes = gridcraft_engine::io::save_file_bytes(&s.doc().unwrap().wb, input.to_str().unwrap()).unwrap(); + gridcraft_engine::io::write_file(input.to_str().unwrap(), &bytes).unwrap(); + convert(&[input.to_string_lossy().into_owned(), output.to_string_lossy().into_owned()]).unwrap(); + let pdf = std::fs::read(&output).unwrap(); + assert!(pdf.starts_with(b"%PDF-")); + assert!(pdf.windows(b"/URI (https://example.org)".len()).any(|w| w == b"/URI (https://example.org)")); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/apps/gridcraft-cli/tests/cli.rs b/apps/gridcraft-cli/tests/cli.rs index fb25b6320..3105819ec 100644 --- a/apps/gridcraft-cli/tests/cli.rs +++ b/apps/gridcraft-cli/tests/cli.rs @@ -174,3 +174,35 @@ fn check_exit_codes_and_json() { assert_eq!(run(&["check", book_s, "--tolerance", "x"]).status.code(), Some(2)); assert_eq!(run(&["check", book_s, "--bogus"]).status.code(), Some(2)); } + +#[test] +fn check_rejects_an_empty_scan() { + let dir = tmpdir("check-empty"); + std::fs::write(dir.join("notes.txt"), "not a workbook").unwrap(); + std::fs::write(dir.join(".hidden.xlsx"), "ignored").unwrap(); + std::fs::write(dir.join("~$locked.xlsx"), "ignored").unwrap(); + let out = run(&["check", dir.to_str().unwrap(), "--json"]); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!(report["files"][0]["error"].as_str().unwrap().contains("no workbook files")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn check_reports_a_depth_limited_scan_while_checking_readable_files() { + let dir = tmpdir("check-depth"); + let book = dir.join("readable.xlsx"); + ok(&["run", "--out", book.to_str().unwrap(), "--quiet"]); + let mut deep = dir.clone(); + for _ in 0..34 { + deep.push("d"); + } + std::fs::create_dir_all(&deep).unwrap(); + std::fs::write(deep.join("skipped.xlsx"), "not reached").unwrap(); + let out = run(&["check", dir.to_str().unwrap(), "--json"]); + assert_eq!(out.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["totals"]["opened"], 1); + assert!(report["files"].as_array().unwrap().iter().any(|r| r["error"].as_str().is_some_and(|e| e.contains("depth limit")))); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index eac6c1354..41059d379 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage"] } diff --git a/apps/gridcraft-web/src/main.rs b/apps/gridcraft-web/src/main.rs index 2920a872d..36f3f3eca 100644 --- a/apps/gridcraft-web/src/main.rs +++ b/apps/gridcraft-web/src/main.rs @@ -11,11 +11,14 @@ //! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample; //! `?host=parent` (inside an `