diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..2408084cf --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2c4b8cb1a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77cde0cec..c704eb4d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,7 @@ -# The AGENTS.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers, -# wasm, parity --check) on every pull request and every push to main, so a merge burst can't land -# a red tree that only the next agent session discovers. name: CI +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. on: push: branches: [main] @@ -12,37 +11,18 @@ on: permissions: contents: read +# A newer push to a PR branch cancels its older run; every push to main runs to the end. concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: gate: - name: cargo xtask ci - runs-on: ubuntu-24.04 - timeout-minutes: 60 - env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: "0" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Linux build dependencies (winit, wgpu, rfd) - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \ - libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers - - - name: Install Rust (stable + clippy, rustfmt, wasm32) - run: | - rustup toolchain install stable --profile minimal --component clippy,rustfmt \ - --target wasm32-unknown-unknown - rustup default stable - - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Quality gate - run: cargo xtask ci + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship. + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev + libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers diff --git a/.github/workflows/flatpak-pages.yml b/.github/workflows/flatpak-pages.yml deleted file mode 100644 index cd8f12ab3..000000000 --- a/.github/workflows/flatpak-pages.yml +++ /dev/null @@ -1,176 +0,0 @@ -name: Publish Flatpak repository to Pages - -on: - release: - types: [published] - workflow_dispatch: - inputs: - release_tag: - description: "Published GitHub release tag to publish (for example, v1.2.3)" - required: true - type: string - -permissions: - contents: read - -concurrency: - group: flatpak-pages-${{ github.repository }} - cancel-in-progress: false - -jobs: - build: - name: Build Flatpak repository - # Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped - # here, and the validation step below refuses a manual tag with a pre-release suffix. - if: github.event_name != 'release' || !github.event.release.prerelease - runs-on: ubuntu-24.04 - permissions: - contents: read - # configure-pages only reads the Pages configuration (base_url); deploy needs write. - pages: read - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} - APP_ID: ai.storyteller.gridcraft - steps: - - name: Configure Pages - id: pages - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - - name: Install Flatpak repository tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends flatpak ostree - - - name: Download and validate release bundles - shell: bash - run: | - set -euo pipefail - tag="$RELEASE_TAG" - if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::Release tag '$tag' is not strict SemVer prefixed with v" - exit 1 - fi - # A pre-release has a "-" before any "+build" metadata (v1.2.3-rc.1). - case "${tag%%+*}" in - *-*) - echo "::error::$tag is a pre-release; only stable releases are published to the Flatpak repository" - exit 1 - ;; - esac - published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \ - 'select(.draft == false and .prerelease == false and .published_at != null) | .tag_name')" - if [[ "$published_tag" != "$tag" ]]; then - echo "::error::$tag is not a published stable (non-pre-release) release in $GITHUB_REPOSITORY" - exit 1 - fi - - version="${tag#v}" - mkdir -p bundles - gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ - --pattern SHA256SUMS.txt --dir bundles - for arch in x86_64 aarch64; do - asset="gridcraft-$version-linux-$arch.flatpak" - gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ - --pattern "$asset" --dir bundles - test -s "bundles/$asset" - ( - cd bundles - awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \ - SHA256SUMS.txt | sha256sum --check - - ) - done - test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2 - - for arch in x86_64 aarch64; do - asset="bundles/gridcraft-$version-linux-$arch.flatpak" - validation_repo="$(mktemp -d)" - ostree --repo="$validation_repo" init --mode=archive-z2 - flatpak build-import-bundle "$validation_repo" "$asset" - expected_ref="app/$APP_ID/$arch/stable" - ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || { - echo "::error::$(basename "$asset") does not contain $expected_ref" - exit 1 - } - rm -rf "$validation_repo" - done - - - name: Build repository and site - shell: bash - env: - PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }} - run: | - set -euo pipefail - site="$RUNNER_TEMP/site" - repo_dir="$site/flatpak/repo" - mkdir -p "$repo_dir" - ostree --repo="$repo_dir" init --mode=archive-z2 - - previous_url="${PAGES_BASE_URL%/}/flatpak/repo" - if curl --fail --silent --show-error --location \ - --output /dev/null "$previous_url/summary"; then - ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url" - ostree --repo="$repo_dir" pull --mirror previous - ostree --repo="$repo_dir" remote delete previous - fi - - version="${RELEASE_TAG#v}" - for arch in x86_64 aarch64; do - flatpak build-import-bundle "$repo_dir" \ - "bundles/gridcraft-$version-linux-$arch.flatpak" - done - # Prune unreachable objects and keep at most 3 commits per ref, so the Pages repository - # (re-mirrored and re-deployed on every release) does not grow without bound. - flatpak build-update-repo \ - --prune --prune-depth=3 \ - --title="GridCraft" \ - --comment='Official GridCraft releases' \ - --description='Official Flatpak repository for GridCraft' \ - --homepage="https://github.com/$GITHUB_REPOSITORY" \ - --default-branch=stable \ - "$repo_dir" - - for arch in x86_64 aarch64; do - ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null - done - test -s "$repo_dir/summary" - - base_url="${PAGES_BASE_URL%/}" - repo_url="$base_url/flatpak/repo/" - cat >"$site/flatpak/gridcraft.flatpakrepo" <"$site/flatpak/index.html" <GridCraft Flatpak repository

GridCraft Flatpak repository

Latest published release: $RELEASE_TAG

Add the GridCraft repository

flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
-          flatpak install gridcraft $APP_ID
- HTML - cat >"$site/index.html" <GridCraft downloads

GridCraft downloads

Flatpak repository

Release assets

- HTML - touch "$site/.nojekyll" - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: ${{ runner.temp }}/site - include-hidden-files: true - - deploy: - name: Deploy Pages - needs: build - runs-on: ubuntu-24.04 - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 0a82a910f..054491322 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,95 +1,32 @@ # Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner # (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. -# Test builds run only from the `release` branch (a manual run from any other branch is skipped). -# Packaging runs (release.yml calls it that way) run from any branch, so a release.yml dry run -# from a branch builds the tarball too; they need no secrets. -# -# With `package: true` (release.yml calls it that way, or a manual run) it instead builds the -# release binaries and uploads `gridcraft--freebsd-x86_64.tar.gz` as the -# `release-freebsd` artifact (packaging/freebsd/package.sh), which release.yml attaches to the -# draft GitHub Release. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: + push: + branches: [release] + pull_request: + paths: + - .github/workflows/freebsd.yml + - packaging/freebsd/** + - packaging/env.sh + schedule: + - cron: "57 2 * * *" workflow_dispatch: - inputs: - package: - description: "Build the release tarball instead of running the tests" - type: boolean - default: false - workflow_call: - inputs: - version: - description: "Version for the artifact name (empty: Cargo.toml)" - type: string - default: "" - version_override: - description: "Also write the version into Cargo.toml before building (test runs)" - type: boolean - default: false - package: - type: boolean - default: false concurrency: - # Called from release.yml, github.workflow is the caller's name; keep the groups apart. - group: freebsd-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.workflow == 'FreeBSD' }} + group: freebsd-${{ github.ref }} + cancel-in-progress: true permissions: contents: read jobs: freebsd: - name: FreeBSD 14 (${{ inputs.package && 'package' || 'build + test' }}) - if: github.ref == 'refs/heads/release' || inputs.package - runs-on: ubuntu-latest - timeout-minutes: 300 - env: - GRIDCRAFT_VERSION: ${{ inputs.version }} - GRIDCRAFT_VERSION_OVERRIDE: ${{ inputs.version_override && '1' || '' }} - GRIDCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }} - GRIDCRAFT_BUILD_SHA: ${{ github.sha }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - name: Build in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - envs: "GRIDCRAFT_VERSION GRIDCRAFT_VERSION_OVERRIDE GRIDCRAFT_PACKAGE GRIDCRAFT_BUILD_SHA" - # Only the packaging run brings files back (dist/release). The target dir lives outside - # the synced checkout, because copying it out of the VM fails on large builds. - copyback: ${{ inputs.package }} - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - export CARGO_TARGET_DIR=/var/tmp/gridcraft-target CARGO_PROFILE_DEV_DEBUG=0 - rustc --version - if [ -n "$GRIDCRAFT_PACKAGE" ]; then - if [ -n "$GRIDCRAFT_VERSION_OVERRIDE" ]; then - cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION" - fi - CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 bash packaging/freebsd/package.sh - else - cargo build --workspace --all-targets --locked - CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked - fi - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - if: inputs.package - with: - name: release-freebsd - path: dist/release/* - if-no-files-found: error + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main + with: + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 000000000..01cb9cd66 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,23 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..e78fe1ea3 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,21 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +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/AGENTS.md b/AGENTS.md index 963fc6d74..7fd1629cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). - **Bound recursion** (formula nesting, dependency chains, names referring to names). - **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. -- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). +- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). - Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. ## Non-negotiables @@ -37,6 +37,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`). - **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`). diff --git a/CLAUDE.md b/CLAUDE.md index 280fe8bcb..1d1a77d1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). - **Bound recursion** (formula nesting, dependency chains, names referring to names). - **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. -- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). +- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). - Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. ## Non-negotiables diff --git a/Cargo.lock b/Cargo.lock index 6d618a02a..7f5203188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -619,6 +619,15 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "cipher" version = "0.5.2" @@ -1496,9 +1505,10 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.7.0" +version = "0.11.1" dependencies = [ "arboard", + "chrono", "craft-print", "eframe", "egui", @@ -1518,7 +1528,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.7.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1530,7 +1540,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.7.0" +version = "0.11.1" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1541,7 +1551,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.7.0" +version = "0.11.1" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1553,8 +1563,9 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.7.0" +version = "0.11.1" dependencies = [ + "chrono", "js-sys", "serde", "serde_json", @@ -1563,7 +1574,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.7.0" +version = "0.11.1" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1584,7 +1595,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.7.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "serde", @@ -1593,7 +1604,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.7.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "regex", @@ -1601,7 +1612,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.7.0" +version = "0.11.1" dependencies = [ "gridcraft-engine", "log", @@ -1610,7 +1621,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.7.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1622,18 +1633,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.7.0" +version = "0.11.1" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.7.0" +version = "0.11.1" [[package]] name = "gridcraft-ui-egui" -version = "0.7.0" +version = "0.11.1" dependencies = [ "craft-ui", "egui", @@ -1655,7 +1666,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.7.0" +version = "0.11.1" dependencies = [ "eframe", "egui", @@ -1672,7 +1683,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.7.0" +version = "0.11.1" dependencies = [ "aes", "gridcraft-core", @@ -4526,7 +4537,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.7.0" +version = "0.11.1" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 98dd572e0..4d3fd4b83 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.7.0" +version = "0.11.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" @@ -30,6 +30,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } quick-xml = "0.41" @@ -66,9 +68,11 @@ type_complexity = "allow" [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 678495f1d..d9dc5eb36 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,18 @@

+

+ GridCraft icon: a spreadsheet page on a green tile +

+

GridCraft

- Spreadsheets and data; an open-source, clean-room reimplementation of Microsoft Excel, rebuilt in pure Rust. + Spreadsheets and data; an open-source, high-performance modern spreadsheet application, built in pure Rust.

- A fast, open-source, clean-room take on the Excel workflow. It runs natively on macOS, + A fast, open-source, clean-room take on modern spreadsheet workflows. It runs natively on macOS, Windows, Linux and BSD, and in the browser via WebAssembly.
By the ArtCraft team.

@@ -24,6 +28,9 @@ Runs on macOS, Windows, Linux, BSD and the web License: MIT OR Apache-2.0 Agent-drivable over MCP +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -67,8 +74,8 @@ A gradebook table with averages and letter grades computed with IFS, green highlights on A grades and data bars, with the Formulas ribbon tab open showing the function library

Formulas. 500+ functions, dynamic arrays, LET and LAMBDA, structured table references.

-The sales dashboard in dark mode with the Insert ribbon tab open, showing PivotTable, Table, Pictures, Shapes, chart, sparkline, link, comment, text box and symbol buttons

Dark mode and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments.

-A PivotTable summarising revenue by region and product with a Channel report filter, grand totals and currency formatting, with the PivotTable Fields pane open on the right showing ticked fields and the Filters, Columns, Rows and Values areas

PivotTables. Field list, filters, layouts, date grouping and show-values-as, saved to and read from XLSX.

+The sales dashboard in dark mode with the Insert ribbon tab open, showing Pivot Table, Table, Pictures, Shapes, chart, sparkline, link, comment, text box and symbol buttons

Dark mode and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments.

+A pivot table summarising revenue by region and product with a Channel report filter, grand totals and currency formatting, with the Pivot Table Fields pane open on the right showing ticked fields and the Filters, Columns, Rows and Values areas

Pivot tables. Field list, filters, layouts, date grouping and show-values-as, saved to and read from XLSX.

The sales dashboard with the column chart selected: resize handles on the chart, its source data outlined in the table, the Chart Design tab open and the Format Chart Area pane on the right

Charts. Seventeen chart types, Chart Design tab and a Format pane; selecting a chart outlines its source data.

@@ -78,19 +85,19 @@ ## Why GridCraft -- **Familiar.** Excel's ribbon, formula bar, Name Box, sheet tabs, keyboard shortcuts, fill - handle, point-mode formula entry with coloured references, Format Cells, Paste Special, - conditional formatting, tables, charts and more. If you know Excel, you already know GridCraft. +- **Familiar.** Ribbon, formula bar, Name Box, sheet tabs, keyboard shortcuts, fill handle, + point-mode formula entry with coloured references, Format Cells, Paste Special, + conditional formatting, tables, charts and more. Everything is right where you expect it. - **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays), tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines, - PivotTables and print settings round-trip. CSV and TSV too. - Excel 97-2003 `.xls` workbooks open with their values, formulas and number formats (formatting and objects not yet). + pivot tables and print settings round-trip. CSV and TSV too. + Legacy 97-2003 `.xls` workbooks open with their values, formulas and number formats (formatting and objects not yet). [XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas. [ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas. [Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data. - **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling, 500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY, - structured table references, Excel's full number-format language, sort and AutoFilter, + structured table references, a comprehensive number-format language, sort and AutoFilter, Flash Fill, data validation, outlining and subtotals. - **Fast.** Copy-on-write workbooks (undo snapshots are nearly free), incremental recalculation, and a virtualised grid that only lays out what's on screen. @@ -119,7 +126,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 @@ -187,7 +194,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the | L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV, plus XLSB and ODS data import) | | L3 | `chart` (toolkit-free chart layout and rendering) | | L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) | -| L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) | +| L6 | `ui-egui` (spreadsheet UI, control channel) · `mcp` (MCP server) | | L7 | `apps/gridcraft`, `apps/gridcraft-cli`, `apps/gridcraft-web` | - **Contributor and agent rules** (clean-room, asset policy, never-crash, quality gates): [`AGENTS.md`](AGENTS.md) @@ -197,9 +204,9 @@ egui frontend is a separate crate, so the UI can be swapped without touching the ## Roadmap GridCraft is in **alpha**: the core workflow works end to end, but depth, fidelity and file -compatibility are still rough. It covers 89% of the Excel ribbon commands in our catalog -([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 of Excel's 523 worksheet functions -([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we +compatibility are still rough. It covers 89% of standard ribbon commands in our catalog +([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 standard worksheet functions +([`docs/function-parity.md`](docs/function-parity.md)); this is a parity inventory, not a count of working implementations. Weighted by what spreadsheet users rely on, we estimate it is about 50% ready for real work. Next up: XLSX fidelity on real-world files, very large workbooks and multi-threaded recalc, chart depth, text in every script, and `.xls` import. Stage, numbers and estimates are in [ROADMAP.md](ROADMAP.md); the ranked work list is [`docs/gaps.md`](docs/gaps.md). @@ -236,7 +243,7 @@ bring back a crash in some Intel UHD Graphics drivers (`igvk64.dll`, #120). The web backend defaults are unchanged. **If the app is slow on an older Windows PC, or DirectX 12 can't start:** when DirectX 12 finds no -graphics card with a driver for it, it only offers Microsoft's software renderer, which draws every +graphics card with a driver for it, it only offers the Windows-provided software renderer, which draws every frame on the processor. In that case GridCraft uses OpenGL instead, without setting anything; if the first renderer fails to start, it tries the other one once. The log file records the choice (lines starting with `graphics:`). `WGPU_BACKEND` still overrides it (and then nothing else is @@ -334,8 +341,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and GridCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Microsoft, Excel and Microsoft 365 are trademarks or registered trademarks of Microsoft Corporation in the United States and/or other countries. GridCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation; these names are used only to describe the workflows it is compatible with. -

diff --git a/ROADMAP.md b/ROADMAP.md index fba2c6a35..591d6a5c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (lookups compare numbers exactly, like Excel) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: formula hardening, #2163/#2162; previously minor: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -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 274 / 290 (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,7 +135,32 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | +| 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 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison | +| 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-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) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -194,7 +222,24 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) | +| 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 | Progress log: lookups compare numbers exactly | +| 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-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) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | 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..972c023fa 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -36,13 +36,15 @@ 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] cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference) gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl] [--out FILE] [--print RANGE] [--quiet] + a script line is id={json} or {\"command\": \"id\", \"params\": {…}}; + blank lines and lines starting with # or // are skipped gridcraft-cli commands [--json] [--search X] gridcraft-cli functions [--json] [--search X] [--category C] gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME] @@ -271,11 +273,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())?; @@ -369,6 +377,17 @@ fn parse_command(text: &str) -> Result, String> { } if t.starts_with('{') { let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))?; + // A key this does not read is a mistake, not a comment: `{"command": …, "args": {…}}` and + // `{"command": …, "name": "Report"}` ran the command with no parameters at all and the + // script still exited 0, so a typo made a valid, wrong workbook (#2256). + if let Some(o) = v.as_object() { + let mut unknown: Vec<&str> = o.keys().map(String::as_str).filter(|k| !matches!(*k, "command" | "id" | "params")).collect(); + if !unknown.is_empty() { + unknown.sort_unstable(); + let (plural, list) = (if unknown.len() == 1 { "key" } else { "keys" }, unknown.join("`, `")); + return Err(format!("unknown {plural} `{list}`: a line is {{\"command\": \"id\", \"params\": {{…}}}}")); + } + } let id = v.get("command").or_else(|| v.get("id")).and_then(Value::as_str).ok_or("JSON command needs a `command` field")?; return Ok(Some((id.to_string(), v.get("params").cloned().unwrap_or_else(|| json!({}))))); } @@ -561,6 +580,26 @@ mod tests { assert_eq!(parse_command("{\"command\":\"edit.redo\"}").unwrap(), Some(("edit.redo".into(), json!({})))); assert_eq!(parse_command(" # comment").unwrap(), None); assert!(parse_command("x={bad").is_err()); + // A key this does not read is a mistake: the command used to run with no parameters and + // the script still exited 0, so a typo made a valid, wrong workbook (#2256). + for (line, named) in [ + (r#"{"command":"home.insertSheet","args":{"name":"R"}}"#, "`args`"), + (r#"{"command":"home.insertSheet","name":"R"}"#, "`name`"), + (r#"{"command":"home.insertSheet","params":{},"note":"why"}"#, "`note`"), + ] { + let e = parse_command(line).expect_err("refused"); + assert!(e.contains("unknown key") && e.contains(named), "{line}: {e}"); + assert!(e.contains(r#"{"command": "id", "params": {…}}"#), "{line}: {e}"); + } + // Several at once are listed in one message, sorted. + let e = parse_command(r#"{"command":"x","name":"R","args":{}}"#).expect_err("refused"); + assert!(e.contains("unknown keys `args`, `name`"), "{e}"); + // `id` is still the accepted alternative to `command`, and `params` still carries them. + assert_eq!(parse_command(r#"{"id":"edit.undo"}"#).unwrap(), Some(("edit.undo".into(), json!({})))); + assert_eq!( + parse_command(r#"{"command":"home.insertSheet","params":{"name":"R"}}"#).unwrap(), + Some(("home.insertSheet".into(), json!({"name": "R"}))) + ); } #[test] @@ -583,4 +622,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/it/cli.rs similarity index 66% rename from apps/gridcraft-cli/tests/cli.rs rename to apps/gridcraft-cli/tests/it/cli.rs index fb25b6320..dde018ca8 100644 --- a/apps/gridcraft-cli/tests/cli.rs +++ b/apps/gridcraft-cli/tests/it/cli.rs @@ -1,5 +1,3 @@ -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use std::io::Write; use std::path::PathBuf; use std::process::{Command, Output, Stdio}; @@ -174,3 +172,73 @@ 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); +} + +/// A script line whose parameters are under a key `run` does not read used to run the command +/// with no parameters at all, and the run still exited 0: three mistaken lines made Sheet2, +/// Sheet3 and Sheet4 instead of the named sheets, and nothing said so (#2256). +#[test] +fn a_script_line_with_an_unknown_key_stops_the_run() { + let dir = tmpdir("script-keys"); + let script = dir.join("s.jsonl"); + let out = dir.join("t.xlsx"); + let write = |lines: &[&str]| std::fs::write(&script, lines.join("\n")).unwrap(); + + write(&[r#"{"command":"file.new"}"#, r#"{"command":"home.insertSheet","args":{"name":"Report"}}"#]); + let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]); + let err = String::from_utf8_lossy(&o.stderr); + assert!(!o.status.success(), "the run must stop: {err}"); + assert!(err.contains("s.jsonl:2"), "it names the line: {err}"); + assert!(err.contains("unknown key `args`"), "and the key: {err}"); + assert!(!out.exists(), "and writes no workbook: {err}"); + + // The forms that are read still work, in one script: `params`, `id=json`, and a comment. + write(&[ + r#"{"command":"file.new"}"#, + r#"{"command":"home.insertSheet","params":{"name":"Report"}}"#, + "# a comment", + r#"home.insertSheet={"name":"Report2"}"#, + ]); + let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let info = ok(&["info", out.to_str().unwrap()]); + assert!(info.contains("Report:") && info.contains("Report2:"), "the sheets are named: {info}"); + assert!(!info.contains("Sheet2"), "and no sheet was added with a default name: {info}"); + + // The usage text says what a line looks like, which is how this was found. + let usage = String::from_utf8_lossy(&run(&[]).stderr).into_owned() + &String::from_utf8_lossy(&run(&["--help"]).stdout); + assert!(usage.contains(r#"{"command": "id", "params": {…}}"#), "{usage}"); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/gridcraft-cli/tests/it/main.rs b/apps/gridcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..17f8db87f --- /dev/null +++ b/apps/gridcraft-cli/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, one module per file, built as one test binary. + +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod cli; +mod text_to_columns_quotes; diff --git a/apps/gridcraft-cli/tests/text_to_columns_quotes.rs b/apps/gridcraft-cli/tests/it/text_to_columns_quotes.rs similarity index 100% rename from apps/gridcraft-cli/tests/text_to_columns_quotes.rs rename to apps/gridcraft-cli/tests/it/text_to_columns_quotes.rs 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/index.html b/apps/gridcraft-web/index.html index 5fdd1ea54..321aa302e 100644 --- a/apps/gridcraft-web/index.html +++ b/apps/gridcraft-web/index.html @@ -2,7 +2,7 @@ - + GridCraft 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 `