From 4d207f0c3f326a6c54cbf7de54b94a4bfe39025d Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:18:55 +0100 Subject: [PATCH 001/122] a11y job: scan stories when their component changes; fix cold-start false failures (#7191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Two fixes to the pull-request a11y job (#7086 follow-up), both found on its first day live. ### It now scans a component's stories when the component changes The job picked its scan set from changed **story files** alone. But a story renders the live component — editing `Button.tsx` changes what every Button story shows without touching a story file, and the job scanned nothing. That's the common way a11y regressions arrive, and it was exactly the case the job missed. The scan set now also includes stories whose **same-named sibling source file changed**: edit `Button.tsx` or `Button.css` and `Button.stories.tsx` is scanned. Changes that ripple further than a component's own stories (shared UI, theme tokens) remain the nightly sweep's job. ### It no longer fails on cold-start infrastructure noise The job's first real run (#7163) flagged a story as "failed to render". The story was fine — on a cold dependency cache (**every** CI run), Vite discovered the preview's own dependency graph mid-run and reloaded the page, killing whichever story happened to be loading with `Failed to fetch dynamically imported module`. Reproduced on a cold cache, passes on a warm one. - The preview's deps are named in `optimizeDeps.include`, which removes the mid-run reload (verified cold). - A batch whose report contains crash-class failures (failures carrying no axe rule) is retried once — a one-off infrastructure death passes the retry, a story that genuinely can't render fails both attempts and is still reported. Also: the scan-report artifacts were never actually uploading — they live in a dot-directory, which `upload-artifact` silently skips as hidden by default. `include-hidden-files: true` fixes that for the PR job and the nightly, so a red run finally has its evidence attached. ### The glue is Node now, so tasks work from any shell Raised in review: the pipeline leaned on `bash`, `sed`, `grep`, `sort` and `tr`. Task runs its commands in an embedded POSIX interpreter, but those are external binaries it has to find on PATH — and a Windows dev calling tasks from **PowerShell** has none of them (`sed`/`tr` missing outright, `sort` resolves to Windows' own, and `bash` resolves to *WSL's*). Confirmed broken by running the task from PowerShell before the change. The batch runner and affected-story detection are now small Node scripts (`a11y-scan.mjs`, `a11y-changed.mjs`) — the repo already requires Node, so one implementation serves PowerShell, git-bash and CI alike, instead of maintaining `.sh`/`.ps1` twins. ## Testing - Sibling detection: editing `Tabs.tsx` (component only) pulls `Tabs.stories.tsx` into the scan set; editing a `.css` sibling does the same; nothing unrelated leaks in. - **From PowerShell**: `task frontend:storybook:a11y:changed` early-exits cleanly with no changes, and with a component edit it detects the sibling, runs the browser scan and passes the gate — same result from git-bash. - Cold cache end-to-end: cleared both Vite caches, ran the scan — no re-optimize, no reload, stories fail only on their (baselined) axe results. - Crash classifier: 1 on a synthetic crash report, 0 on axe-only failures, 0 on a real report — so the retry can't be triggered by legitimate violations. - Full scan + gate run green end-to-end; taskfile parses, workflows are valid YAML, Prettier/ESLint pass. #7163's red check needs no action from that PR's author — it should go green on re-run once this lands. --- .github/workflows/frontend-a11y.yml | 3 + .github/workflows/nightly.yml | 3 + .taskfiles/frontend.yml | 28 ++-- frontend/.storybook/a11y-changed.mjs | 48 +++++++ frontend/.storybook/a11y-scan.mjs | 197 +++++++++++++++++++++++++++ frontend/.storybook/a11y-scan.sh | 84 ------------ frontend/.storybook/vitest.config.ts | 26 +++- 7 files changed, 285 insertions(+), 104 deletions(-) create mode 100644 frontend/.storybook/a11y-changed.mjs create mode 100644 frontend/.storybook/a11y-scan.mjs delete mode 100644 frontend/.storybook/a11y-scan.sh diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index d763447048..f4d3e3b93a 100644 --- a/.github/workflows/frontend-a11y.yml +++ b/.github/workflows/frontend-a11y.yml @@ -55,3 +55,6 @@ jobs: path: frontend/.a11y-scan/ retention-days: 7 if-no-files-found: ignore + # The reports live in a dot-directory, which upload-artifact treats as + # hidden and silently skips by default. + include-hidden-files: true diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 301a1fc8df..50346a6a39 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -92,6 +92,9 @@ jobs: path: frontend/.a11y-scan/ retention-days: 14 if-no-files-found: ignore + # The reports live in a dot-directory, which upload-artifact treats as + # hidden and silently skips by default. + include-hidden-files: true # Builds all desktop platforms on a schedule so the Rust dependency cache is # written on main, where PR and merge-queue tauri builds can restore it. diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 1338aae8e1..8c32efc5e3 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -214,42 +214,42 @@ tasks: desc: "a11y regression gate over every story: fail only on NEW axe violations" deps: [install, storybook:browser] cmds: - - bash .storybook/a11y-scan.sh + - node .storybook/a11y-scan.mjs - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt storybook:a11y:changed: - desc: "a11y gate over stories changed vs a base ref (default origin/main)" + desc: "a11y gate over the stories this branch affects (default base origin/main)" summary: | - Scans only the stories this branch touches, which is what pull requests - run — a full scan takes ~30 minutes, far too long to sit in front of every - merge. The nightly job covers the rest of the suite. + Scans the stories a branch affects, which is what pull requests run — a + full scan takes ~30 minutes, far too long to sit in front of every merge. + A story is affected if its file changed, or if a same-named sibling + source file changed (editing Button.tsx or Button.css re-scans + Button.stories.tsx — the story renders the live component, so a component + edit changes what the story shows without touching the story file). + Changes that ripple further than a component's own stories are covered by + the nightly full sweep. Pass a base ref through CLI_ARGS, e.g. task frontend:storybook:a11y:changed -- origin/release deps: [install, storybook:browser] vars: BASE: '{{.CLI_ARGS | default "origin/main"}}' - # Stories touched by this branch, plus any not yet committed. CHANGED: - sh: | - { git diff --name-only --diff-filter=d {{.CLI_ARGS | default "origin/main"}}...HEAD -- '*.stories.ts' '*.stories.tsx'; - git diff --name-only --diff-filter=d -- '*.stories.ts' '*.stories.tsx'; - git ls-files --others --exclude-standard -- '*.stories.ts' '*.stories.tsx'; } \ - | sed 's|^frontend/||' | sort -u | tr '\n' ' ' + sh: node .storybook/a11y-changed.mjs {{.CLI_ARGS | default "origin/main"}} cmds: - cmd: | if [ -z "{{.CHANGED}}" ]; then - echo "a11y: no story files changed vs {{.BASE}} — nothing to check" + echo "a11y: no story files affected vs {{.BASE}} — nothing to check" exit 0 fi - bash .storybook/a11y-scan.sh {{.CHANGED}} + node .storybook/a11y-scan.mjs {{.CHANGED}} node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt storybook:a11y:record: desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)" deps: [install, storybook:browser] cmds: - - bash .storybook/a11y-scan.sh + - node .storybook/a11y-scan.mjs - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record # ============================================================ diff --git a/frontend/.storybook/a11y-changed.mjs b/frontend/.storybook/a11y-changed.mjs new file mode 100644 index 0000000000..6aacf6274c --- /dev/null +++ b/frontend/.storybook/a11y-changed.mjs @@ -0,0 +1,48 @@ +// Prints the story files a branch affects, one per line — the scan set for the +// pull-request a11y gate. A story is affected if its file changed against the +// base ref (or is uncommitted/untracked), or if a same-named sibling source +// file changed: stories render the live component, so editing Button.tsx or +// Button.css changes what Button.stories.tsx shows without touching it. +// +// node a11y-changed.mjs [base-ref] (default origin/main) +// +// Node rather than shell so the task works no matter what invokes it — Task's +// embedded interpreter runs on Windows, but sed/grep/sort do not exist for +// developers calling tasks from PowerShell. +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +const base = process.argv[2] || "origin/main"; + +const git = (...args) => + execFileSync("git", args, { encoding: "utf8" }) + .split("\n") + .map((l) => l.trim().replace(/^frontend\//, "")) + .filter(Boolean); + +const STORY = /\.stories\.tsx?$/; +const TEST = /\.test\.tsx?$/; +const SOURCE = /\.(ts|tsx|css)$/; + +// Committed changes vs the base, plus working-tree changes, plus untracked +// files — so the gate covers exactly what the branch would merge and what a +// developer is about to commit. +const changed = [ + ...git("diff", "--name-only", "--diff-filter=d", `${base}...HEAD`), + ...git("diff", "--name-only", "--diff-filter=d"), + ...git("ls-files", "--others", "--exclude-standard"), +]; + +const stories = new Set(); +for (const f of changed) { + if (STORY.test(f)) { + stories.add(f); + continue; + } + if (TEST.test(f) || !SOURCE.test(f)) continue; + const sibling = f.replace(SOURCE, ""); + for (const s of [`${sibling}.stories.tsx`, `${sibling}.stories.ts`]) + if (existsSync(s)) stories.add(s); +} + +process.stdout.write([...stories].sort().join("\n")); diff --git a/frontend/.storybook/a11y-scan.mjs b/frontend/.storybook/a11y-scan.mjs new file mode 100644 index 0000000000..77aed79d70 --- /dev/null +++ b/frontend/.storybook/a11y-scan.mjs @@ -0,0 +1,197 @@ +// Runs the Storybook Vitest scan in batches and emits one JSON report per batch +// into .a11y-scan/, plus a manifest of every story file the run was supposed to +// cover and a log of the raw output. Consumed by a11y-check.mjs, which fails if +// any manifest entry produced no results. Run from frontend/. +// +// node a11y-scan.mjs scan every story +// node a11y-scan.mjs [file…] scan only these story files +// +// Batching keeps each browser session small: a single run over the whole story +// set holds one Chromium context open for the entire scan, so one crash in it +// costs every story after that point. +// +// Node rather than shell so the task works no matter what invokes it — Task's +// embedded interpreter runs on Windows, but bash/sed/sort do not exist for +// developers calling tasks from PowerShell. +import { execFileSync, spawn } from "node:child_process"; +import { + existsSync, + mkdirSync, + openSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +const OUT = ".a11y-scan"; +const CHUNK = 20; +const BATCH_TIMEOUT_MS = 300_000; + +const git = (...args) => + execFileSync("git", args, { encoding: "utf8" }) + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + +rmSync(OUT, { recursive: true, force: true }); +mkdirSync(OUT, { recursive: true }); +const manifestFile = join(OUT, "manifest.txt"); +const logFile = join(OUT, "scan.log"); + +const args = process.argv.slice(2); +let files; +if (args.length > 0) { + // Explicit list (the pull-request path passes just the stories a branch + // affects). Anything that no longer exists is dropped, so a deleted story + // doesn't fail the manifest check. + files = [...new Set(args.filter((f) => existsSync(f)))].sort(); + if (files.length === 0) { + console.log( + "a11y-scan: no existing story files in the given list — nothing to scan", + ); + writeFileSync(manifestFile, ""); + process.exit(0); + } +} else { + // Tracked story files plus any not yet committed, so a new story can be + // checked before it is added to the index. + files = [ + ...new Set([ + ...git( + "ls-files", + "--", + "editor/src/**/*.stories.ts", + "editor/src/**/*.stories.tsx", + ), + ...git( + "ls-files", + "--others", + "--exclude-standard", + "--", + "editor/src/**/*.stories.ts", + "editor/src/**/*.stories.tsx", + ), + ]), + ].sort(); + if (files.length === 0) { + console.error("a11y-scan: no story files found — check the glob"); + process.exit(2); + } +} +writeFileSync(manifestFile, files.join("\n") + "\n"); + +/** Failures carrying no axe rule — a throw, a timeout, a dropped browser page. */ +function crashCount(reportFile) { + try { + const report = JSON.parse(readFileSync(reportFile, "utf8")); + let crashes = 0; + for (const tf of report.testResults ?? []) + for (const a of tf.assertionResults ?? []) { + if (a.status === "passed") continue; + const msg = (a.failureMessages ?? []).join("\n"); + if (!/dequeuniversity\.com\/rules\/axe\//.test(msg)) crashes++; + } + return crashes; + } catch { + return -1; // unreadable report counts as a failed attempt + } +} + +/** Runs one vitest batch, tee'd to the log, killed (whole tree) on timeout. */ +function runBatch(filters, outputFile) { + return new Promise((resolve) => { + const cmd = + `npx vitest run --config .storybook/vitest.config.ts ` + + `--reporter=json --outputFile=${outputFile} ` + + filters.map((f) => `"${f}"`).join(" "); + const log = openSync(logFile, "a"); + const child = spawn(cmd, { + shell: true, + detached: process.platform !== "win32", + stdio: ["ignore", log, log], + }); + const timer = setTimeout(() => { + if (process.platform === "win32") { + try { + execFileSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + }); + } catch { + /* already gone */ + } + } else { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + /* already gone */ + } + } + }, BATCH_TIMEOUT_MS); + child.on("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +const hasReport = (f) => existsSync(f) && statSync(f).size > 0; + +const batches = []; +for (let i = 0; i < files.length; i += CHUNK) + batches.push(files.slice(i, i + CHUNK)); +console.log( + `a11y-scan: ${files.length} story files, ${batches.length} batches of ${CHUNK}`, +); + +const failed = []; +for (let bi = 0; bi < batches.length; bi++) { + const n = bi + 1; + const out = join(OUT, `chunk-${n}.json`); + // The scan exits non-zero whenever a story has a violation — expected here, + // so the report is what matters, not the status. + // + // A batch is retried once when it produced no report, or when its report + // contains crash-class failures. A one-off infrastructure death — the + // browser page dropping, a Vite dep re-optimize reloading mid-run — passes + // on the retry; a story that genuinely cannot render fails both attempts. + const filters = batches[bi].map((f) => f.replace(/\.tsx$/, "")); + for (let attempt = 1; attempt <= 2; attempt++) { + await runBatch(filters, out); + if (!hasReport(out)) { + console.error( + `a11y-scan: batch ${n} produced no report (attempt ${attempt})`, + ); + continue; + } + if (attempt === 1) { + const crashes = crashCount(out); + if (crashes !== 0) { + console.error( + `a11y-scan: batch ${n} has ${crashes} crash-class failure(s) — retrying once`, + ); + rmSync(out, { force: true }); + continue; + } + } + break; + } + if (hasReport(out)) console.log(` batch ${n}/${batches.length} done`); + else { + failed.push(n); + console.error(` batch ${n}/${batches.length} FAILED — no report`); + } +} + +const present = batches.filter((_, i) => + hasReport(join(OUT, `chunk-${i + 1}.json`)), +).length; +console.log(`a11y-scan: ${present}/${batches.length} batches produced reports`); +if (failed.length > 0) { + console.error( + `a11y-scan: ${failed.length} batch(es) produced no report: ${failed.join(" ")}`, + ); + console.error(`a11y-scan: see ${logFile}. Not reporting on a partial scan.`); + process.exit(2); +} diff --git a/frontend/.storybook/a11y-scan.sh b/frontend/.storybook/a11y-scan.sh deleted file mode 100644 index 855a4449dc..0000000000 --- a/frontend/.storybook/a11y-scan.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash -# Run the Storybook Vitest scan in batches and emit one JSON report per batch -# into .a11y-scan/, plus a manifest of every story file the run was supposed to -# cover and a log of the raw output. Consumed by a11y-check.mjs, which fails if -# any manifest entry produced no results. Run from frontend/. -# -# a11y-scan.sh scan every story -# a11y-scan.sh [file…] scan only these story files -# -# Batching keeps each browser session small: a single run over the whole story -# set holds one Chromium context open for the entire scan, so one crash in it -# costs every story after that point. -set -uo pipefail -cd "$(dirname "$0")/.." || exit 1 - -OUT=".a11y-scan" -rm -rf "$OUT" -mkdir -p "$OUT" -MANIFEST="$OUT/manifest.txt" -LOG="$OUT/scan.log" - -if [ "$#" -gt 0 ]; then - # Explicit list (the pull-request path passes just the stories a branch - # touched). Anything that no longer exists is dropped, so a deleted story - # doesn't fail the manifest check. - for f in "$@"; do [ -f "$f" ] && printf '%s\n' "$f"; done | sort -u >"$MANIFEST" -else - # Tracked story files plus any not yet committed, so a new story can be - # checked before it is added to the index. - { - git ls-files 'editor/src/**/*.stories.ts' 'editor/src/**/*.stories.tsx' - git ls-files --others --exclude-standard 'editor/src/**/*.stories.ts' \ - 'editor/src/**/*.stories.tsx' - } | sort -u >"$MANIFEST" -fi - -mapfile -t FILES <"$MANIFEST" -TOTAL=${#FILES[@]} -if [ "$TOTAL" -eq 0 ]; then - if [ "$#" -gt 0 ]; then - echo "a11y-scan: no existing story files in the given list — nothing to scan" - exit 0 - fi - echo "a11y-scan: no story files found — check the glob" >&2 - exit 2 -fi - -CHUNK=20 -NB=$(((TOTAL + CHUNK - 1) / CHUNK)) -echo "a11y-scan: $TOTAL story files, $NB batches of $CHUNK" - -failed=() -i=0 -ci=0 -while [ "$i" -lt "$TOTAL" ]; do - ci=$((ci + 1)) - batch=("${FILES[@]:i:CHUNK}") - i=$((i + CHUNK)) - out="$OUT/chunk-$ci.json" - filters=() - for f in "${batch[@]}"; do filters+=("${f%.tsx}"); done - # The scan exits non-zero whenever a story has a violation — expected here, so - # the report is what matters, not the status. Output is teed to the log so a red - # CI run still has the offending selectors and help text to work from. - for attempt in 1 2; do - timeout 300 npx vitest run --config .storybook/vitest.config.ts \ - --reporter=json --outputFile="$out" "${filters[@]}" >>"$LOG" 2>&1 - [ -s "$out" ] && break - echo "a11y-scan: batch $ci produced no report (attempt $attempt)" >&2 - done - if [ -s "$out" ]; then - echo " batch $ci/$NB done" - else - failed+=("$ci") - echo " batch $ci/$NB FAILED — no report" >&2 - fi -done - -echo "a11y-scan: $(ls "$OUT"/chunk-*.json 2>/dev/null | wc -l)/$NB batches produced reports" -if [ ${#failed[@]} -gt 0 ]; then - echo "a11y-scan: ${#failed[@]} batch(es) produced no report: ${failed[*]}" >&2 - echo "a11y-scan: see $LOG. Not reporting on a partial scan." >&2 - exit 2 -fi diff --git a/frontend/.storybook/vitest.config.ts b/frontend/.storybook/vitest.config.ts index 8c0d0e81f2..8519e50267 100644 --- a/frontend/.storybook/vitest.config.ts +++ b/frontend/.storybook/vitest.config.ts @@ -18,18 +18,32 @@ export default defineConfig({ // Pre-scan every story + the preview so Vite discovers the story set's large // dep surface (embedpdf plugins, @mui icons, …) in one pass up front. entries: ["editor/src/**/*.stories.@(ts|tsx)", ".storybook/preview.tsx"], - // `entries` alone does not catch deps reached only through a transformed - // JSX runtime import, so Vite optimizes them lazily mid-run and emits - // "optimized dependencies changed, reloading". That reload tears down the - // browser worker and whichever stories were mid-load fail with a bogus - // "Failed to fetch dynamically imported module" — a scan that then looks - // like a real result. Naming them here keeps a run deterministic. + // `entries` alone does not catch deps reached through a transformed JSX + // runtime import, nor the preview's own dependency graph (the test plugin + // injects the preview in a way the entry scanner doesn't crawl). Vite then + // optimizes them lazily mid-run and emits "optimized dependencies changed, + // reloading" — the reload tears down the browser worker and whichever + // stories were mid-load fail with a bogus "Failed to fetch dynamically + // imported module" that reads like a real crash. Only a cold dep cache + // hits this, which is every CI run. Naming them keeps a run deterministic. include: [ "react", "react/jsx-runtime", "react/jsx-dev-runtime", "react-dom", "react-dom/client", + "@storybook/react-vite", + "@storybook/addon-a11y/preview", + "@storybook/addon-themes", + "msw-storybook-addon", + "react-router-dom", + "@tanstack/react-query", + "i18next", + "react-i18next", + "smol-toml", + "@mantine/core", + "@supabase/supabase-js", + "axios", ], }, test: { From bbd4d2c3ac106321edcbf9c8df8ef9f45e2ae70f Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 29 Jul 2026 15:33:34 +0100 Subject: [PATCH 002/122] Redesign toml sorting to speed up from ~40s to ~2s (#7192) # Description of Changes The `pre-commit` tool to sort the translations is really slow. It took ~40 seconds to run because it's using a parser which attempts to save all of the formatting data from the Toml. Our translations toml is pretty much entirely formatted anyway, so there's no point in trying to preserve any of that data. The only thing we lose is 5 comments, none of which are needed anyway and only appear in the US translation file. By switching to Python stdlib `tomllib` reading and `tomli-w` for writing, we can make the Toml formatting job take 2.11 seconds, where it used to take 39.78s. The whole pre-commit job now takes 4.58 seconds. --- .taskfiles/pre-commit.yml | 4 +- .../public/locales/en-US/translation.toml | 5 - scripts/pre-commit/pyproject.toml | 2 +- scripts/pre-commit/sort_locale_toml.py | 96 +++++++++++++++++++ scripts/pre-commit/uv.lock | 24 ++--- 5 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 scripts/pre-commit/sort_locale_toml.py diff --git a/.taskfiles/pre-commit.yml b/.taskfiles/pre-commit.yml index 136f852a5b..f2e488e815 100644 --- a/.taskfiles/pre-commit.yml +++ b/.taskfiles/pre-commit.yml @@ -73,7 +73,7 @@ tasks: - task: gitleaks install: - desc: "Install the pinned pre-commit Python tools (ruff, codespell, toml-sort)" + desc: "Install the pinned pre-commit Python tools" run: once cmds: - uv sync --project scripts/pre-commit --locked @@ -112,7 +112,7 @@ tasks: toml-sort: deps: [install] cmds: - - uv run --project scripts/pre-commit --no-sync toml-sort --all --ignore-case {{if .FIX}}--in-place{{else}}--check{{end}} {{.LOCALE_TOML}} + - uv run --project scripts/pre-commit --no-sync python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}} whitespace: cmds: diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 913cdf20a4..abbe1446ff 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -692,7 +692,6 @@ manualLinks = "Manual downloads: click the links and place the files into the te noLanguages = "No tessdata languages found in the configured directory." permissionNotice = "The tessdata path is not writable. Downloads will be opened in the browser; please save the .traineddata files manually into the tessdata folder." -# AI engine admin settings (AI nav group) [admin.settings.ai.documents] description = "Configure the embedding model and retrieval settings used to answer questions over documents. Applied to the AI engine when saved." title = "Documents & RAG" @@ -7320,7 +7319,6 @@ sectionsAriaLabel = "Infrastructure sections" subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace." title = "Infrastructure" -# Fixed-enum label maps rendered via t(MAP[value]) in the infrastructure tabs. [portal.infrastructure.apiKeys] createKey = "Create key" heading = "API keys" @@ -8415,13 +8413,10 @@ region = "State / region" regionPlaceholder = "California" running = "{{annual}} / yr · {{years}}-yr {{tcv}}" s1Sub = "Your team, and the PDFs you expect to run each year." -# Step 1 — volume s1Title = "How much will you process?" s2Sub = "Longer terms discount the rate; your service level sets support." -# Step 2 — commitment & service s2Title = "Commitment and service" s3Sub = "For the quote and the agreement it generates." -# Step 3 — details s3Title = "Your details" serviceLevel = "Service level" size_compact = "Compact" diff --git a/scripts/pre-commit/pyproject.toml b/scripts/pre-commit/pyproject.toml index cd13b56f5e..9a730bdcb5 100644 --- a/scripts/pre-commit/pyproject.toml +++ b/scripts/pre-commit/pyproject.toml @@ -9,7 +9,7 @@ requires-python = ">=3.11" dependencies = [ "ruff==0.15.14", "codespell==2.4.2", - "toml-sort==0.24.4", + "tomli-w==1.2.0", ] [tool.uv] diff --git a/scripts/pre-commit/sort_locale_toml.py b/scripts/pre-commit/sort_locale_toml.py new file mode 100644 index 0000000000..1515fe2cfe --- /dev/null +++ b/scripts/pre-commit/sort_locale_toml.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Key-sort the locale translation.toml files. + +python sort_locale_toml.py ... # check: report, exit 1 if unsorted +python sort_locale_toml.py --fix ... # fix: rewrite in place +""" + +from __future__ import annotations + +import subprocess +import sys +import tomllib +from pathlib import Path + +import tomli_w + + +class SortError(Exception): + """A file could not be sorted without risking its contents.""" + + +def ordered(table: dict[str, object]) -> dict[str, object]: + """Rebuild a table with its keys sorted, and sub-tables after its own keys.""" + keys = {key: value for key, value in table.items() if not isinstance(value, dict)} + subtables = {key: value for key, value in table.items() if isinstance(value, dict)} + result: dict[str, object] = {key: keys[key] for key in sorted(keys, key=str.lower)} + for key in sorted(subtables, key=str.lower): + result[key] = ordered(subtables[key]) + return result + + +def tracked_files(path_specs: list[str]) -> list[str]: + result = subprocess.run( + ["git", "ls-files", "-z", *path_specs], + check=True, + capture_output=True, + text=True, + ) + return [path for path in result.stdout.split("\0") if path] + + +def sort_file(path: str, fix: bool) -> bool: + """Rewrite one file if `fix`; return whether it was not already sorted.""" + text = Path(path).read_text(encoding="utf-8") + try: + original = tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + raise SortError(f"{path}: invalid TOML: {exc}") from exc + + expected = tomli_w.dumps(ordered(original)) + if expected == text: + return False + + try: + reordered = tomllib.loads(expected) + except tomllib.TOMLDecodeError as exc: + raise SortError( + f"{path}: refusing to sort, the sorted output is not valid TOML: {exc}" + ) from exc + if reordered != original: + raise SortError( + f"{path}: refusing to sort, sorting would change the file's contents" + ) + + if fix: + Path(path).write_text(expected, encoding="utf-8") + return True + + +def main() -> int: + args = sys.argv[1:] + fix = "--fix" in args + pathspecs = [a for a in args if a != "--fix"] + + offenders: list[str] = [] + errors: list[str] = [] + for path in tracked_files(pathspecs): + try: + if sort_file(path, fix): + offenders.append(path) + except SortError as exc: + errors.append(str(exc)) + + for error in errors: + print(error, file=sys.stderr) + if offenders and not fix: + print(f"{len(offenders)} file(s) need TOML sorting:") + for path in offenders: + print(f" {path}") + if offenders and fix: + print(f"Sorted TOML in {len(offenders)} file(s).") + return 1 if errors or (offenders and not fix) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pre-commit/uv.lock b/scripts/pre-commit/uv.lock index b90593a6e8..2a3c8ee11d 100644 --- a/scripts/pre-commit/uv.lock +++ b/scripts/pre-commit/uv.lock @@ -43,33 +43,21 @@ source = { virtual = "." } dependencies = [ { name = "codespell" }, { name = "ruff" }, - { name = "toml-sort" }, + { name = "tomli-w" }, ] [package.metadata] requires-dist = [ { name = "codespell", specifier = "==2.4.2" }, { name = "ruff", specifier = "==0.15.14" }, - { name = "toml-sort", specifier = "==0.24.4" }, + { name = "tomli-w", specifier = "==1.2.0" }, ] [[package]] -name = "toml-sort" -version = "0.24.4" +name = "tomli-w" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tomlkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/47/c5/d6f650fdcf8e1f83096815fa67fb13a9a345b99da6015c60c4b7e4a8ea2b/toml_sort-0.24.4.tar.gz", hash = "sha256:429b69f5b98b7047a11380c80ecf0838556bdea1a8902d0be564961c48841423", size = 17793, upload-time = "2026-03-24T14:05:53.637Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/5a/1f0e54df4eacf0f4d8f94ba50cf72be33d2a3f04babdfb1931bead48a0ab/toml_sort-0.24.4-py3-none-any.whl", hash = "sha256:125aa5fb94f33c542c6901040456145dd38f79bbb310b56b436a93057d30a739", size = 16577, upload-time = "2026-03-24T14:05:54.757Z" }, -] - -[[package]] -name = "tomlkit" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] From b4a264239c468c89ce9639bfd15e1743e38b3045 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:15:49 +0100 Subject: [PATCH 003/122] fix(saas): provision a new user and their personal team atomically (#7193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New SaaS accounts were landing with `team_id = null`. That state is unrecoverable: portal access derives from leading a team, and signup is the only place one is assigned. Five things had to be fixed, all on the signup path. Only the last is a behaviour change you'd notice. ### 1. Shared-PK entity was routed to `merge()` `SaasUserExtensions` pre-sets its `@MapsId` id in the constructor, so Spring Data's id-nullness check treated a brand-new row as existing and `save()` failed with `AssertionFailure: null identifier`. Now implements `Persistable` and decides on the creation timestamp — the idiom already used by `ProcessedFileEntity` and `SourceDocCountEntity`. This was the blocker. It threw on every signup, and because the failure was swallowed (see 3) every new account was stranded. ### 2. User and team were committed separately `createUser()` is annotated `@Transactional` but is called as `this.createUser(...)`, and self-invocation bypasses the proxy — so the annotation did nothing. `saveUser()` and `ensurePersonalTeam()` each committed in their own transaction, leaving a window where a **committed user was visible with `team_id = null`**. Parallel requests entering that window each provisioned a team, producing duplicates (observed: teams 160/161 and 162/163 for one user). Both writes now happen in one transaction via `SaasTeamService.saveUserWithPersonalTeam()`. The window is gone, so there is nothing left to race over. ### 3. A failed team create was swallowed The old code logged at WARN and committed the user anyway. It now propagates: the shared transaction rolls the user back, the request 401s, and a retry starts clean. Nothing half-built is committed. This is the deliberate trade — a transient failure now surfaces instead of silently producing an account that can never reach the portal. ### 4. Per-request healing removed `recoverMissingTeam` (added in #7180) ran on **every authenticated request** whose user had no team, with no mutual exclusion. Under a burst of parallel requests it was itself a source of concurrent provisioning. Provisioning belongs to signup alone. ### 5. Policy seeding could not run `@TransactionalEventListener(AFTER_COMMIT)` leaves the *completed* transaction bound to the thread, so `JpaPolicyStore.save`'s `@Transactional` joined it instead of opening a live one — and its `FOR UPDATE` lock threw `TransactionRequiredException`. Now seeded in `BEFORE_COMMIT`: the lock has a live transaction, rollback safety is unchanged (a rolled-back team still leaves no policy), and it stays on a single pooled connection. ## Verified `:saas:test` green, both spotless gates green, on top of current `main`. Manually on a live signup: **one** team per user, and the concurrent-signup race resolves correctly through the pre-existing unique-constraint catch (`users_supabase_auth_id_key` violation → refetch the winner). 12 filter tests needed updating. Two of them asserted behaviour this PR deliberately removes (`personalTeamFailureSwallowed`, `assignsTeamWhenMissing`), so they were rewritten to assert the new contract rather than re-stubbed into passing. ## Not in scope - **Existing stranded accounts** are not repaired — with the healer gone, nothing fixes them on the request path. They need a one-off backfill or deletion. - **A DB-level invariant.** A partial unique index (`UNIQUE(created_by_user_id) WHERE is_personal`) would make duplicate personal teams impossible rather than merely unreachable. Wanted, but it is a Supabase migration in the SaaS repo, so it is deliberately separate. - **Per-request auth cost.** The filter still does two remote-Postgres round-trips per authenticated request; a frontend request storm makes that expensive. Being handled separately. --- .../DefaultClassificationPolicySeeder.java | 7 +- .../saas/model/SaasUserExtensions.java | 17 ++++- .../SupabaseAuthenticationFilter.java | 68 ++++--------------- .../saas/service/SaasTeamService.java | 40 +++++++++++ .../SupabaseAuthenticationFilterMoreTest.java | 58 ++++++++-------- .../SupabaseAuthenticationFilterTest.java | 19 +++--- 6 files changed, 112 insertions(+), 97 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java index aca6ae0e84..4150dc2f31 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java @@ -46,9 +46,10 @@ public class DefaultClassificationPolicySeeder { .ifPresent(team -> seedIfMissing(team.getId(), team.getName())); } - // Any team created at runtime (admin-created, SaaS sign-ups); after the team's commit so a - // rolled-back team never leaves a policy behind. - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + // Any team created at runtime (admin-created, SaaS sign-ups). Seeds inside the team's own + // transaction: rollback still leaves no policy behind, and the store's pessimistic lock needs a + // live transaction, which AFTER_COMMIT cannot offer. + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void onTeamCreated(TeamCreatedEvent event) { seedIfMissing(event.teamId(), event.teamName()); } diff --git a/app/saas/src/main/java/stirling/software/saas/model/SaasUserExtensions.java b/app/saas/src/main/java/stirling/software/saas/model/SaasUserExtensions.java index 036fdd27be..8d7dda1726 100644 --- a/app/saas/src/main/java/stirling/software/saas/model/SaasUserExtensions.java +++ b/app/saas/src/main/java/stirling/software/saas/model/SaasUserExtensions.java @@ -7,6 +7,7 @@ import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; import org.hibernate.annotations.UpdateTimestamp; +import org.springframework.data.domain.Persistable; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -16,6 +17,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.MapsId; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; +import jakarta.persistence.Transient; import lombok.Getter; import lombok.NoArgsConstructor; @@ -37,7 +39,7 @@ import stirling.software.proprietary.security.model.User; @NoArgsConstructor @Getter @Setter -public class SaasUserExtensions implements Serializable { +public class SaasUserExtensions implements Serializable, Persistable { private static final long serialVersionUID = 1L; @@ -80,4 +82,17 @@ public class SaasUserExtensions implements Serializable { public boolean isMeteredBillingEnabled() { return Boolean.TRUE.equals(hasMeteredBillingEnabled); } + + @Override + public Long getId() { + return userId; + } + + // Decided on the timestamp, not the id: the constructor pre-sets the @MapsId id, so an + // id-based check would route a new row to merge() and fail with "null identifier". + @Override + @Transient + public boolean isNew() { + return createdAt == null; + } } diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java index 684e22a98f..a4b2366379 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java @@ -239,7 +239,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { && !supabaseUser.isAnonymous()) { user = upgradeAnonymousUser(user, supabaseUser, jwt); } - return recoverMissingTeam(user); + return user; } return createUser(jwt, supabaseId, email, appMetadata); @@ -271,10 +271,8 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { user.setUsername(supabaseUser.getEmail()); } try { - User saved = userService.saveUser(user); // Give the account its own team rather than the shared Default team. - saved.setTeam(saasTeamService.ensurePersonalTeam(saved)); - return saved; + return saasTeamService.saveUserWithPersonalTeam(user); } catch (DataIntegrityViolationException e) { log.warn( "Email collision upgrading anonymous user {} to {}: {}", @@ -372,60 +370,22 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { throw new AuthenticationFailureException("Failed to create SupabaseUser", e); } - User savedUser; - boolean weCreatedThisUser = true; + // Guests get NO team: the editor is free and needs none. Everyone else is provisioned + // atomically, so a user visible to a parallel request always already has one. try { - savedUser = userService.saveUser(newUser); + return isAnonymous(jwt) + ? userService.saveUser(newUser) + : saasTeamService.saveUserWithPersonalTeam(newUser); } catch (DataIntegrityViolationException dup) { // Parallel filter won the race; fetch the winning row. - weCreatedThisUser = false; - savedUser = - userService - .findBySupabaseId(supabaseId) - .orElseThrow( - () -> - new AuthenticationFailureException( - "User creation conflict, but unable to find existing user", - dup)); + return userService + .findBySupabaseId(supabaseId) + .orElseThrow( + () -> + new AuthenticationFailureException( + "User creation conflict, but unable to find existing user", + dup)); } - - // Only the DB-race winner runs first-time init; the losers skip it. Guests (anonymous - // sessions) get NO team: the editor is free and needs none, and automation requires a - // real account. - if (weCreatedThisUser && !isAnonymous(jwt)) { - try { - savedUser.setTeam(saasTeamService.ensurePersonalTeam(savedUser)); - } catch (Exception e) { - log.warn( - "Failed to create personal team for new user {} ({}): {}", - LogRedactionUtils.redactSupabaseId(supabaseId), - LogRedactionUtils.redactEmail(savedUser.getUsername()), - e.getMessage()); - } - } - return savedUser; - } - - /** - * Recover an account stranded without a team: signup is the only other place one is assigned, - * so a null team_id is otherwise permanent — and portal access derives from leading a team. - * Guests get none by design. - */ - private User recoverMissingTeam(User user) { - if (user.getTeam() != null - || ANONYMOUS.toString().equalsIgnoreCase(user.getAuthenticationType())) { - return user; - } - try { - user.setTeam(saasTeamService.ensurePersonalTeam(user)); - log.info("Assigned a personal team to user {} which had none", user.getId()); - } catch (Exception e) { - log.warn( - "Could not assign a personal team to user {}: {}", - user.getId(), - e.getMessage()); - } - return user; } private boolean apiKeyAuthenticated(HttpServletRequest request) throws AuthenticationException { diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index bda0602261..0038cca42a 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -53,6 +53,18 @@ public class SaasTeamService { public static final String DEFAULT_TEAM_NAME = "Default"; public static final String INTERNAL_TEAM_NAME = "Internal"; + /** + * Persist a user and their personal team atomically: an account with no team has no portal + * access and no way to acquire one, so a teamless user must never be committed. Constraint + * violations (the concurrent-signup race) propagate for the caller to resolve. + */ + @Transactional + public User saveUserWithPersonalTeam(User user) { + User saved = userService.saveUser(user); + saved.setTeam(ensurePersonalTeam(saved)); + return saved; + } + /** Returns the user's personal team, creating one if they have none. Idempotent. */ @Transactional public Team ensurePersonalTeam(User user) { @@ -60,9 +72,37 @@ public class SaasTeamService { if (existing != null && saasTeamExtensionService.isPersonal(existing)) { return existing; } + // An empty users.team_id does not prove there is no personal team; adopt one the user + // already owns rather than minting a second. + Team owned = existingPersonalTeam(user); + if (owned != null) { + user.setTeam(owned); + userService.saveUser(user); + return owned; + } return createPersonalTeam(user); } + /** + * The personal team the user already owns — their recorded home, else a solo team they lead. + */ + private Team existingPersonalTeam(User user) { + Long homeId = saasUserExtensionService.getHomeTeamId(user); + if (homeId != null) { + Team home = teamRepository.findById(homeId).orElse(null); + if (home != null) { + return home; + } + } + for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) { + Team team = membership.getTeam(); + if (membership.isLeader() && membershipRepository.countByTeamId(team.getId()) == 1) { + return team; + } + } + return null; + } + /** * Create personal team for new user during signup or migrate existing user from Default team * diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java index 3b8a0292c7..f6181dcebe 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java @@ -315,14 +315,13 @@ class SupabaseAuthenticationFilterMoreTest { local.setSupabaseId(supabaseId); local.setAuthenticationType(AuthenticationType.ANONYMOUS); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); - when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); - when(saasTeamService.ensurePersonalTeam(any(User.class))).thenReturn(new Team()); + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) + .thenAnswer(inv -> inv.getArgument(0)); bearer("tok"); filter.doFilter(request, response, chain); - verify(userService).saveUser(any(User.class)); - verify(saasTeamService).ensurePersonalTeam(any(User.class)); + verify(saasTeamService).saveUserWithPersonalTeam(any(User.class)); assertThat(local.getEmail()).isEqualTo("real@example.com"); assertThat(local.getUsername()).isEqualTo("real@example.com"); assertThat(local.getAuthenticationType()) @@ -342,8 +341,8 @@ class SupabaseAuthenticationFilterMoreTest { local.setSupabaseId(supabaseId); local.setAuthenticationType(AuthenticationType.ANONYMOUS); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); - when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); - when(saasTeamService.ensurePersonalTeam(any(User.class))).thenReturn(new Team()); + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) + .thenAnswer(inv -> inv.getArgument(0)); bearer("tok"); filter.doFilter(request, response, chain); @@ -365,7 +364,7 @@ class SupabaseAuthenticationFilterMoreTest { local.setSupabaseId(supabaseId); local.setAuthenticationType(AuthenticationType.ANONYMOUS); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenThrow(new DataIntegrityViolationException("email exists")); bearer("tok"); @@ -489,12 +488,13 @@ class SupabaseAuthenticationFilterMoreTest { org.mockito.Mockito.doThrow(new DataIntegrityViolationException("dup")) .when(supabaseUserService) .createSupabaseUser(eq(supabaseId), any(), eq(false)); - when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) + .thenAnswer(inv -> inv.getArgument(0)); bearer("tok"); filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); assertThat(SecurityContextHolder.getContext().getAuthentication()) .isInstanceOf(EnhancedJwtAuthenticationToken.class); } @@ -516,7 +516,7 @@ class SupabaseAuthenticationFilterMoreTest { filter.doFilter(request, response, chain); assertThat(response.getStatus()).isEqualTo(401); - verify(userService, never()).saveUser(any()); + verify(saasTeamService, never()).saveUserWithPersonalTeam(any()); } @Test @@ -533,13 +533,13 @@ class SupabaseAuthenticationFilterMoreTest { when(userService.findBySupabaseId(supabaseId)) .thenReturn(Optional.empty()) .thenReturn(Optional.of(winner)); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenThrow(new DataIntegrityViolationException("dup user")); bearer("tok"); filter.doFilter(request, response, chain); - // Race loser does not run first-time init (ensurePersonalTeam). + // The winner committed user and team together, so the loser just adopts its row. verify(saasTeamService, never()).ensurePersonalTeam(any()); assertThat(SecurityContextHolder.getContext().getAuthentication()) .isInstanceOf(EnhancedJwtAuthenticationToken.class); @@ -554,7 +554,7 @@ class SupabaseAuthenticationFilterMoreTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUser(supabaseId, "lost@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenThrow(new DataIntegrityViolationException("dup user")); bearer("tok"); @@ -564,31 +564,30 @@ class SupabaseAuthenticationFilterMoreTest { } @Test - @DisplayName("personal team creation failure for a new user is swallowed") - void personalTeamFailureSwallowed() throws Exception { + @DisplayName("personal team creation failure fails the request, it is not swallowed") + void personalTeamFailureFailsAuth() throws Exception { UUID supabaseId = UUID.randomUUID(); Jwt jwt = fullJwt(supabaseId, "team@example.com", false, "email"); when(jwtDecoder.decode("tok")).thenReturn(jwt); when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUser(supabaseId, "team@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); - when(saasTeamService.ensurePersonalTeam(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenThrow(new IllegalStateException("team boom")); bearer("tok"); filter.doFilter(request, response, chain); - // Auth still succeeds even though team creation failed. - assertThat(SecurityContextHolder.getContext().getAuthentication()) - .isInstanceOf(EnhancedJwtAuthenticationToken.class); - verify(userService, times(1)).saveUser(any(User.class)); + // A teamless account has no portal access, so a failed provision must surface + // rather than admit a half-built user. + assertThat(response.getStatus()).isEqualTo(401); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); } } @Nested - @DisplayName("Team recovery for existing accounts") - class TeamRecovery { + @DisplayName("Existing accounts are never re-provisioned on the request path") + class ExistingAccountProvisioning { private User existingWebUser(UUID supabaseId) { User local = newUser("real@example.com"); @@ -598,8 +597,8 @@ class SupabaseAuthenticationFilterMoreTest { } @Test - @DisplayName("an existing account with no team is given a personal team") - void assignsTeamWhenMissing() throws Exception { + @DisplayName("a teamless account is left alone, not healed on every request") + void teamlessAccountIsNotHealed() throws Exception { UUID supabaseId = UUID.randomUUID(); when(jwtDecoder.decode("tok")) .thenReturn(fullJwt(supabaseId, "real@example.com", false, "email")); @@ -607,15 +606,16 @@ class SupabaseAuthenticationFilterMoreTest { .thenReturn(supabaseUser(supabaseId, "real@example.com", false)); User local = existingWebUser(supabaseId); - Team recovered = new Team(); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); - when(saasTeamService.ensurePersonalTeam(local)).thenReturn(recovered); bearer("tok"); filter.doFilter(request, response, chain); - verify(saasTeamService).ensurePersonalTeam(local); - assertThat(local.getTeam()).isSameAs(recovered); + // Healing here would run per request with no mutual exclusion, so parallel + // requests would mint duplicate teams. Provisioning belongs to signup alone. + verify(saasTeamService, never()).ensurePersonalTeam(any(User.class)); + verify(saasTeamService, never()).saveUserWithPersonalTeam(any(User.class)); + assertThat(local.getTeam()).isNull(); } @Test diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java index 58cf2d72a0..d78be84189 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java @@ -168,7 +168,7 @@ class SupabaseAuthenticationFilterTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUserMatching(supabaseId, "bob@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any())).thenAnswer(inv -> inv.getArgument(0)); + when(saasTeamService.saveUserWithPersonalTeam(any())).thenAnswer(inv -> inv.getArgument(0)); request.setRequestURI("/api/v1/something"); request.setMethod("POST"); @@ -176,10 +176,9 @@ class SupabaseAuthenticationFilterTest { filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); verify(supabaseUserService).createSupabaseUser(supabaseId, "bob@example.com", false); - // New users get their own personal team, never the shared Default team. - verify(saasTeamService).ensurePersonalTeam(any(User.class)); + // Own personal team, never the shared Default team, written with the user. + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); verify(teamService, never()).getOrCreateDefaultTeam(); assertThat(SecurityContextHolder.getContext().getAuthentication()) .isInstanceOf(EnhancedJwtAuthenticationToken.class); @@ -194,7 +193,7 @@ class SupabaseAuthenticationFilterTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUserMatching(supabaseId, "carol@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenAnswer( inv -> { User u = inv.getArgument(0); @@ -210,7 +209,7 @@ class SupabaseAuthenticationFilterTest { filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); } @Test @@ -222,7 +221,7 @@ class SupabaseAuthenticationFilterTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUserMatching(supabaseId, "dave@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenAnswer( inv -> { User u = inv.getArgument(0); @@ -238,7 +237,7 @@ class SupabaseAuthenticationFilterTest { filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); } @Test @@ -250,7 +249,7 @@ class SupabaseAuthenticationFilterTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUserMatching(supabaseId, "eve@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenAnswer( inv -> { User u = inv.getArgument(0); @@ -266,7 +265,7 @@ class SupabaseAuthenticationFilterTest { filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); } @Test From 648b61d6429d74b0c63d4cd41e2de3dffa3777bd Mon Sep 17 00:00:00 2001 From: Frooodle <77850077+Frooodle@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:57:04 +0100 Subject: [PATCH 004/122] regenerate og and lockfile --- frontend/editor/public/og-metadata.saas.json | 6 ++ frontend/package-lock.json | 86 ++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/frontend/editor/public/og-metadata.saas.json b/frontend/editor/public/og-metadata.saas.json index 39f6bf414e..b383b225fa 100644 --- a/frontend/editor/public/og-metadata.saas.json +++ b/frontend/editor/public/og-metadata.saas.json @@ -266,6 +266,11 @@ "title": "Fill Form - Stirling PDF", "description": "Fill PDF form fields interactively with a visual editor" }, + "autoFormDetection": { + "image": "/og_images/home.png", + "title": "Auto Form Detection - Stirling PDF", + "description": "Automatically detect form fields with AI and make your PDF fillable." + }, "multiTool": { "image": "/og_images/multi-tool.png", "title": "Multi-Tool - Stirling PDF", @@ -597,6 +602,7 @@ "/booklet-imposition": "bookletImposition", "/pdf-text-editor": "pdfTextEditor", "/form-fill": "formFill", + "/auto-form-detection": "autoFormDetection", "/multi-tool": "multiTool", "/read": "read", "/automate": "automate", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3daf53e40a..97934fbae2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3013,6 +3013,63 @@ "integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==", "license": "MIT" }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@puppeteer/browsers": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", @@ -11381,6 +11438,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -13714,6 +13777,29 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", From 4dc0927104dc7943a6b8fc3f0c860194e16554a1 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:08:08 +0100 Subject: [PATCH 005/122] a11y job: emit the affected-story list on one line (#7196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Fixes the a11y check failing with `permission denied` on any PR that touches more than one story (currently hitting #7163). The script that lists which stories to scan printed one path per line. That list gets pasted into a shell command, so everything after the first line fell out of the command — the shell treated the second path as a command of its own and failed. One-line fix: print the list on a single line. ## Testing Changed two components and ran the task from both git-bash and PowerShell — both stories scanned, check passes. #7163's red check should go green on re-run once this is in. --- frontend/.storybook/a11y-changed.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/.storybook/a11y-changed.mjs b/frontend/.storybook/a11y-changed.mjs index 6aacf6274c..5ff7a9e633 100644 --- a/frontend/.storybook/a11y-changed.mjs +++ b/frontend/.storybook/a11y-changed.mjs @@ -45,4 +45,12 @@ for (const f of changed) { if (existsSync(s)) stories.add(s); } -process.stdout.write([...stories].sort().join("\n")); +// One line, each path quoted: the output is interpolated into a task command, +// where a newline would end the command after the first story and an unquoted +// space would split a path into two arguments. +process.stdout.write( + [...stories] + .sort() + .map((s) => `"${s}"`) + .join(" "), +); From b35329c8f5e134d07c687fbc34de2e97154b5c35 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:13:41 +0100 Subject: [PATCH 006/122] a11y scan: generate required assets, and fail when a story file can't load (#7201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Two related bugs found while looking at why #7187's a11y check behaves differently on CI than locally. ### 21 stories were never being scanned on CI The scan tasks only depended on `install`, not `prepare`. On a fresh checkout that means the generated icon set (`editor/src/assets/material-symbols-icons.json`, gitignored) doesn't exist, so every story that reaches `LocalIcon` fails to import: ``` Failed to resolve import "../../../assets/material-symbols-icons.json" from "editor/src/core/components/shared/LocalIcon.tsx" ``` On CI that was four story files / 21 stories, every run. It works locally only because our trees already have the file from a previous build. The scan tasks now depend on `prepare`, like the `build:*` tasks do. ### The gate reported those runs as clean Worse than the missing stories: a file that fails to import produces a **failed suite with no assertions**. Every check in `a11y-check.mjs` reads assertions, so the file satisfied the manifest, contributed nothing to compare, and the run printed `✓ no a11y regressions`. An assertion-less failed suite now fails the gate and points at the scan log for the underlying resolve error. `--record` refuses in the same situation, so a baseline can't be written that quietly drops those stories. Also switched the affected-story emptiness test to single quotes, since that list now carries its own per-path quoting (it was producing `[ -z ""a" "b"" ]`). ## Testing - Deleted the generated asset to reproduce a fresh checkout: the gate **fails** with the file named and the cause explained, where before it printed `✓ no a11y regressions` and exited 0. - With the `prepare` dependency the task regenerates the asset itself and the previously-invisible files scan: 21 stories, 35 story-rule pairs, all already baselined. --- .taskfiles/frontend.yml | 14 ++++++------ frontend/.storybook/a11y-check.mjs | 36 +++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 8c32efc5e3..3c2113bf96 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -184,13 +184,13 @@ tasks: storybook: desc: "Start Storybook dev server" - deps: [install] + deps: [prepare] cmds: - npx storybook dev -p 6006 {{.CLI_ARGS}} storybook:build: desc: "Build static Storybook" - deps: [install] + deps: [prepare] cmds: - npx storybook build {{.CLI_ARGS}} @@ -204,7 +204,7 @@ tasks: storybook:test: desc: "Scan every story in real Chromium: it must render and pass axe" - deps: [install, storybook:browser] + deps: [prepare, storybook:browser] cmds: # Runs each story as a browser test. Pass a filter through, e.g. # task frontend:storybook:test -- Button @@ -212,7 +212,7 @@ tasks: storybook:a11y: desc: "a11y regression gate over every story: fail only on NEW axe violations" - deps: [install, storybook:browser] + deps: [prepare, storybook:browser] cmds: - node .storybook/a11y-scan.mjs - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt @@ -231,14 +231,14 @@ tasks: Pass a base ref through CLI_ARGS, e.g. task frontend:storybook:a11y:changed -- origin/release - deps: [install, storybook:browser] + deps: [prepare, storybook:browser] vars: BASE: '{{.CLI_ARGS | default "origin/main"}}' CHANGED: sh: node .storybook/a11y-changed.mjs {{.CLI_ARGS | default "origin/main"}} cmds: - cmd: | - if [ -z "{{.CHANGED}}" ]; then + if [ -z '{{.CHANGED}}' ]; then echo "a11y: no story files affected vs {{.BASE}} — nothing to check" exit 0 fi @@ -247,7 +247,7 @@ tasks: storybook:a11y:record: desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)" - deps: [install, storybook:browser] + deps: [prepare, storybook:browser] cmds: - node .storybook/a11y-scan.mjs - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record diff --git a/frontend/.storybook/a11y-check.mjs b/frontend/.storybook/a11y-check.mjs index 7eef5e079e..43092812ff 100644 --- a/frontend/.storybook/a11y-check.mjs +++ b/frontend/.storybook/a11y-check.mjs @@ -52,6 +52,7 @@ const RULE_URL = /dequeuniversity\.com\/rules\/axe\/[\d.]+\/([a-z0-9-]+)/g; function collect(dir) { const rules = {}; // storyKey -> Set(ruleId) const crashed = []; // storyKey[] — failed for a non-a11y reason + const unloadable = []; // storyFile[] — the file itself never ran const seenFiles = new Set(); let scanned = 0; @@ -68,6 +69,14 @@ function collect(dir) { const idx = norm.search(/editor\/src\//); const file = idx >= 0 ? norm.slice(idx) : norm; seenFiles.add(file); + // A story file that fails to import produces a failed suite with no + // assertions at all. Every other check here reads assertions, so such a + // file satisfies the manifest and contributes nothing — its stories go + // unscanned while the run still reports clean. + if ((tf.assertionResults || []).length === 0 && tf.status !== "passed") { + unloadable.push(file); + continue; + } for (const a of tf.assertionResults || []) { scanned++; if (a.status === "passed") continue; @@ -88,14 +97,14 @@ function collect(dir) { } } } - return { rules, crashed, seenFiles, scanned }; + return { rules, crashed, unloadable, seenFiles, scanned }; } if (!existsSync(inDir)) { console.error(`a11y-check: scan dir not found: ${inDir}`); process.exit(2); } -const { rules, crashed, seenFiles, scanned } = collect(inDir); +const { rules, crashed, unloadable, seenFiles, scanned } = collect(inDir); const observed = {}; for (const [k, set] of Object.entries(rules)) observed[k] = [...set].sort(); @@ -129,6 +138,14 @@ if (record || merge) { merge && existsSync(baselineFile) ? JSON.parse(readFileSync(baselineFile, "utf8")) : {}; + if (unloadable.length) { + console.error( + `a11y-check: refusing to record — ${unloadable.length} story file(s) failed to load:`, + ); + unloadable.slice(0, 20).forEach((f) => console.error(` ${f}`)); + console.error("Their stories never ran, so the baseline would lose them."); + process.exit(2); + } if (crashed.length) { console.error( `a11y-check: refusing to record — ${crashed.length} story(ies) failed for a non-a11y reason:`, @@ -176,6 +193,19 @@ console.log( `${pairs} story-rule pairs (baselined).`, ); +if (unloadable.length) { + console.error( + `\n✖ ${unloadable.length} story file(s) failed to load, so their stories never ran:`, + ); + unloadable.slice(0, 50).forEach((f) => console.error(` ${f}`)); + if (unloadable.length > 50) + console.error(` … and ${unloadable.length - 50} more`); + console.error( + "\nA file that cannot be imported reports no violations at all. The resolve " + + "or transform error is in the scan log (.a11y-scan/scan.log, uploaded as a " + + "run artifact); a missing generated asset is the usual cause.", + ); +} if (crashed.length) { console.error(`\n✖ ${crashed.length} story(ies) failed to render:`); crashed.slice(0, 50).forEach((k) => console.error(` ${k}`)); @@ -191,7 +221,7 @@ if (regressions.length) { "baseline key no longer matches — re-record: task frontend:storybook:a11y:record", ); } -if (crashed.length || regressions.length) process.exit(1); +if (unloadable.length || crashed.length || regressions.length) process.exit(1); if (fixed.length) console.log( From 9d01866c83743a7639332006d41f91915809ecbc Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 30 Jul 2026 15:08:04 +0100 Subject: [PATCH 007/122] Set PRs to build Mac and Windows binaries when building desktop code (#7203) # Description of Changes Currently, desktop PRs only build on Linux, which none of the core maintainers currently use. Change it so that desktop PRs build Mac and Windows, so core maintainers can test the built version. --- .github/workflows/build.yml | 7 +++---- .github/workflows/tauri-build.yml | 14 ++++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c8275c023b..47170d7f07 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -174,13 +174,12 @@ jobs: pull-requests: write uses: ./.github/workflows/tauri-build.yml secrets: inherit - # PR smoke build: Linux only (fastest + cheapest to compile), unsigned, - # deb-only, no AppImage. The full signed multi-OS matrix runs on release; + # PR smoke build: macOS + Windows (the platforms our developers use). + # The full signed multi-OS matrix runs on release; # nightly still warms the Rust cache with all-OS defaults. with: - platform: linux + platform: windows-macos sign: false - minimal: true ai-engine: if: needs.files-changed.outputs.engine == 'true' diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index c97ae4eaef..7daaf18bc1 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -12,7 +12,7 @@ on: workflow_call: inputs: platform: - description: "Platform to build (windows, macos, linux, or all)." + description: "Platform to build (windows, macos, linux, windows-macos, or all)." required: false type: string default: "all" @@ -29,7 +29,7 @@ on: workflow_dispatch: inputs: platform: - description: "Platform to build (windows, macos, linux, or all)" + description: "Platform to build (windows, macos, linux, windows-macos, or all)" required: true default: "all" type: choice @@ -38,6 +38,7 @@ on: - windows - macos - linux + - windows-macos sign: description: "Sign and notarize the bundles." required: false @@ -76,10 +77,11 @@ jobs: LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}' case "$PLATFORM" in - windows) ENTRIES=("$WINDOWS") ;; - macos) ENTRIES=("$MACOS") ;; - linux) ENTRIES=("$LINUX") ;; - *) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;; + windows) ENTRIES=("$WINDOWS") ;; + macos) ENTRIES=("$MACOS") ;; + linux) ENTRIES=("$LINUX") ;; + windows-macos) ENTRIES=("$WINDOWS" "$MACOS") ;; + *) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;; esac # Drop macOS entries when Apple certificate secret is unavailable From 3bee6d212e28e7ff9b11622e1bc7c4e02c3c251e Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 31 Jul 2026 09:53:01 +0100 Subject: [PATCH 008/122] Change pipelines to have 1 input and 1 output (#7121) # Description of Changes Change pipelines so that sources and triggers are grouped into a list of inputs, so you can have a different trigger for each source in the list. This is necessary because triggers are not universally supported by all source types. If you wanted to have a pipeline pull from both a folder and an S3 bucket, the current system allows you to choose "Folder Watch" as the trigger, which will either do nothing or crash when it's paired with the S3 bucket. I've got reservations about actually allowing different triggers for every source because it allows for user workflows that I don't believe exist, like "I want this folder to be polled every minute and this other one to be polled every hour, but they should run the same tools and should output to the same place". Because of this (with agreement from Connor, Anthony and Matt) I've changed this PR to artificially limit pipelines to having 1 input & output at this stage. The backend is still shaped to support multiple inputs & outputs so it should be trivial to re-add support for them in the future if we decide we want to, but the UI can be much simpler and easier to understand with just 1 input and output. image --- .../policy/controller/PolicyController.java | 3 +- .../policy/engine/PolicyRunner.java | 42 ++- .../policy/engine/PolicyValidator.java | 52 ++- .../policy/model/PipelineInput.java | 16 + .../proprietary/policy/model/Policy.java | 61 +-- .../policy/model/PolicyBinding.java | 31 ++ .../overview/PolicyOverviewService.java | 13 +- .../DefaultClassificationPolicySeeder.java | 1 - .../policy/store/InProcessPolicyStore.java | 13 +- .../policy/store/JpaPolicyStore.java | 54 ++- .../policy/store/PolicyEntity.java | 11 +- .../policy/store/PolicyRepository.java | 8 +- .../proprietary/policy/store/PolicyStore.java | 8 +- .../policy/trigger/FolderWatchTrigger.java | 77 ++-- .../policy/trigger/PolicyTrigger.java | 13 +- .../policy/trigger/ScheduleTrigger.java | 44 ++- .../policy/trigger/WebhookTrigger.java | 64 ++-- .../policy/config/FolderAccessGuardTest.java | 10 +- .../policy/config/PolicyAccessGuardTest.java | 2 +- .../controller/PolicyControllerTest.java | 10 +- .../policy/engine/PolicyEngineTest.java | 4 +- .../policy/engine/PolicyRunnerTest.java | 5 +- .../policy/engine/PolicyValidatorTest.java | 59 ++- .../PolicyInlineOutputMigrationTest.java | 3 - .../output/PolicyOutputResolverTest.java | 1 - .../overview/PolicyOverviewServiceTest.java | 13 +- .../s3/EmbeddedS3CredentialMigrationTest.java | 4 +- .../s3/PolicyS3ConnectionUsageCheckTest.java | 1 - ...DefaultClassificationPolicySeederTest.java | 1 - .../policy/source/SourceControllerTest.java | 4 +- .../source/SourceOverviewServiceTest.java | 8 +- .../store/InProcessPolicyStoreTest.java | 20 +- .../policy/store/JpaPolicyStoreTest.java | 54 ++- .../trigger/FolderWatchTriggerTest.java | 99 +++-- .../policy/trigger/ScheduleTriggerTest.java | 87 +++-- .../policy/trigger/WebhookTriggerTest.java | 39 +- .../public/locales/en-US/translation.toml | 8 +- frontend/editor/src/portal/api/pipelines.ts | 18 +- .../pipelines/DestinationPicker.tsx | 47 ++- .../src/portal/mocks/handlers/pipelines.ts | 42 ++- .../src/portal/views/PipelineBuilder.css | 23 ++ .../src/portal/views/PipelineBuilder.test.tsx | 131 +++++-- .../src/portal/views/PipelineBuilder.tsx | 350 +++++++++++------- 43 files changed, 1006 insertions(+), 548 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineInput.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyBinding.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index a34c392597..b6156bc2b8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -355,8 +355,7 @@ public class PolicyController { policy.name(), owner, policy.enabled(), - policy.trigger(), - policy.sourceIds(), + policy.inputs(), policy.steps(), policy.output(), policy.outputIds(), diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 866fe0910d..837e3bd0ae 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -15,6 +15,7 @@ import stirling.software.proprietary.policy.input.ResolvedInput; import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; @@ -42,30 +43,46 @@ public class PolicyRunner { private final SourceDocCounter docCounter; private final ProcessedLedger processedLedger; - /** Full-listing sweep: resolve every source, then reconcile the ledger. */ + /** Full-listing sweep over every input: resolve each source, then reconcile the ledger. */ public SweepOutcome run(Policy policy) { return run(policy, SweepKind.FULL); } - /** - * Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so - * one failure does not affect the others. No sources means one run with no input (generator - * pipeline). Missing or disabled sources are skipped so one broken reference does not stop the - * rest. Returns the ids of the runs it started plus what the sweep skipped, so a manual trigger - * can report which runs to follow or why nothing ran. - */ + /** Sweep every input of the policy at the given listing depth. */ public SweepOutcome run(Policy policy, SweepKind sweep) { + return run(policy, policy.inputs(), sweep); + } + + /** + * Fire one input binding: a background trigger pulling its own source without touching the + * policy's other inputs. Never reconciles the ledger (it sees a single source, so pruning would + * wrongly forget the rest); a full-policy sweep handles that. + */ + public SweepOutcome runInput(Policy policy, PipelineInput input, SweepKind sweep) { + return run(policy, List.of(input), sweep); + } + + /** + * Core sweep: pulls each of the given inputs' sources; each yielded unit becomes its own run so + * one failure does not affect the others. No inputs means one run with no input (generator + * pipeline). Missing or disabled sources are skipped so one broken reference does not stop the + * rest. Presence cleanup only runs when the sweep covered every input of the policy - a + * single-binding fire cannot reconcile the whole policy's ledger. Returns the ids of the runs + * it started plus what the sweep skipped, so a manual trigger can report which runs to follow + * or why nothing ran. + */ + public SweepOutcome run(Policy policy, List inputs, SweepKind sweep) { long sweepStart = System.currentTimeMillis(); PolicySweep context = new PolicySweep(policy.id(), sweep, processedLedger); List runIds = new ArrayList<>(); - List sourceIds = policy.sourceIds(); - if (sourceIds.isEmpty()) { + if (inputs.isEmpty()) { // Generator pipeline: one run with no input. Still fall through to the cleanup // below so rows recorded for its folder outputs are pruned like anything else, // instead of accumulating until the policy is deleted. runIds.add(startRun(policy, PolicyInputs.of(List.of()), unused -> {})); } - for (String sourceId : sourceIds) { + for (PipelineInput input : inputs) { + String sourceId = input.sourceId(); Source source = sourceStore.get(sourceId).orElse(null); if (source == null) { // No veto: a deleted source's rows should age out via the cleanup below. @@ -84,7 +101,8 @@ public class PolicyRunner { } runIds.addAll(pullAndRun(policy, sourceId, source.toInputSpec(), context)); } - if (context.cleanupAllowed()) { + boolean fullPolicy = inputs.size() == policy.inputs().size(); + if (fullPolicy && context.cleanupAllowed()) { processedLedger.markSeen(policy.id(), context.presentIdentities()); int removed = processedLedger.deleteUnseen(policy.id(), sweepStart); if (removed > 0) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index c2d1357889..b30a760fe3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; @@ -18,10 +19,11 @@ import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.trigger.PolicyTrigger; /** - * Validates a policy at save time by delegating each facet (trigger, sources, steps, output) to the - * bean that handles its type, so a misconfiguration fails fast rather than at run time. A null - * trigger is a manual-only policy and skips trigger validation. Each referenced {@code sourceId} - * must resolve to a persisted {@link Source} whose config its {@link InputSource} bean accepts. + * Validates a policy at save time by delegating each facet (inputs, their triggers, output) to the + * bean that handles its type, so a misconfiguration fails fast rather than at run time. Each + * input's {@code sourceId} must resolve to a persisted {@link Source} whose config its {@link + * InputSource} bean accepts; its optional trigger must be a known type compatible with that source. + * A null trigger is a manual-only input and skips trigger validation. */ @Service @RequiredArgsConstructor @@ -34,21 +36,31 @@ public class PolicyValidator { private final SourceStore sourceStore; /** - * @throws IllegalArgumentException if any facet's type is unknown, a referenced source does not - * exist, or any config is invalid + * @throws IllegalArgumentException if the policy has more than one input or output, any facet's + * type is unknown, a referenced source does not exist, a trigger is incompatible with its + * input's source, or any config is invalid */ public void validate(Policy policy) { - if (policy.trigger() != null) { - triggerFor(policy.trigger()).validate(policy); + // Deliberate product cap, not a model limit: the lists stay lists so multiple + // inputs/outputs can be supported later, but today a policy carries at most one of + // each (zero of either remains fine - run on demand / inline output). + if (policy.inputs().size() > 1) { + throw new IllegalArgumentException("a policy supports at most one input"); } - for (String sourceId : policy.sourceIds()) { + if (policy.outputIds().size() > 1) { + throw new IllegalArgumentException("a policy supports at most one output"); + } + for (PipelineInput input : policy.inputs()) { Source source = sourceStore - .get(sourceId) + .get(input.sourceId()) .orElseThrow( () -> new IllegalArgumentException( - "unknown source: " + sourceId)); + "unknown source: " + input.sourceId())); + if (input.trigger() != null) { + validateTrigger(policy, input, source); + } InputSpec spec = source.toInputSpec(); inputSourceFor(spec).validate(spec); } @@ -73,6 +85,24 @@ public class PolicyValidator { } } + /** + * Check an input's trigger is a known type whose source constraints its source satisfies (e.g. + * folder-watch only on a folder source), then let the trigger validate its own options. + */ + private void validateTrigger(Policy policy, PipelineInput input, Source source) { + PolicyTrigger trigger = triggerFor(input.trigger()); + if (!trigger.supportedSourceTypes().isEmpty() + && !trigger.supportedSourceTypes().contains(source.type())) { + throw new IllegalArgumentException( + "trigger '" + + trigger.type() + + "' is not compatible with source type '" + + source.type() + + "'"); + } + trigger.validate(policy, input); + } + /** * Validate an output spec against its sink. Must be called on a request thread (caller's * principal present) so an S3 output's connection is authorization-checked against the caller - diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineInput.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineInput.java new file mode 100644 index 0000000000..15242a9916 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineInput.java @@ -0,0 +1,16 @@ +package stirling.software.proprietary.policy.model; + +/** + * One input of a policy: a reference to a persisted {@code Source} paired with the {@link + * TriggerConfig} that decides when this source is pulled. The trigger lives on the + * binding, not on the source (so one connection can feed many policies on different schedules) and + * not on the policy (so a folder input can be watched while an S3 input on the same policy polls). + * A {@code null} trigger means this input is pulled only when the policy is run on demand. + */ +public record PipelineInput(String sourceId, TriggerConfig trigger) { + + /** An input with no automatic trigger: pulled only on a manual run. */ + public static PipelineInput manual(String sourceId) { + return new PipelineInput(sourceId, null); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index ae9fedc46a..a3d43b712d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -3,29 +3,31 @@ package stirling.software.proprietary.policy.model; import java.util.List; /** - * A stored automation: ordered tool steps, input sources, and output destinations. + * A stored automation: ordered tool steps, input bindings, and output destinations. * - *

Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code - * null} trigger means manual-only. Trigger decides when; {@code sourceIds} reference the persisted - * {@code Source} locations (resolved live at run time) files come from; a run pulls from every - * referenced source. {@code outputIds} reference the {@code Source} locations (resolved live) a - * run's files are delivered to - a run is delivered to every one; when empty the inline {@link - * #output} is used (results returned to the caller), the case for editor and one-off policies. + *

Always runnable on demand. Each {@link PipelineInput} references a persisted {@code Source} + * connection (resolved live at run time) and carries its own optional {@link TriggerConfig}: the + * trigger decides when that source is pulled, so one input can be watched while another polls, and + * a {@code null} trigger makes that input manual-only. An input with no trigger, or a policy with + * no triggered inputs, still runs when the policy is run on demand; a manual run pulls every input. + * + *

{@code outputIds} reference the {@code Source} locations (resolved live) a run's files are + * delivered to - a run is delivered to every one; when empty the inline {@link #output} is used + * (results returned to the caller), the case for editor and one-off policies. */ public record Policy( String id, String name, String owner, boolean enabled, - TriggerConfig trigger, - List sourceIds, + List inputs, List steps, OutputSpec output, List outputIds, Long teamId) { public Policy { - sourceIds = sourceIds == null ? List.of() : List.copyOf(sourceIds); + inputs = inputs == null ? List.of() : List.copyOf(inputs); steps = steps == null ? List.of() : steps; output = output == null ? OutputSpec.inline() : output; outputIds = outputIds == null ? List.of() : List.copyOf(outputIds); @@ -41,12 +43,11 @@ public record Policy( String name, String owner, boolean enabled, - TriggerConfig trigger, - List sourceIds, + List inputs, List steps, OutputSpec output, Long teamId) { - this(id, name, owner, enabled, trigger, sourceIds, steps, output, List.of(), teamId); + this(id, name, owner, enabled, inputs, steps, output, List.of(), teamId); } /** @@ -58,35 +59,35 @@ public record Policy( String name, String owner, boolean enabled, - TriggerConfig trigger, - List sourceIds, + List inputs, List steps, OutputSpec output) { - this(id, name, owner, enabled, trigger, sourceIds, steps, output, List.of(), null); + this(id, name, owner, enabled, inputs, steps, output, List.of(), null); } - /** A policy with no configured sources (a generator, or files supplied directly to a run). */ - public Policy( - String id, - String name, - String owner, - boolean enabled, - TriggerConfig trigger, - List steps, - OutputSpec output) { - this(id, name, owner, enabled, trigger, List.of(), steps, output, List.of(), null); + /** The source ids this policy pulls from, in input order; a derived view for reads. */ + public List sourceIds() { + return inputs.stream().map(PipelineInput::sourceId).toList(); + } + + /** The distinct trigger types configured across this policy's inputs (manual inputs aside). */ + public List triggerTypes() { + return inputs.stream() + .map(PipelineInput::trigger) + .filter(trigger -> trigger != null) + .map(TriggerConfig::type) + .distinct() + .toList(); } /** A copy with the inline output replaced (e.g. resolved for the engine, or migrated). */ public Policy withOutput(OutputSpec resolved) { - return new Policy( - id, name, owner, enabled, trigger, sourceIds, steps, resolved, outputIds, teamId); + return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId); } /** A copy referencing the given saved output destinations. */ public Policy withOutputIds(List newOutputIds) { - return new Policy( - id, name, owner, enabled, trigger, sourceIds, steps, output, newOutputIds, teamId); + return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyBinding.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyBinding.java new file mode 100644 index 0000000000..18e93ce1bc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyBinding.java @@ -0,0 +1,31 @@ +package stirling.software.proprietary.policy.model; + +import java.util.List; + +/** + * A policy paired with one of its {@link PipelineInput}s: the unit a background trigger fires. A + * policy with two triggered inputs yields two bindings, so each fires independently on its own + * trigger and pulls only its own source. + */ +public record PolicyBinding(Policy policy, PipelineInput input) { + + /** + * The bindings across these policies whose input carries a trigger of the given type. Shared by + * the {@code PolicyStore} implementations so every backend derives a trigger's bindings the + * same way. Callers pass the policies a background trigger should consider (i.e. the enabled + * ones). + */ + public static List matching(List policies, String triggerType) { + return policies.stream() + .flatMap( + policy -> + policy.inputs().stream() + .filter( + input -> + input.trigger() != null + && triggerType.equals( + input.trigger().type())) + .map(input -> new PolicyBinding(policy, input))) + .toList(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java index c927ec199c..272917ec7f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -14,7 +14,6 @@ import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; -import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.source.Source; import stirling.software.proprietary.policy.source.SourceAccessGuard; import stirling.software.proprietary.policy.source.SourceStore; @@ -73,7 +72,7 @@ public class PolicyOverviewService { policy.name(), policy.enabled(), policy.enabled() ? "active" : "paused", - triggerSummary(policy.trigger()), + triggerSummary(policy), sources, steps, outputSummary(policy, sourceNames), @@ -95,9 +94,13 @@ public class PolicyOverviewService { return outputSummary(policy.output()); } - /** A null trigger is a manual-only policy; otherwise the trigger's type keys the summary. */ - private static String triggerSummary(TriggerConfig trigger) { - return trigger == null ? "manual" : trigger.type(); + /** + * Summarise a policy's triggers for the overview row: "manual" when no input is triggered, + * otherwise the distinct trigger types across its inputs (e.g. "folder-watch, schedule"). + */ + private static String triggerSummary(Policy policy) { + List types = policy.triggerTypes(); + return types.isEmpty() ? "manual" : String.join(", ", types); } private static String outputSummary(OutputSpec output) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java index 4150dc2f31..63ebae833a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java @@ -87,7 +87,6 @@ public class DefaultClassificationPolicySeeder { POLICY_NAME, "system", true, - null, List.of(), List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())), new OutputSpec("inline", options), diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index 6498e7e28a..de6be8ef5d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -9,6 +9,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; /** * In-memory {@link PolicyStore} for tests and any future no-database mode. {@link JpaPolicyStore} @@ -32,8 +33,7 @@ public class InProcessPolicyStore implements PolicyStore { policy.name(), policy.owner(), policy.enabled(), - policy.trigger(), - policy.sourceIds(), + policy.inputs(), policy.steps(), policy.output(), policy.outputIds(), @@ -77,12 +77,9 @@ public class InProcessPolicyStore implements PolicyStore { } @Override - public List findByTriggerType(String triggerType) { - return policies.values().stream() - .filter(Policy::enabled) - .filter(policy -> policy.trigger() != null) - .filter(policy -> triggerType.equals(policy.trigger().type())) - .toList(); + public List findBindingsByTriggerType(String triggerType) { + List enabled = policies.values().stream().filter(Policy::enabled).toList(); + return PolicyBinding.matching(enabled, triggerType); } @Override diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 1b598b88c1..4f91f9e930 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -12,8 +12,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; /** * Durable {@link PolicyStore} backed by JPA; the runtime store. Policies are persisted as JSON via @@ -40,8 +44,7 @@ public class JpaPolicyStore implements PolicyStore { policy.name(), policy.owner(), policy.enabled(), - policy.trigger(), - policy.sourceIds(), + policy.inputs(), policy.steps(), policy.output(), policy.outputIds(), @@ -52,7 +55,6 @@ public class JpaPolicyStore implements PolicyStore { entity.setName(stored.name()); entity.setOwner(stored.owner()); entity.setEnabled(stored.enabled()); - entity.setTriggerType(stored.trigger() == null ? null : stored.trigger().type()); entity.setTeamId(stored.teamId()); // Preserve an existing policy's run-order position; append a new one to the end of its // team's queue (max + 1), so setting up a policy adds it last by default. @@ -119,11 +121,13 @@ public class JpaPolicyStore implements PolicyStore { } @Override - public List findByTriggerType(String triggerType) { - return repository.findByTriggerTypeAndEnabledTrue(triggerType).stream() - .map(this::toPolicy) - .flatMap(Optional::stream) - .toList(); + public List findBindingsByTriggerType(String triggerType) { + List enabled = + repository.findByEnabledTrue().stream() + .map(this::toPolicy) + .flatMap(Optional::stream) + .toList(); + return PolicyBinding.matching(enabled, triggerType); } @Override @@ -139,7 +143,8 @@ public class JpaPolicyStore implements PolicyStore { // One unreadable row must never abort a bulk read or crash startup. private Optional toPolicy(PolicyEntity entity) { try { - return Optional.of(objectMapper.readValue(entity.getPolicyJson(), Policy.class)); + JsonNode node = upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson())); + return Optional.of(objectMapper.treeToValue(node, Policy.class)); } catch (Exception e) { log.error( "Skipping unreadable policy id={} name={}: stored JSON could not be parsed" @@ -150,4 +155,35 @@ public class JpaPolicyStore implements PolicyStore { return Optional.empty(); } } + + /** + * Migrate a policy JSON blob written before triggers moved onto inputs. The old shape carried a + * single policy-level {@code trigger} and a {@code sourceIds} list; pair each source with that + * trigger so an upgraded policy keeps firing. A trigger incompatible with a source + * (folder-watch on an S3 source) is simply inert at run time, matching the old behaviour where + * such a source was never watched. New-shape blobs (already carrying {@code inputs}) are + * returned untouched. + */ + private JsonNode upgradeLegacyShape(JsonNode root) { + if (!(root instanceof ObjectNode obj) || obj.has("inputs")) { + return root; + } + JsonNode trigger = obj.get("trigger"); + JsonNode sourceIds = obj.get("sourceIds"); + ArrayNode inputs = objectMapper.createArrayNode(); + if (sourceIds != null && sourceIds.isArray()) { + for (JsonNode sourceId : sourceIds) { + ObjectNode input = objectMapper.createObjectNode(); + input.set("sourceId", sourceId); + if (trigger != null && !trigger.isNull()) { + input.set("trigger", trigger); + } + inputs.add(input); + } + } + obj.set("inputs", inputs); + obj.remove("trigger"); + obj.remove("sourceIds"); + return obj; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java index c871267bc4..fa639b5c19 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java @@ -17,10 +17,10 @@ import stirling.software.proprietary.integration.crypto.LegacyDecryptStringConve /** * JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives * as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies - * for querying, notably {@code triggerType} + {@code enabled} so background triggers can fetch - * their policies, and {@code teamId} so the caller's team can be loaded without scanning every - * team's rows. {@code owner} and {@code teamId} are plain values, not foreign keys, to stay - * decoupled from the security entities. + * for querying, notably {@code enabled} so background triggers can scan the active policies, and + * {@code teamId} so the caller's team can be loaded without scanning every team's rows. {@code + * owner} and {@code teamId} are plain values, not foreign keys, to stay decoupled from the security + * entities. */ @Entity @Table(name = "policies") @@ -44,9 +44,6 @@ public class PolicyEntity implements Serializable { @Column(name = "enabled") private boolean enabled; - @Column(name = "trigger_type") - private String triggerType; - @Column(name = "team_id") private Long teamId; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java index 82ae4355dd..1c8465c82a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java @@ -13,8 +13,12 @@ import jakarta.persistence.LockModeType; @Repository public interface PolicyRepository extends JpaRepository { - /** Enabled policies of a given trigger type, for background triggers to activate. */ - List findByTriggerTypeAndEnabledTrue(String triggerType); + /** + * Enabled policies, for background triggers to scan for inputs of their trigger type. Which + * inputs (and their trigger types) a policy carries lives in the JSON blob, so the type filter + * is applied after parsing rather than in SQL. + */ + List findByEnabledTrue(); /** * Policies belonging to a team, in run order (ascending {@code sortOrder}; a null order sorts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java index ac9e210439..fa2a7e9b87 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; /** Stores {@link Policy} definitions. */ public interface PolicyStore { @@ -18,8 +19,11 @@ public interface PolicyStore { /** Policies owned by the given team, loaded scoped rather than fetched globally. */ List findByTeam(Long teamId); - /** Enabled policies with the given trigger type, for background triggers. */ - List findByTriggerType(String triggerType); + /** + * Enabled inputs with the given trigger type, as {@code (policy, input)} bindings, so a + * background trigger fires each input independently and pulls only its own source. + */ + List findBindingsByTriggerType(String triggerType); /** * Set the team's run order from {@code orderedIds} (position → sortOrder). Only policies that diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java index a4470a418b..5b859a4786 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java @@ -31,7 +31,9 @@ import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.SweepKind; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.source.Source; import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; @@ -85,10 +87,10 @@ public class FolderWatchTrigger implements PolicyTrigger { } @Override - public void validate(Policy policy) { - if (watchDirsOf(policy).isEmpty()) { + public void validate(Policy policy, PipelineInput input) { + if (watchDirsOf(input).isEmpty()) { throw new IllegalArgumentException( - "folder-watch trigger requires at least one watchable (folder) input source"); + "folder-watch trigger requires a watchable (folder) input source"); } } @@ -185,24 +187,30 @@ public class FolderWatchTrigger implements PolicyTrigger { return changed; } - /** Run every folder-watch policy that draws from one of the changed directories. */ + /** Fire every folder-watch input that draws from one of the changed directories. */ void runForChangedDirs(Set changedDirs) { if (changedDirs.isEmpty()) { return; } - for (Policy policy : policyStore.findByTriggerType(TYPE)) { + for (PolicyBinding binding : policyStore.findBindingsByTriggerType(TYPE)) { List dirs; try { - dirs = watchDirsOf(policy); + dirs = watchDirsOf(binding.input()); } catch (RuntimeException e) { log.warn( - "Folder-watch policy {} is misconfigured: {}", policy.id(), e.getMessage()); + "Folder-watch input {}/{} is misconfigured: {}", + binding.policy().id(), + binding.input().sourceId(), + e.getMessage()); continue; } if (dirs.stream().anyMatch(changedDirs::contains)) { - log.debug("Folder-watch policy {} ({}) saw activity", policy.id(), policy.name()); + log.debug( + "Folder-watch input {}/{} saw activity", + binding.policy().id(), + binding.input().sourceId()); // Light: the periodic reconcile does the full sweep. - policyRunner.run(policy, SweepKind.LIGHT); + policyRunner.runInput(binding.policy(), binding.input(), SweepKind.LIGHT); } } } @@ -216,15 +224,16 @@ public class FolderWatchTrigger implements PolicyTrigger { } } - /** Reconcile safety net: run every folder-watch policy regardless of watch events. */ + /** Reconcile safety net: run every folder-watch input regardless of watch events. */ void runAll() { - for (Policy policy : policyStore.findByTriggerType(TYPE)) { + for (PolicyBinding binding : policyStore.findBindingsByTriggerType(TYPE)) { try { - policyRunner.run(policy); + policyRunner.runInput(binding.policy(), binding.input(), SweepKind.FULL); } catch (RuntimeException e) { log.warn( - "Folder-watch reconcile run failed for policy {}: {}", - policy.id(), + "Folder-watch reconcile run failed for input {}/{}: {}", + binding.policy().id(), + binding.input().sourceId(), e.getMessage()); } } @@ -269,41 +278,43 @@ public class FolderWatchTrigger implements PolicyTrigger { return Set.copyOf(keysByDir.keySet()); } - /** Every existing directory any current folder-watch policy wants watched. */ + /** Every existing directory any current folder-watch input wants watched. */ private Set desiredDirs() { Set dirs = new HashSet<>(); - for (Policy policy : policyStore.findByTriggerType(TYPE)) { + for (PolicyBinding binding : policyStore.findBindingsByTriggerType(TYPE)) { try { - for (Path dir : watchDirsOf(policy)) { + for (Path dir : watchDirsOf(binding.input())) { if (Files.isDirectory(dir)) { dirs.add(dir); } } } catch (RuntimeException e) { log.warn( - "Folder-watch policy {} is misconfigured: {}", policy.id(), e.getMessage()); + "Folder-watch input {}/{} is misconfigured: {}", + binding.policy().id(), + binding.input().sourceId(), + e.getMessage()); } } return dirs; } // Absolute + normalised so registration keys and event-time matching compare regardless of how - // the path was configured. - private List watchDirsOf(Policy policy) { + // the path was configured. Empty for a non-folder or missing source (that input is never + // watched), so a folder-watch trigger paired with an S3 input is simply inert. + private List watchDirsOf(PipelineInput input) { List dirs = new ArrayList<>(); - for (String sourceId : policy.sourceIds()) { - Source source = sourceStore.get(sourceId).orElse(null); - if (source == null) { - continue; - } - InputSpec spec = source.toInputSpec(); - InputSource inputSource = sourceFor(spec); - if (inputSource == null) { - continue; - } - for (Path dir : inputSource.watchTargets(spec)) { - dirs.add(dir.toAbsolutePath().normalize()); - } + Source source = sourceStore.get(input.sourceId()).orElse(null); + if (source == null) { + return dirs; + } + InputSpec spec = source.toInputSpec(); + InputSource inputSource = sourceFor(spec); + if (inputSource == null) { + return dirs; + } + for (Path dir : inputSource.watchTargets(spec)) { + dirs.add(dir.toAbsolutePath().normalize()); } return dirs; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java index ade5357162..e1e0cc7873 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java @@ -2,11 +2,13 @@ package stirling.software.proprietary.policy.trigger; import java.util.Set; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; /** - * Decides when a policy runs. On firing it hands the policy to {@code PolicyRunner}; it - * never resolves sources itself. New trigger kinds are just new beans of this type. + * Decides when a policy input runs. On firing it hands the binding to {@code + * PolicyRunner}, which pulls only that input's source; it never resolves sources itself. New + * trigger kinds are just new beans of this type. */ public interface PolicyTrigger { @@ -32,10 +34,11 @@ public interface PolicyTrigger { } /** - * Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole - * {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that. + * Validate one input's use of this trigger at save time so misconfiguration fails fast, not at + * fire time. Receives the owning {@link Policy} and the specific {@link PipelineInput} so a + * trigger that depends on the input's source (folder-watch) can check it. */ - default void validate(Policy policy) {} + default void validate(Policy policy, PipelineInput input) {} default void start() {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java index 2018ffd126..f4ffb30146 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java @@ -17,14 +17,18 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.engine.PolicyRunner; +import stirling.software.proprietary.policy.engine.SweepKind; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.model.Schedule; import stirling.software.proprietary.policy.store.PolicyStore; import tools.jackson.databind.ObjectMapper; /** - * Fires policies on a {@link Schedule}: a fixed-interval sweep runs each due "schedule" policy. + * Fires policy inputs on a {@link Schedule}: a fixed-interval sweep pulls each due "schedule" + * input, independently of the policy's other inputs. * *

Last-fire times are in memory, so this assumes a single node and resets on restart. */ @@ -40,17 +44,20 @@ public class ScheduleTrigger implements PolicyTrigger { private final ObjectMapper objectMapper; private final ApplicationProperties applicationProperties; - private final Map lastFiredByPolicy = new ConcurrentHashMap<>(); + private final Map lastFiredByBinding = new ConcurrentHashMap<>(); private volatile ScheduledExecutorService scheduler; + /** Identifies a schedule binding: one input (by source) of one policy. */ + private record BindingKey(String policyId, String sourceId) {} + @Override public String type() { return TYPE; } @Override - public void validate(Policy policy) { - ScheduleConfig.from(objectMapper, policy.trigger().options()); + public void validate(Policy policy, PipelineInput input) { + ScheduleConfig.from(objectMapper, input.trigger().options()); } @Override @@ -83,19 +90,26 @@ public class ScheduleTrigger implements PolicyTrigger { } } - /** Fire every scheduled policy that is due as of {@code now}. Package-visible for testing. */ + /** Fire every scheduled input that is due as of {@code now}. Package-visible for testing. */ void sweep(Instant now) { - for (Policy policy : policyStore.findByTriggerType(TYPE)) { + for (PolicyBinding binding : policyStore.findBindingsByTriggerType(TYPE)) { + Policy policy = binding.policy(); + PipelineInput input = binding.input(); ScheduleConfig config; try { - config = ScheduleConfig.from(objectMapper, policy.trigger().options()); + config = ScheduleConfig.from(objectMapper, input.trigger().options()); } catch (IllegalArgumentException e) { - log.warn("Scheduled policy {} is misconfigured: {}", policy.id(), e.getMessage()); + log.warn( + "Scheduled input {}/{} is misconfigured: {}", + policy.id(), + input.sourceId(), + e.getMessage()); continue; } - // Baseline a newly-seen policy to now so it does not fire immediately. - Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now); + // Baseline a newly-seen binding to now so it does not fire immediately. + BindingKey key = new BindingKey(policy.id(), input.sourceId()); + Instant last = lastFiredByBinding.computeIfAbsent(key, id -> now); ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone())); if (next.toInstant().isAfter(now)) { continue; @@ -105,9 +119,13 @@ public class ScheduleTrigger implements PolicyTrigger { next = later; later = config.schedule().nextAfter(later); } - lastFiredByPolicy.put(policy.id(), next.toInstant()); - log.info("Scheduled policy {} ({}) is due", policy.id(), policy.name()); - policyRunner.run(policy); + lastFiredByBinding.put(key, next.toInstant()); + log.info( + "Scheduled input {}/{} ({}) is due", + policy.id(), + input.sourceId(), + policy.name()); + policyRunner.runInput(policy, input, SweepKind.FULL); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java index 816f510bba..d5bb412a7c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java @@ -13,7 +13,9 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.SweepKind; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.source.Source; import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; @@ -50,15 +52,14 @@ public class WebhookTrigger implements PolicyTrigger { } @Override - public void validate(Policy policy) { - boolean hasWebhookSource = - policy.sourceIds().stream() - .map(sourceStore::get) - .flatMap(java.util.Optional::stream) - .anyMatch(source -> WEBHOOK_SOURCE_TYPE.equals(source.type())); - if (!hasWebhookSource) { - throw new IllegalArgumentException( - "webhook trigger requires at least one webhook input source"); + public void validate(Policy policy, PipelineInput input) { + boolean isWebhookSource = + sourceStore + .get(input.sourceId()) + .filter(source -> WEBHOOK_SOURCE_TYPE.equals(source.type())) + .isPresent(); + if (!isWebhookSource) { + throw new IllegalArgumentException("webhook trigger requires a webhook input source"); } } @@ -83,29 +84,38 @@ public class WebhookTrigger implements PolicyTrigger { } } + /** Fire every webhook input fed by this webhook, pulling only that input's source. */ public void fireForWebhook(String webhookId) { - for (Policy policy : policyStore.findByTriggerType(TYPE)) { - if (!referencesWebhook(policy, webhookId)) { + for (PolicyBinding binding : policyStore.findBindingsByTriggerType(TYPE)) { + if (!referencesWebhook(binding.input(), webhookId)) { continue; } try { - log.debug("Webhook policy {} ({}) saw a delivery", policy.id(), policy.name()); - policyRunner.run(policy, SweepKind.LIGHT); + log.debug( + "Webhook input {}/{} saw a delivery", + binding.policy().id(), + binding.input().sourceId()); + policyRunner.runInput(binding.policy(), binding.input(), SweepKind.LIGHT); } catch (RuntimeException e) { - log.warn("Webhook run failed for policy {}: {}", policy.id(), e.getMessage()); + log.warn( + "Webhook run failed for input {}/{}: {}", + binding.policy().id(), + binding.input().sourceId(), + e.getMessage()); } } } private void safeReconcile() { try { - for (Policy policy : policyStore.findByTriggerType(TYPE)) { + for (PolicyBinding binding : policyStore.findBindingsByTriggerType(TYPE)) { try { - policyRunner.run(policy); + policyRunner.runInput(binding.policy(), binding.input(), SweepKind.FULL); } catch (RuntimeException e) { log.warn( - "Webhook reconcile run failed for policy {}: {}", - policy.id(), + "Webhook reconcile run failed for input {}/{}: {}", + binding.policy().id(), + binding.input().sourceId(), e.getMessage()); } } @@ -114,17 +124,13 @@ public class WebhookTrigger implements PolicyTrigger { } } - private boolean referencesWebhook(Policy policy, String webhookId) { - for (String sourceId : policy.sourceIds()) { - Source source = sourceStore.get(sourceId).orElse(null); - if (source == null || !WEBHOOK_SOURCE_TYPE.equals(source.type())) { - continue; - } - Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION); - if (configured != null && configured.toString().equals(webhookId)) { - return true; - } + /** Whether this input draws from the webhook source the delivery arrived on. */ + private boolean referencesWebhook(PipelineInput input, String webhookId) { + Source source = sourceStore.get(input.sourceId()).orElse(null); + if (source == null || !WEBHOOK_SOURCE_TYPE.equals(source.type())) { + return false; } - return false; + Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION); + return configured != null && configured.toString().equals(webhookId); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java index 351dcd1d2f..431d74d8b8 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java @@ -17,6 +17,7 @@ import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.source.InProcessSourceStore; import stirling.software.proprietary.policy.source.Source; @@ -189,6 +190,13 @@ class FolderAccessGuardTest { null)) .id()) .toList(); - return new Policy("p1", "p", "owner", true, null, sourceIds, List.of(), output); + return new Policy( + "p1", + "p", + "owner", + true, + sourceIds.stream().map(PipelineInput::manual).toList(), + List.of(), + output); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java index 35c4d2631e..c3f6552dc5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java @@ -90,6 +90,6 @@ class PolicyAccessGuardTest { private static Policy inTeam(Long teamId) { return new Policy( - null, "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId); + null, "p", "owner", true, List.of(), List.of(), OutputSpec.inline(), teamId); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 351eecd581..c29c96a8ed 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -139,7 +139,7 @@ class PolicyControllerTest { } private static Policy policy(String id, Long teamId) { - return new Policy(id, "name", "owner", true, null, List.of(), List.of(), null, teamId); + return new Policy(id, "name", "owner", true, List.of(), List.of(), null, teamId); } private static Policy s3OutputPolicy(String id, String secret) { @@ -150,7 +150,7 @@ class PolicyControllerTest { "bucket", "outbox", "accessKeyId", "AKIAEXAMPLE", "secretAccessKey", secret)); - return new Policy(id, "name", "owner", true, null, List.of(), List.of(), output, 1L); + return new Policy(id, "name", "owner", true, List.of(), List.of(), output, 1L); } private static PolicyRunHandle handle(String runId) { @@ -401,8 +401,7 @@ class PolicyControllerTest { void updatePreservesOwnership() { applicationProperties.getSecurity().setEnableLogin(false); Policy existing = - new Policy( - "p2", "name", "origOwner", true, null, List.of(), List.of(), null, 3L); + new Policy("p2", "name", "origOwner", true, List.of(), List.of(), null, 3L); when(policyStore.get("p2")).thenReturn(Optional.of(existing)); when(policyAccessGuard.canAccess(existing)).thenReturn(true); when(policyStore.save(any())).thenAnswer(i -> i.getArgument(0)); @@ -410,8 +409,7 @@ class PolicyControllerTest { ResponseEntity response = controller.savePolicy( new Policy( - "p2", "name", "forged", true, null, List.of(), List.of(), null, - 77L)); + "p2", "name", "forged", true, List.of(), List.of(), null, 77L)); assertThat(response.getBody().owner()).isEqualTo("origOwner"); assertThat(response.getBody().teamId()).isEqualTo(3L); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java index e69f0f5476..560c80d7cd 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java @@ -262,7 +262,7 @@ class PolicyEngineTest { "rotate", "owner", true, - null, + List.of(), List.of(new PipelineStep(ROTATE, Map.of())), OutputSpec.inline()); @@ -306,7 +306,7 @@ class PolicyEngineTest { "rotate", "alice", true, - null, + List.of(), List.of(new PipelineStep(ROTATE, Map.of())), OutputSpec.inline()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java index e21f371305..51959a2525 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java @@ -36,6 +36,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyInputs; @@ -306,7 +307,6 @@ class PolicyRunnerTest { "p", "owner", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline(), @@ -338,8 +338,7 @@ class PolicyRunnerTest { "p", "owner", true, - null, - sourceIds, + sourceIds.stream().map(PipelineInput::manual).toList(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java index 2e9613a024..6440cefa4a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java @@ -20,6 +20,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.output.PolicyOutputSink; @@ -60,25 +61,27 @@ class PolicyValidatorTest { validator.validate(policy); - verify(trigger).validate(policy); + verify(trigger).validate(policy, policy.inputs().get(0)); verify(inputSource).validate(InputSpec.folder("/in")); verify(outputSink).validate(policy.output()); } @Test - void skipsTriggerValidationForAManualOnlyPolicy() { + void skipsTriggerValidationForAManualOnlyInput() { when(inputSource.supports(any())).thenReturn(true); when(outputSink.supports(any())).thenReturn(true); validator.validate(manualOnly()); - verify(trigger, never()).validate(any()); + verify(trigger, never()).validate(any(), any()); } @Test void surfacesAnInvalidConfigFromAHandler() { when(trigger.type()).thenReturn("schedule"); - doThrow(new IllegalArgumentException("invalid schedule")).when(trigger).validate(any()); + doThrow(new IllegalArgumentException("invalid schedule")) + .when(trigger) + .validate(any(), any()); IllegalArgumentException ex = assertThrows( @@ -120,14 +123,55 @@ class PolicyValidatorTest { assertTrue(ex.getMessage().contains("unknown trigger type")); } + // The one-input/one-output caps are a product decision, not a model limit: the lists stay so + // multiple can be supported later, but saving more than one of either is rejected today. + + @Test + void rejectsMoreThanOneInput() { + Policy twoInputs = + new Policy( + "p1", + "p", + "owner", + true, + List.of( + PipelineInput.manual(folderSourceId()), + PipelineInput.manual(folderSourceId())), + List.of(), + OutputSpec.inline()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> validator.validate(twoInputs)); + assertTrue(ex.getMessage().contains("at most one input")); + } + + @Test + void rejectsMoreThanOneOutput() { + Policy twoOutputs = manualOnly().withOutputIds(List.of("out-a", "out-b")); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> validator.validate(twoOutputs)); + assertTrue(ex.getMessage().contains("at most one output")); + } + + @Test + void allowsZeroInputsAndZeroOutputs() { + when(outputSink.supports(any())).thenReturn(true); + Policy bare = + new Policy("p1", "p", "owner", true, List.of(), List.of(), OutputSpec.inline()); + + validator.validate(bare); + } + private Policy policy(String triggerType) { return new Policy( "p1", "p", "owner", true, - new TriggerConfig(triggerType, Map.of()), - List.of(folderSourceId()), + List.of( + new PipelineInput( + folderSourceId(), new TriggerConfig(triggerType, Map.of()))), List.of(), OutputSpec.inline()); } @@ -138,8 +182,7 @@ class PolicyValidatorTest { "p", "owner", true, - null, - List.of(folderSourceId()), + List.of(PipelineInput.manual(folderSourceId())), List.of(), OutputSpec.inline()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java index 082ca8cd42..8cd91c56e9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java @@ -54,7 +54,6 @@ class PolicyInlineOutputMigrationTest { "Editor run", "owner", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline())); @@ -128,7 +127,6 @@ class PolicyInlineOutputMigrationTest { name, "owner", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.folder(directory)); @@ -140,7 +138,6 @@ class PolicyInlineOutputMigrationTest { name, "owner", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.folder(directory), diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java index d65c5b24f2..acaa68dae1 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java @@ -65,7 +65,6 @@ class PolicyOutputResolverTest { "Pipeline", "owner", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java index 221cbc4482..8610926c2a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java @@ -16,6 +16,7 @@ import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; @@ -57,8 +58,9 @@ class PolicyOverviewServiceTest { "Redaction", "owner", true, - new TriggerConfig("schedule", Map.of()), - List.of(claims.id()), + List.of( + new PipelineInput( + claims.id(), new TriggerConfig("schedule", Map.of()))), List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())), OutputSpec.inline())); policyStore.save( @@ -67,7 +69,6 @@ class PolicyOverviewServiceTest { "Archive (paused)", "owner", false, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline())); @@ -102,8 +103,7 @@ class PolicyOverviewServiceTest { "Orphan", "owner", true, - null, - List.of("src-missing"), + List.of(PipelineInput.manual("src-missing")), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline())); @@ -170,8 +170,7 @@ class PolicyOverviewServiceTest { name, "owner", true, - null, - List.of(sourceIds), + List.of(sourceIds).stream().map(PipelineInput::manual).toList(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline(), teamId)); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java index a794ee104e..41f80461cd 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java @@ -25,6 +25,7 @@ import stirling.software.proprietary.integration.repository.IntegrationConfigRep import stirling.software.proprietary.model.Team; import stirling.software.proprietary.policy.migration.InProcessCompletedMigrations; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.source.InProcessSourceStore; @@ -97,8 +98,7 @@ class EmbeddedS3CredentialMigrationTest { "Rotate", "alice", true, - null, - List.of(source.id()), + List.of(PipelineInput.manual(source.id())), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), new OutputSpec( "s3", diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java index 847a553ba6..4af725428d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java @@ -39,7 +39,6 @@ class PolicyS3ConnectionUsageCheckTest { "Rotate", "alice", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), new OutputSpec("s3", Map.of("connectionId", "5")), diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java index 2f270afddb..49159e7d6e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java @@ -41,7 +41,6 @@ class DefaultClassificationPolicySeederTest { "Classification Policy", "system", true, - null, List.of(), List.of(), new OutputSpec("inline", Map.of("categoryId", "classification")), diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java index 545ea8e50f..20f783f75c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java @@ -30,6 +30,7 @@ import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.input.WebhookInputSource; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.store.InProcessPolicyStore; @@ -274,8 +275,7 @@ class SourceControllerTest { name, "owner", true, - null, - List.of(sourceId), + List.of(PipelineInput.manual(sourceId)), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java index 3f63e18407..a8c295acc8 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java @@ -16,6 +16,7 @@ import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.store.InProcessPolicyStore; @@ -216,8 +217,7 @@ class SourceOverviewServiceTest { name, "owner", true, - null, - List.of(sourceIds), + List.of(sourceIds).stream().map(PipelineInput::manual).toList(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline())); } @@ -232,7 +232,6 @@ class SourceOverviewServiceTest { name, "owner", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), new OutputSpec("inline", Map.of("sources", List.of("editor"))))); @@ -245,8 +244,7 @@ class SourceOverviewServiceTest { name, "owner", true, - null, - List.of(sourceIds), + List.of(sourceIds).stream().map(PipelineInput::manual).toList(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline(), teamId)); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/InProcessPolicyStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/InProcessPolicyStoreTest.java index 60f14be20d..f74a4cfb5b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/InProcessPolicyStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/InProcessPolicyStoreTest.java @@ -12,11 +12,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.model.TriggerConfig; -/** Tests for {@link InProcessPolicyStore}: id assignment, upsert, trigger-type lookup, delete. */ +/** Tests for {@link InProcessPolicyStore}: id assignment, upsert, binding lookup, delete. */ class InProcessPolicyStoreTest { private PolicyStore store; @@ -45,7 +47,7 @@ class InProcessPolicyStoreTest { "after", "owner", true, - null, + List.of(), List.of(), OutputSpec.inline())); @@ -54,16 +56,16 @@ class InProcessPolicyStoreTest { } @Test - void findByTriggerTypeReturnsOnlyEnabledMatches() { + void findBindingsByTriggerTypeReturnsOnlyEnabledMatches() { store.save(policy(null, "nightly", "schedule", true)); store.save(policy(null, "nightly-disabled", "schedule", false)); store.save(policy(null, "hooked", "webhook", true)); store.save(policy(null, "on-demand", null, true)); // manual-only: no trigger - List scheduled = store.findByTriggerType("schedule"); + List scheduled = store.findBindingsByTriggerType("schedule"); assertEquals(1, scheduled.size()); - assertEquals("nightly", scheduled.get(0).name()); + assertEquals("nightly", scheduled.get(0).policy().name()); } @Test @@ -76,14 +78,16 @@ class InProcessPolicyStoreTest { } private static Policy policy(String id, String name, String triggerType, boolean enabled) { - TriggerConfig trigger = - triggerType == null ? null : new TriggerConfig(triggerType, Map.of()); + PipelineInput input = + triggerType == null + ? PipelineInput.manual("src") + : new PipelineInput("src", new TriggerConfig(triggerType, Map.of())); return new Policy( id, name, "owner", enabled, - trigger, + List.of(input), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java index e9667f2e8d..2a1d2b4f11 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java @@ -19,8 +19,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.model.TriggerConfig; import tools.jackson.databind.ObjectMapper; @@ -53,8 +55,9 @@ class JpaPolicyStoreTest { "compress incoming", "alice", true, - new TriggerConfig("schedule", Map.of()), - List.of("src-in"), + List.of( + new PipelineInput( + "src-in", new TriggerConfig("schedule", Map.of()))), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline())); @@ -63,7 +66,6 @@ class JpaPolicyStoreTest { verify(repository).save(captor.capture()); PolicyEntity entity = captor.getValue(); assertEquals(saved.id(), entity.getId()); - assertEquals("schedule", entity.getTriggerType()); assertTrue(entity.isEnabled()); // The stored JSON round-trips back to an equal policy. assertEquals(saved, objectMapper.readValue(entity.getPolicyJson(), Policy.class)); @@ -77,7 +79,7 @@ class JpaPolicyStoreTest { "rotate", "alice", true, - null, // manual-only: no automatic trigger + List.of(), // no inputs: run on demand only List.of( new PipelineStep( "/api/v1/general/rotate-pdf", Map.of("angle", 90))), @@ -87,6 +89,30 @@ class JpaPolicyStoreTest { assertEquals(policy, store.get("p1").orElseThrow()); } + @Test + void getUpgradesLegacyTriggerAndSourceIdsToPerInputTriggers() { + // A blob written before triggers moved onto inputs: one policy-level trigger + sourceIds. + String legacyJson = + "{\"id\":\"p1\",\"name\":\"legacy\",\"owner\":\"alice\",\"enabled\":true," + + "\"trigger\":{\"type\":\"schedule\",\"options\":{}}," + + "\"sourceIds\":[\"s1\",\"s2\"],\"steps\":[]," + + "\"output\":{\"type\":\"inline\",\"options\":{}}}"; + PolicyEntity entity = new PolicyEntity(); + entity.setId("p1"); + entity.setName("legacy"); + entity.setEnabled(true); + entity.setPolicyJson(legacyJson); + when(repository.findById("p1")).thenReturn(Optional.of(entity)); + + Policy upgraded = store.get("p1").orElseThrow(); + + assertEquals( + List.of( + new PipelineInput("s1", new TriggerConfig("schedule", Map.of())), + new PipelineInput("s2", new TriggerConfig("schedule", Map.of()))), + upgraded.inputs()); + } + @Test void saveDenormalizesTeamIdForScopedQueries() { store.save( @@ -95,7 +121,6 @@ class JpaPolicyStoreTest { "scoped", "alice", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline(), @@ -114,7 +139,6 @@ class JpaPolicyStoreTest { "ours", "alice", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline(), @@ -128,23 +152,28 @@ class JpaPolicyStoreTest { } @Test - void findByTriggerTypeUsesTheEnabledQuery() { + void findBindingsByTriggerTypeScansEnabledPoliciesForMatchingInputs() { Policy policy = new Policy( "p1", "watch", "alice", true, - new TriggerConfig("schedule", Map.of()), + List.of( + new PipelineInput( + "src-in", new TriggerConfig("schedule", Map.of())), + PipelineInput.manual("src-manual")), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); - when(repository.findByTriggerTypeAndEnabledTrue("schedule")) - .thenReturn(List.of(entityFor(policy))); + when(repository.findByEnabledTrue()).thenReturn(List.of(entityFor(policy))); - List scheduled = store.findByTriggerType("schedule"); + List scheduled = store.findBindingsByTriggerType("schedule"); + // Only the scheduled input yields a binding; the manual input on the same policy does not. assertEquals(1, scheduled.size()); - assertEquals("p1", scheduled.get(0).id()); + assertEquals("p1", scheduled.get(0).policy().id()); + assertEquals("src-in", scheduled.get(0).input().sourceId()); + assertEquals("schedule", scheduled.get(0).input().trigger().type()); } @Test @@ -163,7 +192,6 @@ class JpaPolicyStoreTest { entity.setName(policy.name()); entity.setOwner(policy.owner()); entity.setEnabled(policy.enabled()); - entity.setTriggerType(policy.trigger() == null ? null : policy.trigger().type()); entity.setTeamId(policy.teamId()); entity.setPolicyJson(objectMapper.writeValueAsString(policy)); return entity; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java index 2c3adf691e..c955e9ed8d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java @@ -14,6 +14,7 @@ import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.WatchService; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Set; @@ -31,8 +32,10 @@ import stirling.software.proprietary.policy.engine.SweepKind; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.source.InProcessSourceStore; import stirling.software.proprietary.policy.source.Source; @@ -41,10 +44,10 @@ import stirling.software.proprietary.policy.store.PolicyStore; /** * Tests for {@link FolderWatchTrigger}'s dispatch logic via the package-visible {@code - * runForChangedDirs}/{@code runAll}, plus its cross-facet validation. The OS watch loop and - * scheduled reconcile are thin glue around these and are not exercised here (a real {@code - * WatchService} is timing-dependent), mirroring how {@link ScheduleTriggerTest} drives {@code - * sweep} directly. The folder source is stubbed to mirror {@code FolderInputSource.watchTargets}. + * runForChangedDirs}/{@code runAll}, plus its per-input validation. The OS watch loop and scheduled + * reconcile are thin glue around these and are not exercised here (a real {@code WatchService} is + * timing-dependent), mirroring how {@link ScheduleTriggerTest} drives {@code sweep} directly. The + * folder source is stubbed to mirror {@code FolderInputSource.watchTargets}. */ @ExtendWith(MockitoExtension.class) class FolderWatchTriggerTest { @@ -83,39 +86,43 @@ class FolderWatchTriggerTest { } @Test - void validateRejectsPolicyWithNoWatchableSource() { + void validateRejectsInputWithNoWatchableSource() { + PolicyBinding binding = + bindings(folderWatch("p1", List.of(new InputSpec("folder", Map.of())))).get(0); assertThrows( IllegalArgumentException.class, - () -> trigger.validate(folderWatch("p1", List.of()))); + () -> trigger.validate(binding.policy(), binding.input())); } @Test - void validateAcceptsPolicyWithAFolderSource() { - trigger.validate(folderWatch("p1", List.of(InputSpec.folder("/in")))); + void validateAcceptsAFolderSource() { + PolicyBinding binding = + bindings(folderWatch("p1", List.of(InputSpec.folder("/in")))).get(0); + trigger.validate(binding.policy(), binding.input()); } @Test - void runsOnlyPoliciesDrawingFromTheChangedDirectory() { + void runsOnlyInputsDrawingFromTheChangedDirectory() { Policy a = folderWatch("a", List.of(InputSpec.folder("/in/a"))); Policy b = folderWatch("b", List.of(InputSpec.folder("/in/b"))); - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(a, b)); + when(policyStore.findBindingsByTriggerType("folder-watch")).thenReturn(bindings(a, b)); trigger.runForChangedDirs(Set.of(normalized("/in/a"))); - verify(policyRunner).run(a, SweepKind.LIGHT); - verify(policyRunner, never()).run(eq(b), any()); + verify(policyRunner).runInput(a, a.inputs().get(0), SweepKind.LIGHT); + verify(policyRunner, never()).runInput(eq(b), any(), any()); } @Test - void skipsAMisconfiguredPolicyButStillRunsTheOthers() { + void skipsAMisconfiguredInputButStillRunsTheOthers() { Policy bad = folderWatch("bad", List.of(new InputSpec("folder", Map.of()))); Policy good = folderWatch("good", List.of(InputSpec.folder("/in/a"))); - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(bad, good)); + when(policyStore.findBindingsByTriggerType("folder-watch")).thenReturn(bindings(bad, good)); trigger.runForChangedDirs(Set.of(normalized("/in/a"))); - verify(policyRunner).run(good, SweepKind.LIGHT); - verify(policyRunner, never()).run(eq(bad), any()); + verify(policyRunner).runInput(good, good.inputs().get(0), SweepKind.LIGHT); + verify(policyRunner, never()).runInput(eq(bad), any(), any()); } @Test @@ -126,15 +133,15 @@ class FolderWatchTriggerTest { } @Test - void reconcileRunsEveryFolderWatchPolicyAsASafetyNet() { + void reconcileRunsEveryFolderWatchInputAsASafetyNet() { Policy a = folderWatch("a", List.of(InputSpec.folder("/in/a"))); Policy b = folderWatch("b", List.of(InputSpec.folder("/in/b"))); - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(a, b)); + when(policyStore.findBindingsByTriggerType("folder-watch")).thenReturn(bindings(a, b)); trigger.runAll(); - verify(policyRunner).run(a); - verify(policyRunner).run(b); + verify(policyRunner).runInput(a, a.inputs().get(0), SweepKind.FULL); + verify(policyRunner).runInput(b, b.inputs().get(0), SweepKind.FULL); } @Test @@ -151,15 +158,16 @@ class FolderWatchTriggerTest { try { trigger.watchService = service; - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(a, b, m)); + when(policyStore.findBindingsByTriggerType("folder-watch")) + .thenReturn(bindings(a, b, m)); trigger.syncRegistrations(); // Existing dirs are watched; the non-existent one is skipped. assertEquals( Set.of(normalized(dirA.toString()), normalized(dirB.toString())), trigger.watchedDirs()); - // b's policy is removed: its registration is cancelled, a remains. - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(a)); + // b's input is removed: its registration is cancelled, a remains. + when(policyStore.findBindingsByTriggerType("folder-watch")).thenReturn(bindings(a)); trigger.syncRegistrations(); assertEquals(Set.of(normalized(dirA.toString())), trigger.watchedDirs()); } finally { @@ -175,15 +183,15 @@ class FolderWatchTriggerTest { WatchService service = FileSystems.getDefault().newWatchService(); try { trigger.watchService = service; - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(p)); + when(policyStore.findBindingsByTriggerType("folder-watch")).thenReturn(bindings(p)); - // The mutation hook registers the new policy's directory without waiting for a + // The mutation hook registers the new input's directory without waiting for a // reconcile. trigger.onPoliciesChanged(); assertEquals(Set.of(normalized(dir.toString())), trigger.watchedDirs()); - // Once the policy is gone, the same hook cancels its registration. - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of()); + // Once the input is gone, the same hook cancels its registration. + when(policyStore.findBindingsByTriggerType("folder-watch")).thenReturn(List.of()); trigger.onPoliciesChanged(); assertEquals(Set.of(), trigger.watchedDirs()); } finally { @@ -195,31 +203,40 @@ class FolderWatchTriggerTest { return Path.of(dir).toAbsolutePath().normalize(); } + /** Every (policy, input) binding across the given policies, as the store would return them. */ + private static List bindings(Policy... policies) { + return Arrays.stream(policies) + .flatMap( + policy -> policy.inputs().stream().map(in -> new PolicyBinding(policy, in))) + .toList(); + } + /** Persists each spec as a source and returns a folder-watch policy referencing them by id. */ private Policy folderWatch(String id, List sources) { - List sourceIds = + List inputs = sources.stream() .map( spec -> - sourceStore - .save( - new Source( - null, - "src", - spec.type(), - spec.options(), - true, - "owner", - null)) - .id()) + new PipelineInput( + sourceStore + .save( + new Source( + null, + "src", + spec.type(), + spec.options(), + true, + "owner", + null)) + .id(), + new TriggerConfig("folder-watch", Map.of()))) .toList(); return new Policy( id, "watcher", "owner", true, - new TriggerConfig("folder-watch", Map.of()), - sourceIds, + inputs, List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java index 3f81392285..b7b7dccc9a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java @@ -25,8 +25,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.model.Schedule; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.store.PolicyStore; @@ -35,9 +37,9 @@ import tools.jackson.databind.json.JsonMapper; /** * Tests for {@link ScheduleTrigger}'s due-firing logic via the package-visible {@code - * sweep(Instant)}. The trigger only decides when a policy is due; pulling sources and starting runs - * is the {@link PolicyRunner}'s job, so these assert it delegates to the runner. Schedules default - * to UTC, so explicit UTC instants make these deterministic. + * sweep(Instant)}. The trigger only decides when an input is due; pulling the source and starting + * runs is the {@link PolicyRunner}'s job, so these assert it delegates to the runner per binding. + * Schedules default to UTC, so explicit UTC instants make these deterministic. */ @ExtendWith(MockitoExtension.class) class ScheduleTriggerTest { @@ -59,102 +61,105 @@ class ScheduleTriggerTest { @Test void firesOncePerScheduleWhenItComesDue() { - Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); - when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + PolicyBinding binding = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); + when(policyStore.findBindingsByTriggerType("schedule")).thenReturn(List.of(binding)); Instant t0 = Instant.parse("2026-06-05T10:00:30Z"); trigger.sweep(t0); // first sight: baseline, must not fire immediately - verify(policyRunner, never()).run(any()); + verify(policyRunner, never()).runInput(any(), any(), any()); trigger.sweep(t0.plusSeconds(120)); // the one-minute mark has passed - verify(policyRunner, times(1)).run(eq(policy)); + verify(policyRunner, times(1)).runInput(eq(binding.policy()), eq(binding.input()), any()); } @Test void anIntervalMatchingTheSweepPeriodFiresEverySweepDespiteJitter() { - Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); - when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + PolicyBinding binding = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); + when(policyStore.findBindingsByTriggerType("schedule")).thenReturn(List.of(binding)); Instant t0 = Instant.parse("2026-06-05T10:00:00Z"); trigger.sweep(t0); // baseline // The sweep that fires runs a few ms late (scheduler jitter)... trigger.sweep(t0.plusSeconds(60).plusMillis(5)); - verify(policyRunner, times(1)).run(eq(policy)); + verify(policyRunner, times(1)).runInput(eq(binding.policy()), eq(binding.input()), any()); // ...and the next sweep lands exactly on the 60s grid. Anchoring lastFired to the due // time (not the jittered observation) means this must still fire, not alias to skip. trigger.sweep(t0.plusSeconds(120)); - verify(policyRunner, times(2)).run(eq(policy)); + verify(policyRunner, times(2)).runInput(eq(binding.policy()), eq(binding.input()), any()); } @Test void aGapFiresOnceNotOncePerMissedInterval() { - Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); - when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + PolicyBinding binding = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); + when(policyStore.findBindingsByTriggerType("schedule")).thenReturn(List.of(binding)); Instant t0 = Instant.parse("2026-06-05T10:00:00Z"); trigger.sweep(t0); // baseline // Ten minutes of downtime: nine missed due points collapse into one firing. trigger.sweep(t0.plusSeconds(600)); - verify(policyRunner, times(1)).run(eq(policy)); + verify(policyRunner, times(1)).runInput(eq(binding.policy()), eq(binding.input()), any()); // Not due again until a full interval after the latest due point. trigger.sweep(t0.plusSeconds(630)); - verify(policyRunner, times(1)).run(eq(policy)); + verify(policyRunner, times(1)).runInput(eq(binding.policy()), eq(binding.input()), any()); trigger.sweep(t0.plusSeconds(660)); - verify(policyRunner, times(2)).run(eq(policy)); + verify(policyRunner, times(2)).runInput(eq(binding.policy()), eq(binding.input()), any()); } @Test void doesNotFireBeforeTheNextScheduledTime() { - Policy policy = scheduled("p1", new Schedule.Daily(LocalTime.of(3, 0))); // 03:00 UTC daily - when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + PolicyBinding binding = + scheduled("p1", new Schedule.Daily(LocalTime.of(3, 0))); // 03:00 UTC daily + when(policyStore.findBindingsByTriggerType("schedule")).thenReturn(List.of(binding)); Instant t0 = Instant.parse("2026-06-05T10:00:00Z"); trigger.sweep(t0); trigger.sweep(t0.plusSeconds(60)); // next 03:00 is far away - verify(policyRunner, never()).run(any()); + verify(policyRunner, never()).runInput(any(), any(), any()); } @Test void firesWeeklyOnAChosenDay() { // 2026-06-05 is a Friday; the next Monday 09:00 is the soonest firing. - Policy policy = + PolicyBinding binding = scheduled("p1", new Schedule.Weekly(Set.of(DayOfWeek.MONDAY), LocalTime.of(9, 0))); - when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + when(policyStore.findBindingsByTriggerType("schedule")).thenReturn(List.of(binding)); Instant friday = Instant.parse("2026-06-05T10:00:00Z"); trigger.sweep(friday); // baseline trigger.sweep(Instant.parse("2026-06-08T09:00:00Z")); // Monday 09:00 - verify(policyRunner, times(1)).run(eq(policy)); + verify(policyRunner, times(1)).runInput(eq(binding.policy()), eq(binding.input()), any()); } @Test - void skipsPoliciesWithAnInvalidSchedule() { - Policy policy = scheduledWithRawOptions("p1", Map.of()); // no schedule - when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + void skipsInputsWithAnInvalidSchedule() { + PolicyBinding binding = scheduledWithRawOptions("p1", Map.of()); // no schedule + when(policyStore.findBindingsByTriggerType("schedule")).thenReturn(List.of(binding)); trigger.sweep(Instant.parse("2026-06-05T10:00:00Z")); - verify(policyRunner, never()).run(any()); + verify(policyRunner, never()).runInput(any(), any(), any()); } @Test void validateRejectsMissingSchedule() { + PolicyBinding binding = scheduledWithRawOptions("p1", Map.of()); assertThrows( IllegalArgumentException.class, - () -> trigger.validate(scheduledWithRawOptions("p1", Map.of()))); + () -> trigger.validate(binding.policy(), binding.input())); } @Test void validateRejectsAnInvalidSchedule() { Map options = Map.of("schedule", Map.of("type", "every", "count", -5, "unit", "MINUTES")); + PolicyBinding binding = scheduledWithRawOptions("p1", options); assertThrows( IllegalArgumentException.class, - () -> trigger.validate(scheduledWithRawOptions("p1", options))); + () -> trigger.validate(binding.policy(), binding.input())); } @Test @@ -162,21 +167,25 @@ class ScheduleTriggerTest { Map options = new LinkedHashMap<>(); options.put("schedule", new Schedule.Daily(LocalTime.of(2, 0))); options.put("zone", "Europe/London"); - trigger.validate(scheduledWithRawOptions("p1", options)); + PolicyBinding binding = scheduledWithRawOptions("p1", options); + trigger.validate(binding.policy(), binding.input()); } - private static Policy scheduled(String id, Schedule schedule) { + private static PolicyBinding scheduled(String id, Schedule schedule) { return scheduledWithRawOptions(id, Map.of("schedule", schedule)); } - private static Policy scheduledWithRawOptions(String id, Map options) { - return new Policy( - id, - "nightly", - "owner", - true, - new TriggerConfig("schedule", options), - List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), - OutputSpec.inline()); + private static PolicyBinding scheduledWithRawOptions(String id, Map options) { + PipelineInput input = new PipelineInput("s1", new TriggerConfig("schedule", options)); + Policy policy = + new Policy( + id, + "nightly", + "owner", + true, + List.of(input), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline()); + return new PolicyBinding(policy, input); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java index 63b721dd6d..a5b06bde01 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java @@ -2,10 +2,12 @@ package stirling.software.proprietary.policy.trigger; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.Arrays; import java.util.List; import java.util.Map; @@ -19,8 +21,10 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.SweepKind; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.source.InProcessSourceStore; import stirling.software.proprietary.policy.source.Source; @@ -46,37 +50,45 @@ class WebhookTriggerTest { } @Test - void firesOnlyPoliciesReferencingTheDeliveredWebhook() { + void firesOnlyInputsReferencingTheDeliveredWebhook() { Policy matching = webhookPolicy("a", "whkA"); Policy other = webhookPolicy("b", "whkB"); - when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(matching, other)); + when(policyStore.findBindingsByTriggerType(TYPE)).thenReturn(bindings(matching, other)); trigger.fireForWebhook("whkA"); - verify(policyRunner).run(matching, SweepKind.LIGHT); - verify(policyRunner, never()).run(other, SweepKind.LIGHT); + verify(policyRunner).runInput(matching, matching.inputs().get(0), SweepKind.LIGHT); + verify(policyRunner, never()).runInput(eq(other), any(), any()); } @Test void ignoresADeliveryForAnUnknownWebhookId() { Policy policy = webhookPolicy("a", "whkA"); - when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(policy)); + when(policyStore.findBindingsByTriggerType(TYPE)).thenReturn(bindings(policy)); trigger.fireForWebhook("whkZ"); - verify(policyRunner, never()).run(any(), any(SweepKind.class)); + verify(policyRunner, never()).runInput(any(), any(), any()); } @Test void validateRequiresAWebhookSource() { + // A non-webhook source (here: an id that resolves to nothing) is rejected. + Policy notWebhook = policy("p", PipelineInput.manual("missing-source")); assertThrows( IllegalArgumentException.class, - () -> trigger.validate(policy("p", webhookTriggerConfig(), List.of()))); - trigger.validate(webhookPolicy("p", "whkA")); + () -> trigger.validate(notWebhook, notWebhook.inputs().get(0))); + + Policy hooked = webhookPolicy("p", "whkA"); + trigger.validate(hooked, hooked.inputs().get(0)); } - private static TriggerConfig webhookTriggerConfig() { - return new TriggerConfig(TYPE, Map.of()); + /** Every (policy, input) binding across the given policies, as the store would return them. */ + private static List bindings(Policy... policies) { + return Arrays.stream(policies) + .flatMap( + policy -> policy.inputs().stream().map(in -> new PolicyBinding(policy, in))) + .toList(); } private Policy webhookPolicy(String id, String webhookId) { @@ -98,17 +110,16 @@ class WebhookTriggerTest { "owner", null)) .id(); - return policy(id, webhookTriggerConfig(), List.of(sourceId)); + return policy(id, new PipelineInput(sourceId, new TriggerConfig(TYPE, Map.of()))); } - private static Policy policy(String id, TriggerConfig trigger, List sourceIds) { + private static Policy policy(String id, PipelineInput input) { return new Policy( id, "hook", "owner", true, - trigger, - sourceIds, + List.of(input), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index abbe1446ff..d02e60b84c 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7745,11 +7745,17 @@ newPipeline = "New pipeline" addStep = "Add tool" back = "Back to pipelines" chooseAccount = "Choose an account" +chooseDestination = "Choose a destination" chooseOperation = "Choose what this step does" +chooseSource = "Choose a source" discard = "Discard changes" enabled = "Enabled" +inputs = "Input" +inputSource = "Input source" +inputTrigger = "Trigger" keepEditing = "Keep editing" needsUpload = "Needs an uploaded file" +noSources = "No sources connected yet. Create one to use it as an input." noToolMatches = "No tools match your search." pipelineSettings = "Pipeline settings" searchTools = "Search tools" @@ -7777,7 +7783,7 @@ namePlaceholder = "e.g. Redaction sweep" noToolSettings = "This tool has no configurable settings." operations_one = "Operation ({{count}})" operations_other = "Operations ({{count}})" -output = "Destinations" +output = "Destination" removeStep = "Remove operation" save = "Save changes" scheduleEvery = "Run every" diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index 2a4f25b79e..d6088d06ff 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -17,12 +17,22 @@ export interface PipelineStep { fileParameters?: Record; } -/** When a policy fires automatically. `type` keys a trigger bean (e.g. "schedule"). */ +/** When a policy input fires automatically. `type` keys a trigger bean (e.g. "schedule"). */ export interface TriggerConfig { type: string; options: Record; } +/** + * One input of a pipeline: a persisted source paired with the trigger that decides when that + * source is pulled. A `null` trigger means the input is pulled only on a manual run. Mirrors the + * backend `PipelineInput`. + */ +export interface PipelineInput { + sourceId: string; + trigger: TriggerConfig | null; +} + /** Where a run's outputs are delivered. `type` keys an output sink (e.g. "inline"). */ export interface OutputSpec { type: string; @@ -35,15 +45,15 @@ export type PipelineOutputMode = "folder" | "s3"; /** * The stored policy record: the create/update body (`id` blank on create) and what * the backend returns from GET/POST. Mirrors Policy.java exactly; `owner`/`teamId` - * are stamped server-side. A `null` trigger means manual-only. + * are stamped server-side. Each input pairs a source with its own trigger; an input + * with a `null` trigger (or a policy with no triggered inputs) runs only on demand. */ export interface Policy { id?: string; name: string; owner?: string | null; enabled: boolean; - trigger: TriggerConfig | null; - sourceIds: string[]; + inputs: PipelineInput[]; steps: PipelineStep[]; /** * Inline output, used only when no destinations are referenced (editor/one-off runs that return diff --git a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx index 683c6301b4..de1187c3da 100644 --- a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx +++ b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx @@ -1,14 +1,15 @@ import { useTranslation } from "react-i18next"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; -import { Button, Checkbox } from "@app/ui"; +import { Button, Select } from "@app/ui"; /** - * Picks the saved sources a pipeline delivers its output to. A destination is just - * a source used as a write target, and a pipeline may write to several, so this is - * a checklist over the same locations the builder loaded (filtered to writable - * types by the caller) - mirroring the input-sources checklist. Creating a new one - * is delegated to {@code onCreateNew} (the builder navigates to the source builder, - * prompting about unsaved edits first). + * Picks the saved source a pipeline delivers its output to. A destination is just a + * source used as a write target. The value stays a list ({@code outputIds}) because + * the model supports several, but the product caps a pipeline at one destination + * today, so this renders a single dropdown over the same locations the builder + * loaded (filtered to writable types by the caller). Creating a new one is delegated + * to {@code onCreateNew} (the builder navigates to the source builder, prompting + * about unsaved edits first). */ interface DestinationOption { id: string; @@ -31,23 +32,21 @@ export function DestinationPicker({ }: DestinationPickerProps) { const { t } = useTranslation(); - function toggle(id: string, checked: boolean) { - onChange( - checked ? [...value, id] : value.filter((existing) => existing !== id), - ); - } - return ( - <> -

- {sources.map((source) => ( - toggle(source.id, e.target.checked)} - label={source.name} - /> - ))} +
+
+ setScheduleCount(e.target.value)} - className="portal-pipelines__schedule-count" - /> - changeInputSource(value ?? "")} + options={sourceOptions} + /> +
+
+ + updateInput({ scheduleCount: e.target.value }) + } + className="portal-pipelines__schedule-count" + /> + diff --git a/frontend/editor/src/portal/components/sources/connectionTypes.ts b/frontend/editor/src/portal/components/sources/connectionTypes.ts index ad5dfa3f17..d71d0ce2dc 100644 --- a/frontend/editor/src/portal/components/sources/connectionTypes.ts +++ b/frontend/editor/src/portal/components/sources/connectionTypes.ts @@ -29,6 +29,12 @@ export interface ConnectionFieldDef { defaultValue?: string; /** Shown only when another field has one of these values, e.g. auth fields per authType. */ visibleWhen?: { key: string; oneOf: string[] }; + /** + * When this field changes, move another field onto the default paired with the new value (FTP + * port per encryption mode) — but only while the target still holds a default, never a custom + * value the operator typed. + */ + syncsDefault?: { targetKey: string; map: Record }; } export interface CreatableConnectionType { @@ -122,6 +128,162 @@ const S3_FIELDS: ConnectionFieldDef[] = [ }, ]; +// Network file servers (SFTP/FTP/SMB). The protocol is baked into presetConfig; the operator +// supplies host and credentials. host/port/username/password reuse the shared commonFields copy. +const SFTP_FIELDS: ConnectionFieldDef[] = [ + { + key: "host", + labelKey: `${COMMON}.host.label`, + control: "text", + required: true, + placeholderKey: `${PREFIX}.sftp.hostPlaceholder`, + }, + { + key: "port", + labelKey: `${COMMON}.port.label`, + control: "text", + defaultValue: "22", + }, + { + key: "username", + labelKey: `${COMMON}.username.label`, + control: "text", + required: true, + }, + { + key: "password", + labelKey: `${COMMON}.password.label`, + control: "password", + helperTextKey: `${PREFIX}.sftp.fields.password.helperText`, + }, + { + key: "privateKey", + labelKey: `${PREFIX}.sftp.fields.privateKey.label`, + control: "textarea", + helperTextKey: `${PREFIX}.sftp.fields.privateKey.helperText`, + }, + { + key: "privateKeyPassphrase", + labelKey: `${PREFIX}.sftp.fields.passphrase.label`, + control: "password", + }, + { + key: "hostKeyFingerprint", + labelKey: `${PREFIX}.sftp.fields.hostKeyFingerprint.label`, + control: "text", + placeholderKey: `${PREFIX}.sftp.fields.hostKeyFingerprint.placeholder`, + helperTextKey: `${PREFIX}.sftp.fields.hostKeyFingerprint.helperText`, + }, +]; + +const FTP_FIELDS: ConnectionFieldDef[] = [ + { + key: "host", + labelKey: `${COMMON}.host.label`, + control: "text", + required: true, + placeholderKey: `${PREFIX}.ftp.hostPlaceholder`, + }, + { + key: "port", + labelKey: `${COMMON}.port.label`, + control: "text", + defaultValue: "21", + }, + { + key: "username", + labelKey: `${COMMON}.username.label`, + control: "text", + required: true, + }, + { + key: "password", + labelKey: `${COMMON}.password.label`, + control: "password", + required: true, + }, + { + key: "security", + labelKey: `${PREFIX}.ftp.fields.security.label`, + control: "select", + defaultValue: "NONE", + helperTextKey: `${PREFIX}.ftp.fields.security.helperText`, + // Implicit FTPS listens on 990; follow the untouched port default across modes. + syncsDefault: { + targetKey: "port", + map: { NONE: "21", EXPLICIT: "21", IMPLICIT: "990" }, + }, + options: [ + { value: "NONE", labelKey: `${PREFIX}.ftp.fields.security.options.none` }, + { + value: "EXPLICIT", + labelKey: `${PREFIX}.ftp.fields.security.options.explicit`, + }, + { + value: "IMPLICIT", + labelKey: `${PREFIX}.ftp.fields.security.options.implicit`, + }, + ], + }, + { + key: "passive", + labelKey: `${PREFIX}.ftp.fields.passive.label`, + control: "select", + defaultValue: "true", + options: [ + { + value: "true", + labelKey: `${PREFIX}.ftp.fields.passive.options.passive`, + }, + { + value: "false", + labelKey: `${PREFIX}.ftp.fields.passive.options.active`, + }, + ], + }, +]; + +const SMB_FIELDS: ConnectionFieldDef[] = [ + { + key: "host", + labelKey: `${COMMON}.host.label`, + control: "text", + required: true, + placeholderKey: `${PREFIX}.smb.hostPlaceholder`, + }, + { + key: "port", + labelKey: `${COMMON}.port.label`, + control: "text", + defaultValue: "445", + }, + { + key: "share", + labelKey: `${PREFIX}.smb.fields.share.label`, + control: "text", + required: true, + placeholderKey: `${PREFIX}.smb.fields.share.placeholder`, + }, + { + key: "username", + labelKey: `${COMMON}.username.label`, + control: "text", + required: true, + }, + { + key: "password", + labelKey: `${COMMON}.password.label`, + control: "password", + required: true, + }, + { + key: "domain", + labelKey: `${PREFIX}.smb.fields.domain.label`, + control: "text", + helperTextKey: `${PREFIX}.smb.fields.domain.helperText`, + }, +]; + const PURVIEW_FIELDS: ConnectionFieldDef[] = [ { key: "tenantId", @@ -587,6 +749,46 @@ export const CREATABLE_CONNECTION_TYPES: CreatableConnectionType[] = [ searchTerms: ["aws", "bucket", "minio", "object storage"], fields: S3_FIELDS, }, + { + id: "sftp", + integrationType: "NETWORK", + kind: "preset", + category: "storage", + labelKey: `${PREFIX}.sftp.label`, + descriptionKey: `${PREFIX}.sftp.description`, + searchTerms: ["sftp", "ssh", "scp", "drop folder", "file transfer"], + presetConfig: { protocol: "SFTP" }, + fields: SFTP_FIELDS, + }, + { + id: "ftp", + integrationType: "NETWORK", + kind: "preset", + category: "storage", + labelKey: `${PREFIX}.ftp.label`, + descriptionKey: `${PREFIX}.ftp.description`, + searchTerms: ["ftp", "ftps", "file transfer", "drop folder"], + presetConfig: { protocol: "FTP" }, + fields: FTP_FIELDS, + }, + { + id: "smb", + integrationType: "NETWORK", + kind: "preset", + category: "storage", + labelKey: `${PREFIX}.smb.label`, + descriptionKey: `${PREFIX}.smb.description`, + searchTerms: [ + "smb", + "cifs", + "samba", + "network drive", + "windows share", + "unc", + ], + presetConfig: { protocol: "SMB" }, + fields: SMB_FIELDS, + }, { id: "purview", integrationType: "PURVIEW", diff --git a/frontend/editor/src/portal/components/sources/sourceTypes.ts b/frontend/editor/src/portal/components/sources/sourceTypes.ts index e25490a2bf..ee51ba21f3 100644 --- a/frontend/editor/src/portal/components/sources/sourceTypes.ts +++ b/frontend/editor/src/portal/components/sources/sourceTypes.ts @@ -37,6 +37,18 @@ const SOURCE_TYPE_META: Record = { labelKey: "portal.sources.types.s3.label", accent: "brand", }, + sftp: { + labelKey: "portal.sources.types.sftp.label", + accent: "default", + }, + ftp: { + labelKey: "portal.sources.types.ftp.label", + accent: "default", + }, + network: { + labelKey: "portal.sources.types.network.label", + accent: "default", + }, webhook: { labelKey: "portal.sources.types.webhook.label", accent: "warning", @@ -56,12 +68,17 @@ export function sourceTypeMeta(type: string): SourceTypeMeta { export interface SourceFieldDef { key: string; labelKey: string; - control: "text" | "password" | "select" | "s3Connection"; + control: "text" | "password" | "select" | "s3Connection" | "connection"; required?: boolean; placeholderKey?: string; helperTextKey?: string; options?: { value: string; labelKey: string }[]; defaultValue?: string; + /** + * For `control: "connection"` - the connection-catalogue entry id this slot accepts (e.g. + * "sftp"). Filters the picker to matching connections and pins the inline "new connection" form. + */ + connectionTypeId?: string; } /** A source type the wizard can create, with the fields its config needs. */ @@ -72,6 +89,64 @@ export interface CreatableSourceType { fields: SourceFieldDef[]; } +/** + * The config a network source (SFTP/FTP/SMB) needs: a stored connection of the matching protocol, + * the folder to poll, and the same consume/snapshot + recursion choices as a folder source. Shared + * copy across the three protocols, since only the connection type differs. + */ +function networkSourceFields(connectionTypeId: string): SourceFieldDef[] { + return [ + { + key: "connectionId", + labelKey: "portal.sources.networkFields.connection.label", + control: "connection", + connectionTypeId, + required: true, + helperTextKey: "portal.sources.networkFields.connection.helperText", + }, + { + key: "directory", + labelKey: "portal.sources.networkFields.directory.label", + control: "text", + placeholderKey: "portal.sources.networkFields.directory.placeholder", + helperTextKey: "portal.sources.networkFields.directory.helperText", + }, + { + key: "mode", + labelKey: "portal.sources.networkFields.mode.label", + control: "select", + defaultValue: "consume", + helperTextKey: "portal.sources.networkFields.mode.helperText", + options: [ + { + value: "consume", + labelKey: "portal.sources.networkFields.mode.options.consume", + }, + { + value: "snapshot", + labelKey: "portal.sources.networkFields.mode.options.snapshot", + }, + ], + }, + { + key: "recursive", + labelKey: "portal.sources.networkFields.recursive.label", + control: "select", + defaultValue: "false", + options: [ + { + value: "false", + labelKey: "portal.sources.networkFields.recursive.options.top", + }, + { + value: "true", + labelKey: "portal.sources.networkFields.recursive.options.all", + }, + ], + }, + ]; +} + export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [ { type: "folder", @@ -182,6 +257,24 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [ }, ], }, + { + type: "sftp", + labelKey: "portal.sources.types.sftp.label", + descriptionKey: "portal.sources.types.sftp.description", + fields: networkSourceFields("sftp"), + }, + { + type: "ftp", + labelKey: "portal.sources.types.ftp.label", + descriptionKey: "portal.sources.types.ftp.description", + fields: networkSourceFields("ftp"), + }, + { + type: "network", + labelKey: "portal.sources.types.network.label", + descriptionKey: "portal.sources.types.network.description", + fields: networkSourceFields("smb"), + }, { type: WEBHOOK_SOURCE_TYPE, labelKey: "portal.sources.types.webhook.label", @@ -208,8 +301,6 @@ export const COMING_SOON_SOURCE_TYPES: ComingSoonSourceType[] = [ "googledrive", "dropbox", "box", - "network", - "sftp", "email", ].map((type) => ({ type, diff --git a/gradle.properties b/gradle.properties index 0e8306a444..2a33a5cebc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,3 +16,8 @@ org.gradle.java.installations.auto-download=true org.gradle.daemon=true # org.gradle.configuration-cache=true + +# Gradle daemon heap. Without this the daemon uses Gradle's 512m default, which the SaaS build +# variant (it compiles saas + proprietary + core together) exhausts during compileTestJava - the +# GC thrashes and the daemon is stopped. Give all flavours comfortable headroom. +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 From 28480d137a4079953e64c2ce6997f80a61c1376b Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:58:14 +0100 Subject: [PATCH 055/122] Cache Gradle dependencies in the live E2E workflow (#7234) # Description of Changes Fixes the `playwright-e2e-live` failure seen on [run 30694073128](https://github.com/Stirling-Tools/Stirling-PDF/actions/runs/30694073128). The backend never started: ``` > Could not resolve org.springframework.boot:spring-boot-buildpack-platform:4.0.6. > Could not GET 'https://repo.maven.apache.org/maven2/.../spring-boot-buildpack-platform-4.0.6.pom'. Received status code 429 from server: Too Many Requests > There are 14 more failures with identical causes. BUILD FAILED in 21s ``` Gradle was throttled by Maven Central while resolving the buildscript classpath, `:stirling-pdf:bootRun` died, and the runner's "backend exited before becoming ready" guard aborted the suite before a single test ran. `e2e-live.yml` was the only Java-running workflow with no Gradle dependency cache, no `setup-gradle`, and no Maven mirror env - every sibling (`backend-build.yml`, `db-migration-test.yml`, `coverage-aggregate.yml`) has all three. So it downloaded the Gradle distribution and resolved the entire classpath cold from Maven Central on every single run, and eventually got throttled. Added: - the same `Cache Gradle dependency artifacts` + `Setup Gradle` pair used by `backend-build.yml` - `MAVEN_USER` / `MAVEN_PASSWORD` / `MAVEN_PUBLIC_URL` on the two Gradle-invoking steps, so runs that have the secrets use the internal mirror instead of hitting Central - a `Prime Gradle dependencies` step that retries 3x with backoff. Gradle does not retry 429s, and doing the cold resolve up front means a rate-limit failure retries cheaply instead of killing a backgrounded `bootRun` twenty minutes in Side benefit: the job gets faster once the cache is warm. ## Notes for reviewers - The 429 itself is transient infrastructure behaviour - a re-run would likely have gone green. The defect being fixed is that this job had no cache to fall back on, so it was exposed to it on every run. - `:stirling-pdf:classes` does not trigger a frontend build (`buildWithFrontend` defaults off, `app/core/build.gradle:147`), so priming before the Vite build step is safe. It is not wasted work either - `bootRun` compiles the same classes. - This PR originally also carried a fix for the `tauri-build` updater-key failure on that same run. #7181 fixes that more simply and has been merged, so that half has been dropped here. - Workflow changes cannot be fully verified locally; a CI run on this branch is the real check. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/workflows/e2e-live.yml | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index af3ca377c1..302663cbf9 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -25,6 +25,39 @@ jobs: with: java-version: "25" distribution: "temurin" + # Same cache layer as backend-build.yml. Without it every run resolved the + # whole classpath cold and eventually got HTTP 429 from Maven Central. + - name: Cache Gradle dependency artifacts + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/wrapper + ~/.gradle/caches/modules-2/files-2.1 + ~/.gradle/caches/modules-2/metadata-2.* + key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + with: + gradle-version: 9.6.1 + cache-disabled: true + # Gradle does not retry 429s, and a cold cache resolving the buildscript + # classpath is exactly where Maven Central rate-limits us. Retry it here, + # where a failure is cheap, instead of inside the backgrounded bootRun. + - name: Prime Gradle dependencies + env: + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + run: | + for attempt in 1 2 3; do + if ./gradlew --quiet -PnoSpotless :stirling-pdf:classes; then + exit 0 + fi + echo "::warning::Gradle dependency resolution failed (attempt $attempt of 3)" + sleep $((attempt * 30)) + done + echo "::error::Gradle could not resolve dependencies after 3 attempts" + exit 1 - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -53,6 +86,11 @@ jobs: # to aggregate. Chromium-only - other engines silently skip. PW_COVERAGE: "1" PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json + # Internal mirror, as in backend-build.yml. Empty on Dependabot and + # fork PRs, where the build falls back to Maven Central. + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} run: task e2e:live - name: Flag flaky tests # Runs regardless of the test outcome: a flaky test (passed on retry) @@ -66,6 +104,10 @@ jobs: - name: Generate JaCoCo report from e2e:live .exec if: always() id: live-coverage + env: + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} # `if: always()` so even a failed test run still produces a # report from whatever flows did exercise the backend before # the failure. The task itself tolerates a missing .exec From 22815b545880f5c3066f3018abaf0d819aaf7863 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 3 Aug 2026 17:18:21 +0100 Subject: [PATCH 056/122] Sign Mac PR builds (#7267) # Description of Changes Mac builds in PRs are not currently signed, which means you can't run them when downloaded. This restores the functionality so that Mac builds are always signed. --- .github/workflows/build.yml | 6 ++++-- .github/workflows/tauri-build.yml | 15 ++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 07dbb781da..9d4aee192d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -174,12 +174,14 @@ jobs: pull-requests: write uses: ./.github/workflows/tauri-build.yml secrets: inherit - # PR smoke build: macOS + Windows (the platforms our developers use). + # PR smoke build: macOS + Windows (the platforms our developers use). + # sign: true only reaches macOS - tauri-build's per-platform gate keeps + # Windows/Linux signing on main, and an unsigned .dmg cannot be opened. # The full signed multi-OS matrix runs on release; # nightly still warms the Rust cache with all-OS defaults. with: platform: windows-macos - sign: false + sign: true ai-engine: if: needs.files-changed.outputs.engine == 'true' diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 65aac82520..e504826d35 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -108,6 +108,11 @@ jobs: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} + # Per-platform sign gate. macOS signs on any run with the cert available, + # PRs included: Gatekeeper blocks an unsigned .dmg, so an unsigned macOS + # PR build is not testable. Windows and Linux stay main-only, matching the + # gates on their own signing steps below. + SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }} steps: - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -274,7 +279,7 @@ jobs: } - name: Import Apple Developer Certificate - if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != '' + if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15' env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -295,7 +300,7 @@ jobs: rm certificate.p12 - name: Verify Certificate - if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != '' + if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15' run: | echo "Verifying Apple Developer Certificate..." KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db @@ -368,7 +373,7 @@ jobs: fi - name: Build Tauri app (signed) - if: inputs.sign + if: env.SIGN_BUNDLE == 'true' uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -403,7 +408,7 @@ jobs: args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }} - name: Build Tauri app (unsigned) - if: ${{ !inputs.sign }} + if: env.SIGN_BUNDLE != 'true' uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -458,7 +463,7 @@ jobs: fi - name: Verify notarization (macOS only) - if: inputs.sign && matrix.platform == 'macos-15' + if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15' run: | echo "🔍 Verifying notarization status..." cd ./frontend/editor/src-tauri/target From c91d63f21500221ace37102b48cab5f2db8882d9 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:59:28 +0100 Subject: [PATCH 057/122] New design, part one: shared branding, nav surfaces and theme tokens (#7163) ## Overview First part of the move over to the new designs. This lays the groundwork (shared brand components, button/nav styling, theme tokens) and applies it across the editor and the processor. Later parts will build on top of it. ## What's changed **Branding** - Shared `Logo` and `BrandMark` components used everywhere, so the mark and wordmark are identical across the editor, processor, auth pages and the chat FAB. - The sidebar logo doubles as the editor to processor switcher, morphing into a chevron on hover. It only appears for users who can actually reach the processor. **Navigation and layout** - Both sidebars restructured onto the floating nav surface treatment, with rounded panels sitting on the app canvas. - The editor file sidebar is now three sections (controls, PDF Library, settings) and the workbench top bar and tools panel match. - Added a collapse toggle to both sidebars, with an animated expand and collapse and a tidy icon rail when collapsed. The processor did not have a desktop collapse before. **Components** - Buttons and action icons now share one styling system, so both react to the same tokens. - Secondary buttons in dark mode use a neutral fill and border instead of inheriting the primary colour. - Status badges default to a clean dot with no background, with a filled pill as the alternative. - Metric strips gained a row layout with an optional leading icon. **Theme** - Colour tokens consolidated. Literal colours live only in the palette file, everything else references the semantic `--c-*` tokens. - `saas-theme.css` removed and the parts that were genuinely needed moved into the shared theme, so all builds get them. - The colour linter enforces this across the app and runs in CI. ## Notes - Nothing functional should change here, it is styling plus the sidebar collapse feature. - Main has been merged in. The Sources and billing pages picked up changes from main during that merge and are worth a look alongside the new styling. --------- Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- .../public/locales/en-US/translation.toml | 9 +- frontend/editor/scripts/lint/theme-lint.mjs | 1 - .../assets/brand/branding-logo/logo-mark.svg | 4 + .../brand/branding-logo/wordmark-dark.svg | 3 + .../brand/branding-logo/wordmark-light.svg | 3 + .../components/fileEditor/AddFileCard.tsx | 2 +- .../fileEditor/FileEditor.module.css | 20 +- .../core/components/shared/AppConfigModal.tsx | 21 +- .../components/shared/AppConfigModalLazy.tsx | 4 + .../src/core/components/shared/AppSwitch.css | 27 - .../components/shared/AppSwitch.stories.tsx | 35 -- .../src/core/components/shared/AppSwitch.tsx | 76 +-- .../core/components/shared/AppSwitcher.tsx | 24 +- .../src/core/components/shared/BrandMark.css | 56 ++ .../src/core/components/shared/BrandMark.tsx | 36 ++ .../core/components/shared/BrandSwitcher.css | 15 + .../shared/BrandSwitcher.stories.tsx | 16 + .../core/components/shared/BrandSwitcher.tsx | 57 ++ .../core/components/shared/FileSidebar.css | 185 +++--- .../core/components/shared/FileSidebar.tsx | 562 +++++++++--------- .../core/components/shared/LandingActions.tsx | 2 + .../core/components/shared/LandingPage.css | 54 +- .../core/components/shared/LandingPage.tsx | 31 +- .../components/shared/SidebarToggleIcon.tsx | 39 ++ .../core/components/shared/WorkbenchBar.css | 9 +- .../core/components/tools/RightSidebar.tsx | 90 ++- .../src/core/components/tools/ToolPanel.css | 24 +- .../src/core/components/tools/ToolPicker.tsx | 2 +- .../core/components/viewer/EmbedPdfViewer.tsx | 5 +- .../components/viewer/PdfViewerToolbar.css | 35 ++ .../components/viewer/PdfViewerToolbar.tsx | 9 +- frontend/editor/src/core/pages/HomePage.tsx | 1 + frontend/editor/src/core/styles/theme.css | 53 ++ .../tests/live/edge-cases-security.spec.ts | 4 +- .../stubbed/language-localization.spec.ts | 10 +- .../core/tests/stubbed/main-dashboard.spec.ts | 13 +- .../core/tests/stubbed/tool-search.spec.ts | 24 +- frontend/editor/src/core/theme/colors.css | 60 +- frontend/editor/src/core/theme/dimensions.css | 5 + frontend/editor/src/core/theme/primitives.css | 14 +- frontend/editor/src/core/tokens/tokens.css | 10 - frontend/editor/src/core/ui/ActionIcon.tsx | 25 +- frontend/editor/src/core/ui/Button.tsx | 26 +- frontend/editor/src/core/ui/ChatFABButton.css | 9 +- frontend/editor/src/core/ui/ChatFABButton.tsx | 19 +- .../editor/src/core/ui/Inline.stories.tsx | 4 +- frontend/editor/src/core/ui/Logo.css | 33 + frontend/editor/src/core/ui/Logo.stories.tsx | 48 ++ frontend/editor/src/core/ui/Logo.tsx | 89 +++ frontend/editor/src/core/ui/MetricStrip.css | 68 ++- .../src/core/ui/MetricStrip.stories.tsx | 46 ++ frontend/editor/src/core/ui/MetricStrip.tsx | 32 +- frontend/editor/src/core/ui/NavItem.tsx | 1 + frontend/editor/src/core/ui/NavSurface.css | 5 + .../editor/src/core/ui/NavSurface.stories.tsx | 62 ++ frontend/editor/src/core/ui/NavSurface.tsx | 30 + .../src/core/ui/PanelHeader.stories.tsx | 4 +- frontend/editor/src/core/ui/StatusBadge.css | 55 +- .../src/core/ui/StatusBadge.stories.tsx | 2 +- frontend/editor/src/core/ui/StatusBadge.tsx | 16 +- frontend/editor/src/core/ui/accents.css | 24 +- frontend/editor/src/core/ui/index.ts | 2 + .../desktop/components/shared/AppSwitcher.tsx | 19 +- .../editor/src/portal/components/AppShell.css | 3 - .../editor/src/portal/components/AppShell.tsx | 12 +- .../portal/components/PortalSettingsHost.tsx | 4 + .../editor/src/portal/components/Sidebar.css | 151 +++-- .../editor/src/portal/components/Sidebar.tsx | 124 ++-- .../account-link/LinkedInstancesTable.tsx | 2 +- .../editor-admin/DeploymentSummaryStrip.tsx | 7 +- .../editor-admin/DeploymentTargets.tsx | 6 +- .../editor-admin/InstanceHealthTable.tsx | 6 +- .../components/infrastructure/AuditTab.tsx | 5 +- .../infrastructure/DeploymentsTab.tsx | 6 +- .../components/infrastructure/ModelsTab.tsx | 11 +- .../portal/components/pipelines/KpiStrip.tsx | 3 +- .../components/pipelines/PipelinesTable.tsx | 6 +- .../components/policies/CatalogueSummary.tsx | 3 +- .../policies/PolicyCatalogueTable.tsx | 6 +- .../policies/PolicyCategoryCard.tsx | 1 - .../components/policies/PolicyDetailPanel.tsx | 5 +- .../portal/components/sources/KpiStrip.tsx | 3 +- .../components/sources/SourcesTable.tsx | 6 +- .../editor/src/portal/contexts/UIContext.tsx | 31 + .../src/portal/views/Infrastructure.css | 19 - .../proprietary/auth/ui/AuthShell.module.css | 2 +- .../proprietary/auth/ui/AuthSignupPrompt.tsx | 4 +- .../proprietary/auth/ui/EmailPasswordForm.tsx | 4 +- .../src/proprietary/auth/ui/auth-theme.css | 2 - .../agents/StirlingLogoOutline.stories.tsx | 22 - .../components/agents/StirlingLogoOutline.tsx | 21 - .../proprietary/components/chat/ChatPanel.css | 35 +- .../proprietary/components/chat/ChatPanel.tsx | 13 +- .../components/shared/AppSwitcher.tsx | 29 +- .../editor/src/proprietary/routes/Login.tsx | 44 +- .../editor/src/proprietary/routes/Signup.tsx | 1 + frontend/editor/src/saas/App.tsx | 2 +- .../onboarding/OnboardingChecklist.module.css | 11 +- .../saas/components/shared/AppConfigModal.tsx | 22 +- .../saas/components/shared/AppSwitcher.tsx | 41 ++ .../src/saas/hooks/usePortalAccess.test.tsx | 108 ++++ .../editor/src/saas/hooks/usePortalAccess.ts | 52 ++ frontend/editor/src/saas/routes/Login.tsx | 109 ++-- frontend/editor/src/saas/routes/Signup.tsx | 101 ++-- .../saas/routes/login/EmailPasswordForm.tsx | 2 +- .../editor/src/saas/styles/saas-theme.css | 173 ------ 106 files changed, 2121 insertions(+), 1366 deletions(-) create mode 100644 frontend/editor/src/core/assets/brand/branding-logo/logo-mark.svg create mode 100644 frontend/editor/src/core/assets/brand/branding-logo/wordmark-dark.svg create mode 100644 frontend/editor/src/core/assets/brand/branding-logo/wordmark-light.svg delete mode 100644 frontend/editor/src/core/components/shared/AppSwitch.css delete mode 100644 frontend/editor/src/core/components/shared/AppSwitch.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/BrandMark.css create mode 100644 frontend/editor/src/core/components/shared/BrandMark.tsx create mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.css create mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.tsx create mode 100644 frontend/editor/src/core/components/shared/SidebarToggleIcon.tsx create mode 100644 frontend/editor/src/core/components/viewer/PdfViewerToolbar.css create mode 100644 frontend/editor/src/core/ui/Logo.css create mode 100644 frontend/editor/src/core/ui/Logo.stories.tsx create mode 100644 frontend/editor/src/core/ui/Logo.tsx create mode 100644 frontend/editor/src/core/ui/NavSurface.css create mode 100644 frontend/editor/src/core/ui/NavSurface.stories.tsx create mode 100644 frontend/editor/src/core/ui/NavSurface.tsx delete mode 100644 frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx delete mode 100644 frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx create mode 100644 frontend/editor/src/saas/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/saas/hooks/usePortalAccess.test.tsx create mode 100644 frontend/editor/src/saas/hooks/usePortalAccess.ts delete mode 100644 frontend/editor/src/saas/styles/saas-theme.css diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 77e41e2ffd..bf00cfd08d 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3877,10 +3877,10 @@ customizeGroups = "Customize groups" dropHint = "Open files to get started" dropToAdd = "Drop files to add" expand = "Expand sidebar" -files = "Files" googleDrive = "Google Drive" googleDriveDisabled = "Google Drive is not configured" leaveMyFiles = "Leave My Files" +library = "PDF Library" myFiles = "My Files" noFiles = "No files yet" openFileManager = "Browse all files & folders" @@ -4830,8 +4830,7 @@ welcomeTitle = "You've been invited!" addFiles = "Add Files" mobileUpload = "Upload from Mobile" openFromComputer = "Open from computer" -uploadFromComputer = "Upload from computer" -workbenchEmptyStateHero = "Drop a PDF anywhere" +uploadFromComputer = "Browse files" [language] direction = "ltr" @@ -4896,7 +4895,6 @@ signInWith = "Sign in with" title = "Sign in" unexpectedError = "Unexpected error: {{message}}" updatePassword = "Update password" -useEmailInstead = "Login with email" useMagicLink = "Use magic link instead" username = "Username" youAreLoggedIn = "You are logged in!" @@ -8720,7 +8718,6 @@ account-link = "Account link" [portal.shell.sidebar] appEditor = "Editor" appProcessor = "Processor" -brandSuffix = "Stirling Processor" linkAccount = "Link Stirling account" primaryNav = "Primary navigation" switchApp = "Switch app" @@ -10761,8 +10758,10 @@ backToAllTools = "Back to all tools" collapse = "Collapse panel" expand = "Expand panel" goBack = "Go back" +pdfTools = "PDF Tools" placeholder = "Choose a tool to get started" premiumFeature = "Premium feature:" +searchTools = "Search tools" toolsHeader = "Tools" viewAllTools = "View all tools" diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs index 71a9cf07e5..0ae53a3ce9 100644 --- a/frontend/editor/scripts/lint/theme-lint.mjs +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -740,7 +740,6 @@ const PRIMITIVE_LAYER = [ /^editor\/src\/core\/theme\//, /^editor\/src\/core\/styles\/theme\.css$/, /^editor\/src\/core\/tokens\/tokens\.css$/, - /^editor\/src\/saas\/styles\/saas-theme\.css$/, /^editor\/src\/proprietary\/auth\/ui\/auth-theme\.css$/, /^editor\/src\/core\/ui\/accents\.css$/, ]; diff --git a/frontend/editor/src/core/assets/brand/branding-logo/logo-mark.svg b/frontend/editor/src/core/assets/brand/branding-logo/logo-mark.svg new file mode 100644 index 0000000000..de7d337f91 --- /dev/null +++ b/frontend/editor/src/core/assets/brand/branding-logo/logo-mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/editor/src/core/assets/brand/branding-logo/wordmark-dark.svg b/frontend/editor/src/core/assets/brand/branding-logo/wordmark-dark.svg new file mode 100644 index 0000000000..5c29f32920 --- /dev/null +++ b/frontend/editor/src/core/assets/brand/branding-logo/wordmark-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/editor/src/core/assets/brand/branding-logo/wordmark-light.svg b/frontend/editor/src/core/assets/brand/branding-logo/wordmark-light.svg new file mode 100644 index 0000000000..a9eeaa9e74 --- /dev/null +++ b/frontend/editor/src/core/assets/brand/branding-logo/wordmark-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx index 5491138e6b..94d2e35e47 100644 --- a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx +++ b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx @@ -181,11 +181,11 @@ const AddFileCard = ({ {/* Instruction Text */} {terminology.dropFilesHere} diff --git a/frontend/editor/src/core/components/fileEditor/FileEditor.module.css b/frontend/editor/src/core/components/fileEditor/FileEditor.module.css index c5770f1ea7..410ade863b 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditor.module.css +++ b/frontend/editor/src/core/components/fileEditor/FileEditor.module.css @@ -325,13 +325,19 @@ ========================= */ .addFileCard { - width: 260px; - max-width: 260px; - height: calc(310px - 0.5rem); - margin: 0.5rem auto 0; - background: var(--c-bg); - border: 1.5px solid var(--c-border); - border-radius: 12px; + /* Fill the same slot a portrait page thumbnail does. Height: the 310px + .thumbWrap minus the always-reserved 26px toolchain bar (22px + 4px) that + sits above the page. Width: the file card's 260px minus its 10px side + padding. Offset 36px down (that padding-top + the bar) so the two line up. + A fixed size rather than an aspect-ratio, because each thumbnail derives + --thumb-aspect from its own PDF's page dimensions. */ + height: calc(310px - 26px); + width: calc(260px - 20px); + max-width: 100%; + margin: 36px auto auto; + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + border-radius: 0.625rem; box-shadow: var(--shadow-md); cursor: pointer; transition: diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 9ad7963b6f..3805b4b912 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -47,6 +47,8 @@ interface AppConfigModalProps { initialSection?: NavKey | null; /** Host-specific sections appended after the build's registry sections. */ extraSections?: ConfigNavSection[]; + /** Registry section keys to drop, for hosts a section can't run in. */ + hiddenSectionKeys?: NavKey[]; } // Extract section from URL path (e.g., /settings/people -> people) @@ -65,6 +67,7 @@ const AppConfigModalInner: React.FC = ({ urlSync = true, initialSection, extraSections, + hiddenSectionKeys, }) => { const { t } = useTranslation(); // Initialize from the URL so a deep link (`/settings/people`) lands on the @@ -218,13 +221,17 @@ const AppConfigModalInner: React.FC = ({ handleCloseSync, config?.showSettingsWhenNoLogin ?? true, ); - const configNavSections = useMemo( - () => - extraSections?.length - ? [...registrySections, ...extraSections] - : registrySections, - [registrySections, extraSections], - ); + const configNavSections = useMemo(() => { + const base = hiddenSectionKeys?.length + ? registrySections + .map((s) => ({ + ...s, + items: s.items.filter((i) => !hiddenSectionKeys.includes(i.key)), + })) + .filter((s) => s.items.length > 0) + : registrySections; + return extraSections?.length ? [...base, ...extraSections] : base; + }, [registrySections, extraSections, hiddenSectionKeys]); const activeLabel = useMemo(() => { for (const section of configNavSections) { diff --git a/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx b/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx index 1ba57618f0..6c23178219 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx @@ -20,6 +20,8 @@ interface AppConfigModalLazyProps { initialSection?: NavKey | null; /** Host-specific sections appended after the build's registry sections. */ extraSections?: ConfigNavSection[]; + /** Registry section keys to drop, for hosts a section can't run in. */ + hiddenSectionKeys?: NavKey[]; } export default function AppConfigModalLazy({ @@ -28,6 +30,7 @@ export default function AppConfigModalLazy({ urlSync, initialSection, extraSections, + hiddenSectionKeys, }: AppConfigModalLazyProps) { const [shouldMount, setShouldMount] = useState(false); @@ -44,6 +47,7 @@ export default function AppConfigModalLazy({ urlSync={urlSync} initialSection={initialSection} extraSections={extraSections} + hiddenSectionKeys={hiddenSectionKeys} /> )} diff --git a/frontend/editor/src/core/components/shared/AppSwitch.css b/frontend/editor/src/core/components/shared/AppSwitch.css deleted file mode 100644 index bccf8fbceb..0000000000 --- a/frontend/editor/src/core/components/shared/AppSwitch.css +++ /dev/null @@ -1,27 +0,0 @@ -/* Trigger: bare square icon button that blends into either sidebar's header. */ -.app-switch-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - border: none; - background: none; - cursor: pointer; - border-radius: var(--radius-sm); - color: var(--c-text-subtle); - transition: - background var(--motion-fast), - color var(--motion-fast); -} - -.app-switch-btn:hover { - background: var(--c-hover); - color: var(--c-text-muted); -} - -.app-switch-icon { - width: 1rem; - height: 1.0625rem; - display: block; -} diff --git a/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx b/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx deleted file mode 100644 index 6d4449fe58..0000000000 --- a/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { AppSwitch } from "@app/components/shared/AppSwitch"; - -/** The editor ⇄ processor app switcher rendered by both the editor and portal sidebars. */ -const meta: Meta = { - title: "Shared/AppSwitch", - component: AppSwitch, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -export const Editor: Story = { - args: { - current: "editor", - theme: "light", - onSwitch: () => {}, - }, -}; - -export const Processor: Story = { - args: { - current: "processor", - theme: "light", - onSwitch: () => {}, - }, -}; - -export const DarkTheme: Story = { - args: { - current: "editor", - theme: "dark", - onSwitch: () => {}, - }, -}; diff --git a/frontend/editor/src/core/components/shared/AppSwitch.tsx b/frontend/editor/src/core/components/shared/AppSwitch.tsx index b713e75e7c..71d35ed5ad 100644 --- a/frontend/editor/src/core/components/shared/AppSwitch.tsx +++ b/frontend/editor/src/core/components/shared/AppSwitch.tsx @@ -1,52 +1,27 @@ import { useTranslation } from "react-i18next"; -import { Button, Dropdown } from "@app/ui"; -import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; -import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; -import "@app/components/shared/AppSwitch.css"; +import { Dropdown } from "@app/ui"; +import { BrandMark } from "@app/components/shared/BrandMark"; export type AppSwitchTarget = "editor" | "processor"; -function ChevronDownIcon() { - return ( - - - - ); -} - -interface AppSwitchProps { +interface AppSwitchMenuItemsProps { /** The app this switcher is rendered in (shown as active in the menu). */ current: AppSwitchTarget; - /** Resolved color scheme; picks the brand mark for the menu items. */ - theme: "light" | "dark"; /** Invoked with the selected app; only called for apps other than `current`. */ onSwitch: (app: AppSwitchTarget) => void; - className?: string; } /** - * The editor ⇄ processor app switcher (chevron button → app menu). The editor - * and portal sidebars render this same element so the two apps present one - * identical switcher; each host supplies its own theme source and navigation. + * The editor / processor items for the app-switch menu. Rendered inside the + * BrandSwitcher's logo dropdown, which both apps use as their switcher. The + * mark is the shared , which recolours itself from the theme + * tokens, so no colour-scheme prop needs threading down here. */ -export function AppSwitch({ +export function AppSwitchMenuItems({ current, - theme, onSwitch, - className, -}: AppSwitchProps) { +}: AppSwitchMenuItemsProps) { const { t } = useTranslation(); - const mark = theme === "dark" ? markDark : markLight; const apps: Array<{ id: AppSwitchTarget; label: string }> = [ { id: "processor", @@ -55,28 +30,17 @@ export function AppSwitch({ { id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") }, ]; return ( - - - - - - {apps.map((app) => ( - onSwitch(app.id)} - leading={} - > - {app.label} - - ))} - - + {app.label} + + ))} + ); } diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx index 298a46e1ac..aaf55148ef 100644 --- a/frontend/editor/src/core/components/shared/AppSwitcher.tsx +++ b/frontend/editor/src/core/components/shared/AppSwitcher.tsx @@ -1,8 +1,22 @@ +import { Logo } from "@app/ui/Logo"; + +export interface AppSwitcherProps { + /** Icon-only brand mark for the collapsed rail. */ + collapsed?: boolean; +} + /** - * Core stub for the sidebar app switcher. Builds that bundle the admin portal - * (proprietary/saas) shadow this with a real switcher; core has no portal, so - * there is nothing to switch to. + * Sidebar brand header. Core has no admin portal to switch to, so it just + * shows the Stirling logo. Builds that bundle the portal (proprietary/saas) + * shadow this with a version whose logo doubles as the editor⇄processor + * switcher. */ -export function AppSwitcher() { - return null; +export function AppSwitcher({ collapsed }: AppSwitcherProps) { + return ( + + ); } diff --git a/frontend/editor/src/core/components/shared/BrandMark.css b/frontend/editor/src/core/components/shared/BrandMark.css new file mode 100644 index 0000000000..7ddff9b4c7 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandMark.css @@ -0,0 +1,56 @@ +/* Morphing Stirling mark. At rest: the two-tone red brand parallelograms + (from the `d` attributes in the markup). When an ancestor marked + [data-brandmark-morph] is hovered / focused / open, each parallelogram is + transformed into one arm of a smaller, symmetric downward chevron in the + primary text colour. + + The morph uses a CSS `transform` (not the `d` property) so it works in every + browser: both the logo shape and its target chevron arm are parallelograms, + and an affine matrix maps one exactly onto the other. The matrices below were + solved to send each path's 4 corners onto the chevron-arm corners: + arm A (left): M13 27 L35.5 41 L35.5 53 L13 39 Z + arm B (right): M35.5 41 L58 27 L58 39 L35.5 53 Z + (mirror images — equal area + dimensions). */ +.sui-brandmark { + display: block; + width: auto; + overflow: visible; +} + +.sui-brandmark__a, +.sui-brandmark__b { + transform-box: view-box; + transform-origin: 0 0; + transition: + transform var(--motion-slow), + fill var(--motion-slow); +} + +/* Rest state — brand mark. */ +.sui-brandmark__a { + fill: var(--c-brand-mark-soft); +} +.sui-brandmark__b { + fill: var(--c-brand-mark); +} + +/* Morphed state — the two chevron arms, both in the primary text colour. */ +[data-brandmark-morph]:hover .sui-brandmark__a, +[data-brandmark-morph]:focus-visible .sui-brandmark__a, +[data-brandmark-morph].is-open .sui-brandmark__a { + fill: var(--c-text); + transform: matrix(0.483871, 0.584583, 0, 0.338028, 13, 13.8169); +} +[data-brandmark-morph]:hover .sui-brandmark__b, +[data-brandmark-morph]:focus-visible .sui-brandmark__b, +[data-brandmark-morph].is-open .sui-brandmark__b { + fill: var(--c-text); + transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428); +} + +@media (prefers-reduced-motion: reduce) { + .sui-brandmark__a, + .sui-brandmark__b { + transition: none; + } +} diff --git a/frontend/editor/src/core/components/shared/BrandMark.tsx b/frontend/editor/src/core/components/shared/BrandMark.tsx new file mode 100644 index 0000000000..f140eddf7f --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandMark.tsx @@ -0,0 +1,36 @@ +import "@app/components/shared/BrandMark.css"; + +interface BrandMarkProps { + /** Height of the mark (CSS length). */ + height?: string; + className?: string; +} + +/** + * The Stirling logo mark as inline SVG so it can morph. At rest it is the + * two-tone red brand mark; when an ancestor marked `[data-brandmark-morph]` is + * hovered / focused / open (`.is-open`), the two parallelograms slide into a + * smaller downward chevron in the primary text colour — a self-explaining + * "this opens a menu" affordance. See BrandMark.css for the morph geometry. + */ +export function BrandMark({ height = "1.6rem", className }: BrandMarkProps) { + return ( + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.css b/frontend/editor/src/core/components/shared/BrandSwitcher.css new file mode 100644 index 0000000000..dc1659ea15 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandSwitcher.css @@ -0,0 +1,15 @@ +/* Logo + app-switch dropdown, shared between the editor and the processor. + The logo itself is the trigger (its mark morphs into a chevron on hover). */ +.sui-brand-switcher { + display: flex; + align-items: center; + flex: 1; + min-width: 0; +} + +/* Tighten the ghost-button padding so the lockup sits flush like a plain logo, + and negative-margin it back so the hover surface still extends past the text. */ +.sui-brand-switcher__trigger.sui-btn { + --button-padding-x: 0.375rem; + margin-inline: -0.375rem; +} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx new file mode 100644 index 0000000000..92deb518b2 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; + +const meta: Meta = { + title: "Brand/BrandSwitcher", + component: BrandSwitcher, + parameters: { layout: "centered" }, + args: { current: "processor", onSwitch: () => {} }, + argTypes: { + current: { control: "inline-radio", options: ["editor", "processor"] }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.tsx new file mode 100644 index 0000000000..474173fee2 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandSwitcher.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Dropdown } from "@app/ui"; +import { Logo } from "@app/ui/Logo"; +import { BrandMark } from "@app/components/shared/BrandMark"; +import { + AppSwitchMenuItems, + type AppSwitchTarget, +} from "@app/components/shared/AppSwitch"; +import "@app/components/shared/BrandSwitcher.css"; + +interface BrandSwitcherProps { + /** The app this is rendered in (shown active in the menu). */ + current: AppSwitchTarget; + /** Called with the selected app (only for the non-current one). */ + onSwitch: (app: AppSwitchTarget) => void; + /** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */ + collapsed?: boolean; + className?: string; +} + +/** + * Brand lockup that doubles as the editor⇄processor switcher. The whole logo + * is the dropdown trigger: on hover / focus / open the mark morphs into a + * downward chevron (see BrandMark), so no separate chevron button is needed. + * Shared so the editor and the processor present one identical header. + */ +export function BrandSwitcher({ + current, + onSwitch, + collapsed = false, + className, +}: BrandSwitcherProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( +
+ + + + + + + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 7dce3d32b9..e2393114df 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -1,17 +1,32 @@ /* ========== FILE SIDEBAR ========== */ .file-sidebar { - background-color: var(--c-bg-raised); - border-right: 1px solid var(--c-border-subtle); + background-color: var(--c-bg); display: flex; flex-direction: column; height: 100%; position: relative; - z-index: 10; - /* Animating width + min-width + max-width together can leave the flex layout - stuck on the pre-animation size in some browsers. Snap instead and rely - on the inner content fade for visual smoothness. */ + /* Above the workbench column (also z-10, but later in the DOM, so it would + otherwise paint over us). The brand switcher's menu is wider than the + collapsed rail and has to spill across that boundary intact. */ + z-index: var(--z-dropdown); flex-shrink: 0; + /* Slide the rail between collapsed/expanded. The delayed content-fade + (sidebar-content-in, 0.18s) is timed against this 0.22s so labels resolve + only after the width has settled — no squashed text mid-animation. */ + transition: + width var(--motion-spring), + min-width var(--motion-spring), + max-width var(--motion-spring); + /* Gap around the floating boxes. */ + padding: var(--nav-gutter); + gap: var(--nav-gutter); +} + +@media (prefers-reduced-motion: reduce) { + .file-sidebar { + transition: none; + } } .file-sidebar-inner { @@ -19,8 +34,74 @@ flex-direction: column; flex: 1; min-height: 0; + gap: 0.5rem; +} + +/* ---- Brand header (logo / editor⇄processor switcher) ---- */ +.file-sidebar-brand { + display: flex; + align-items: center; + min-height: 40px; + padding: 0 0.375rem; + flex-shrink: 0; +} + +.file-sidebar-collapse-toggle { + margin-left: auto; + flex-shrink: 0; +} + +.file-sidebar[data-collapsed="true"] .file-sidebar-brand { + flex-direction: column; + gap: 0.25rem; + padding: 0; +} +.file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle { + margin-left: 0; +} + +/* ---- Three floating nav-surface boxes (controls / files / footer) ---- */ +/* Horizontal padding is 0 so row highlights bleed to the surface edges; each + row's own inner padding keeps its text/icon indented. */ +.file-sidebar-controls { + padding: 0.25rem 0; + flex-shrink: 0; +} +.file-sidebar-files-box { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 0.25rem 0; overflow: hidden; } +.file-sidebar-footer-box { + padding: 0.25rem 0; + flex-shrink: 0; +} + +/* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and + let the boxes stack at the top — controls, then the settings footer right + after — instead of the files box stretching to fill. */ +.file-sidebar[data-collapsed="true"] .file-sidebar-controls, +.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box { + padding: 0.25rem; +} +.file-sidebar[data-collapsed="true"] .file-sidebar-files-box { + display: none; +} +.file-sidebar[data-collapsed="true"] .file-sidebar-inner { + flex: 0 0 auto; +} +/* Centre each row's icon in the narrow rail (no side padding/margin to shove + it off the edge). */ +.file-sidebar[data-collapsed="true"] .file-sidebar-search-row, +.file-sidebar[data-collapsed="true"] .file-sidebar-action-row, +.file-sidebar[data-collapsed="true"] .file-sidebar-cloud-row { + justify-content: center; + padding-inline: 0; + margin: 0; +} /* ---- Native file drag-and-drop ---- */ .file-sidebar[data-file-drag-over] { @@ -58,72 +139,16 @@ color: var(--mantine-color-blue-6, var(--c-primary)); } -/* ---- Header ---- */ -.file-sidebar-header { - display: flex; - align-items: center; - height: 48px; - padding: 0 14px; - gap: 10px; - cursor: pointer; - border-radius: 4px; - margin: 4px 4px 0 4px; - flex-shrink: 0; - transition: background-color 0.15s ease; -} - -/* Icons stay left-aligned during animation; overflow:hidden on inner clips text naturally */ - -.file-sidebar-header:hover { - background-color: var(--c-hover); -} - -.file-sidebar-menu-icon { - color: var(--c-text-subtle) !important; - font-size: 18px !important; - flex-shrink: 0; -} - -/* Inherits font-size so swap-in icons render at 18px like the original. */ -.file-sidebar-menu-icon > svg { - font-size: inherit; - width: 1em; - height: 1em; -} - -/* Flip directional toggle icons in RTL (skipped for the symmetric burger). */ -[dir="rtl"] .file-sidebar-menu-icon[data-toggle-flip-rtl="true"] > svg { - transform: scaleX(-1); -} - -.file-sidebar-brand-text { - height: 22px; - width: auto; - flex-shrink: 0; -} - -/* App switcher (portal builds only) sits at the far end of the header row. - The content-fade animation makes this span a stacking context, which would - trap the menu's z-index below later sidebar rows — elevate the span so the - open menu paints above them. */ -.file-sidebar-app-switch { - margin-inline-start: auto; - display: flex; - align-items: center; - position: relative; - z-index: var(--z-dropdown); -} - /* ---- Search row ---- */ .file-sidebar-search-row { display: flex; align-items: center; min-height: 32px; - padding: 0 14px; + padding: 0 8px; gap: 0; cursor: pointer; border-radius: 4px; - margin: 0 4px; + margin: 0; flex-shrink: 0; transition: background-color 0.15s ease; } @@ -160,7 +185,7 @@ .file-sidebar-search-label { margin-left: 12px; font-size: 14px; - color: var(--c-text-muted); + color: var(--c-text); } /* ---- Scrollable content ---- */ @@ -170,7 +195,8 @@ min-height: 0; display: flex; flex-direction: column; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; } .file-sidebar-scroll::-webkit-scrollbar { @@ -189,10 +215,10 @@ display: flex; align-items: center; height: 32px; - padding: 0 14px; + padding: 0 8px; cursor: pointer; border-radius: 4px; - margin: 0 4px; + margin: 0; gap: 0; transition: background-color 0.15s ease; flex-shrink: 0; @@ -234,7 +260,7 @@ .file-sidebar-action-label { margin-left: 12px; font-size: 14px; - color: var(--c-text-muted); + color: var(--c-text); white-space: nowrap; } @@ -243,10 +269,10 @@ display: flex; align-items: center; height: 32px; - padding: 0 14px; + padding: 0 8px; cursor: pointer; border-radius: 4px; - margin: 0 4px; + margin: 0; gap: 0; transition: background-color 0.15s ease; flex-shrink: 0; @@ -452,18 +478,16 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 10px 14px 6px 14px; - margin: 4px 0 0 0; - border-top: 1px solid var(--c-border-subtle); + padding: 0 6px 2px 6px; + margin: 0; flex-shrink: 0; } .file-sidebar-section-label { - font-size: 13px; + font-size: 0.875rem; font-weight: 600; - letter-spacing: 0.02em; - color: var(--c-text-subtle); - text-transform: uppercase; + letter-spacing: -0.01em; + color: var(--c-text); } /* Slim "Adding files… X/Y" progress row shown during a bulk drop's pre-scan, @@ -570,10 +594,9 @@ display: flex; align-items: center; gap: 8px; - padding: 8px 10px; - border-top: 1px solid var(--c-border-subtle); + padding: 4px 6px; flex-shrink: 0; - min-height: 48px; + min-height: 40px; } /* Bottom bar settings icon tracks the right edge during collapse animation */ diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 501c5385d1..7d09261226 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -8,6 +8,7 @@ import React, { } from "react"; import { Loader, Tooltip } from "@mantine/core"; import { ActionIcon } from "@app/ui/ActionIcon"; +import { NavSurface } from "@app/ui/NavSurface"; import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -29,10 +30,9 @@ import { } from "@app/contexts/IndexedDBContext"; import { accountService } from "@app/services/accountService"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; -import { Wordmark } from "@app/components/shared/Wordmark"; import { AppSwitcher } from "@app/components/shared/AppSwitcher"; +import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; import type { StirlingFileStub } from "@app/types/fileContext"; -import MenuIcon from "@mui/icons-material/Menu"; import SearchIcon from "@mui/icons-material/Search"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderSpecialIcon from "@mui/icons-material/FolderSpecial"; @@ -152,12 +152,12 @@ const FileSidebar = forwardRef( collapsed = false, onToggleCollapse, onOpenSettings, - toggleAriaLabel, - toggleIcon, onUploadFiles, onPickGoogleDriveFiles, onSearchClick, extraAction, + toggleAriaLabel, + toggleIcon, }, ref, ) { @@ -833,110 +833,78 @@ const FileSidebar = forwardRef(
)}
- {/* Header: hamburger + branding */} - -
onToggleCollapse?.()} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onToggleCollapse?.(); +
+ + {onToggleCollapse && ( + onToggleCollapse()} + aria-label={ + toggleAriaLabel ?? + (collapsed + ? t("fileSidebar.expand", "Expand sidebar") + : t("fileSidebar.collapse", "Collapse sidebar")) } - }} - aria-label={ - toggleAriaLabel ?? - (collapsed - ? t("fileSidebar.expand", "Expand sidebar") - : t("fileSidebar.collapse", "Collapse sidebar")) - } - > - {/* Wrapper carries sizing; data-toggle-flip-rtl flips icon in RTL. */} - - {toggleIcon ?? } - - {!collapsed && ( - - )} - {!collapsed && ( - // The header row itself toggles collapse; stop the switcher's - // clicks and key presses from reaching it. - e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - - - )} -
- + {toggleIcon ?? } + + )} +
- {/* Search row */} - -
e.key === "Enter" && handleSearchClick() - : undefined - } + {/* Box 1 — top controls (search + open / my files / cloud). No title. */} + + {/* Search row */} + - {searchActive && !collapsed ? ( - { - e.stopPropagation(); - handleSearchClose(); - }} - /> - ) : ( - - )} - {!collapsed && - (searchActive ? ( - setSearchQuery(e.target.value)} - placeholder={t( - "fileSidebar.searchPlaceholder", - "Search files...", - )} - onClick={(e) => e.stopPropagation()} +
e.key === "Enter" && handleSearchClick() + : undefined + } + > + {searchActive && !collapsed ? ( + { + e.stopPropagation(); + handleSearchClose(); + }} /> ) : ( - - {t("fileSidebar.search", "Search")} - - ))} -
-
+ + )} + {!collapsed && + (searchActive ? ( + setSearchQuery(e.target.value)} + placeholder={t( + "fileSidebar.searchPlaceholder", + "Search files...", + )} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {t("fileSidebar.search", "Search")} + + ))} +
+
- {/* Scrollable content */} -
{/* Hidden native file input - kept outside the !collapsed gate so the "Open from computer" row below (always rendered) can fire it in either sidebar state without a silent no-op. */} @@ -1157,145 +1125,158 @@ const FileSidebar = forwardRef( )}
)} + - {/* Files section - always visible when expanded */} - {!collapsed && ( -
-
- - {t("fileSidebar.files", "Files")} - - - navigate("/files")} - title={t( - "fileSidebar.openFileManager", - "Browse all files & folders", - )} - aria-label={t( - "fileSidebar.openFileManager", - "Browse all files & folders", - )} - data-testid="open-files-page" - > - - - nativeFileInputRef.current?.click()} - title={t("fileSidebar.addFiles", "Add files")} - aria-label={t("fileSidebar.addFiles", "Add files")} - > - - -
- - - - {!stubsLoaded ? ( -
- + {/* Box 2 — the file tree (this box scrolls). */} + +
+ {/* Files section - always visible when expanded */} + {!collapsed && ( +
+
+ + {t("fileSidebar.library", "PDF Library")} + + + navigate("/files")} + title={t( + "fileSidebar.openFileManager", + "Browse all files & folders", + )} + aria-label={t( + "fileSidebar.openFileManager", + "Browse all files & folders", + )} + data-testid="open-files-page" + > + + + nativeFileInputRef.current?.click()} + title={t("fileSidebar.addFiles", "Add files")} + aria-label={t("fileSidebar.addFiles", "Add files")} + > + +
- ) : filteredFileStubs.length > 0 ? ( -
- {fileGroups ? ( - <> - {fileGroups.map((group) => { - const isOpen = - groupOpen[group.id] ?? group.defaultExpanded; - return ( -
- -
- {isOpen && group.stubs.map(renderFileRow)} -
-
- ); - })} - - - ) : ( - filteredFileStubs.map(renderFileRow) - )} -
- ) : ( - !searchActive && ( -
-

- {t("fileSidebar.noFiles", "No files yet")} -

-

- {t("fileSidebar.dropHint", "Open files to get started")} -

+ + + + {!stubsLoaded ? ( +
+
- ) - )} -
- )} -
+ ) : filteredFileStubs.length > 0 ? ( +
+ {fileGroups ? ( + <> + {fileGroups.map((group) => { + const isOpen = + groupOpen[group.id] ?? group.defaultExpanded; + return ( +
+ +
+ {isOpen && group.stubs.map(renderFileRow)} +
+
+ ); + })} + + + ) : ( + filteredFileStubs.map(renderFileRow) + )} +
+ ) : ( + !searchActive && ( +
+

+ {t("fileSidebar.noFiles", "No files yet")} +

+

+ {t( + "fileSidebar.dropHint", + "Open files to get started", + )} +

+
+ ) + )} +
+ )} +
+
{/* Kebab "Save to cloud" upload modal (one file at a time). */} @@ -1325,65 +1306,70 @@ const FileSidebar = forwardRef( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Bottom bar: user name + settings */} - -
+ {/* Bottom bar: user name + settings */} + e.key === "Enter" && onOpenSettings() - : undefined - } - data-testid={onOpenSettings ? "config-button" : undefined} - data-tour={onOpenSettings ? "config-button" : undefined} - aria-label={ - onOpenSettings - ? t("fileSidebar.openSettings", "Open settings") + ? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}` : displayName } - style={onOpenSettings ? { cursor: "pointer" } : undefined} + position="right" + withinPortal + disabled={!collapsed} >
e.key === "Enter" && onOpenSettings() + : undefined + } + data-testid={onOpenSettings ? "config-button" : undefined} + data-tour={onOpenSettings ? "config-button" : undefined} + aria-label={ + onOpenSettings + ? t("fileSidebar.openSettings", "Open settings") + : displayName + } + style={onOpenSettings ? { cursor: "pointer" } : undefined} > - {showProfilePicture ? ( - setPictureFailed(true)} - /> - ) : ( - displayName.charAt(0).toUpperCase() +
+ {showProfilePicture ? ( + setPictureFailed(true)} + /> + ) : ( + displayName.charAt(0).toUpperCase() + )} +
+ {!collapsed && ( + + {displayName} + + )} + {onOpenSettings && !collapsed && ( +
+ +
)}
- {!collapsed && ( - - {displayName} - - )} - {onOpenSettings && !collapsed && ( -
- -
- )} -
-
+
+
); }, diff --git a/frontend/editor/src/core/components/shared/LandingActions.tsx b/frontend/editor/src/core/components/shared/LandingActions.tsx index ec237b0394..d69f90a1ef 100644 --- a/frontend/editor/src/core/components/shared/LandingActions.tsx +++ b/frontend/editor/src/core/components/shared/LandingActions.tsx @@ -33,6 +33,7 @@ export function LandingActions({
@@ -263,37 +264,62 @@ export default function RightSidebar() { onChange={handleHeaderSearchChange} toolRegistry={toolRegistry} mode="filter" - autoFocus={allToolsView && !inToolView} + autoFocus />
- ) : null} - {showCloseButton ? ( - - - ) : ( - - - + + {t("toolPanel.pdfTools", "PDF Tools")} + )} +
+ {!showCloseButton && ( + { + if (headerSearchOpen) handleHeaderSearchChange(""); + setHeaderSearchOpen((open) => !open); + }} + aria-label={t("toolPanel.searchTools", "Search tools")} + className="tool-panel__expand-btn" + > + {headerSearchOpen ? ( + + ) : ( + + )} + + )} + {showCloseButton ? ( + + + + ) : ( + + + + )} +
)} diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index 5ba31e5b86..41100bbf9f 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -11,6 +11,7 @@ .tool-panel { position: relative; + background: var(--c-surface); transition: width 0.3s ease, max-width 0.3s ease; @@ -18,6 +19,14 @@ user-select: none; } +.tool-panel--floating { + margin: var(--nav-gutter) var(--nav-gutter) var(--nav-gutter) 0; + height: calc(100vh - (var(--nav-gutter) * 2)); + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-nav); +} + .tool-panel__collapsed-strip { display: flex; flex-direction: column; @@ -151,8 +160,21 @@ box-sizing: border-box; } -.tool-panel__compact-header .tool-panel__expand-btn { +.tool-panel__compact-title { + flex: 1 1 auto; + min-width: 0; + font-size: 0.875rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--c-text); +} + +.tool-panel__compact-header-actions { + display: flex; + align-items: center; + gap: 0.25rem; margin-left: auto; + flex-shrink: 0; } .tool-panel__compact-header-search { diff --git a/frontend/editor/src/core/components/tools/ToolPicker.tsx b/frontend/editor/src/core/components/tools/ToolPicker.tsx index 6cda34aff2..5c5ce0e261 100644 --- a/frontend/editor/src/core/components/tools/ToolPicker.tsx +++ b/frontend/editor/src/core/components/tools/ToolPicker.tsx @@ -50,7 +50,7 @@ const SCROLLABLE_STYLE: React.CSSProperties = { const CONTAINER_STYLE: React.CSSProperties = { display: "flex", flexDirection: "column", - background: "var(--c-bg-raised)", + background: "var(--c-surface)", }; const toTitleCase = (s: string) => s.replace( diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 1e21637849..cc4a04598e 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -1364,9 +1364,12 @@ const EmbedPdfViewerContent = ({ {/* Bottom Toolbar Overlay */} {effectiveFile && (
diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index cd0a838421..983d5213ed 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -499,6 +499,7 @@ export default function HomePage() { gap={0} h="100%" className="flex-nowrap flex" + bg="var(--c-bg)" > { test("should prevent XSS via search input", async ({ page }) => { await loginAndSetup(page); - // Step 1: Enter XSS payload in the search box + // Step 1: Open the search box (the tool panel header shows a search + // toggle; the field only mounts once it's pressed) and enter the payload + await page.getByRole("button", { name: /search tools/i }).click(); const searchBox = page.getByPlaceholder(/search|cari/i).first(); await searchBox.fill('">'); diff --git a/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts b/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts index b351acb1be..30ecc8d779 100644 --- a/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts @@ -54,10 +54,12 @@ test.describe("13. Language / Localization", () => { // Step 5: Wait for page reload (language change triggers window.location.reload()) await page.waitForLoadState("domcontentloaded"); - // Step 6: Verify the UI text is in English - await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({ - timeout: 10000, - }); + // Step 6: Verify the UI text is in English. The tool search is a + // header toggle, so assert its English label rather than the field, + // which only mounts once the toggle is pressed. + await expect( + page.getByRole("button", { name: /search tools/i }).first(), + ).toBeVisible({ timeout: 10000 }); } }); }); diff --git a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts index c66f392fa1..b8efb283fc 100644 --- a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts @@ -17,6 +17,14 @@ test.describe("2. Main Dashboard / Home Page", () => { page.locator('[data-testid="config-button"]').first(), ).toBeVisible(); + // Tool search sits behind a header toggle now, so assert the affordance + // AND that pressing it actually mounts a usable search field — dropping + // the second half would stop covering the input entirely. + const searchToggle = page + .getByRole("button", { name: /search tools/i }) + .first(); + await expect(searchToggle).toBeVisible(); + await searchToggle.click(); await expect(page.getByPlaceholder(/search/i).first()).toBeVisible(); await expect( @@ -74,7 +82,10 @@ test.describe("2. Main Dashboard / Home Page", () => { await page.goto("/"); - await expect(page.getByPlaceholder(/search/i).first()).toBeVisible(); + // Tool search is a header toggle; the field mounts only once pressed. + await expect( + page.getByRole("button", { name: /search tools/i }).first(), + ).toBeVisible(); }); }); diff --git a/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts b/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts index 397c86d605..7dfda74b79 100644 --- a/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts @@ -1,13 +1,24 @@ +import type { Page } from "@playwright/test"; import { test, expect } from "@app/tests/helpers/stub-test-base"; +/** + * The tool panel header shows a search *toggle*; the field only mounts once + * it's pressed. Open it and hand back the focused input. + */ +async function openToolSearch(page: Page) { + await page.getByRole("button", { name: /search tools/i }).click(); + const searchBox = page.getByPlaceholder(/search|cari/i).first(); + await expect(searchBox).toBeVisible({ timeout: 5000 }); + return searchBox; +} + test.describe("3. Tool Search", () => { test.describe("3.1 Search - Happy Path", () => { test("should filter tools in real time based on search input", async ({ page, }) => { - // Step 1: Click on the search box - const searchBox = page.getByPlaceholder(/search|cari/i).first(); - await searchBox.click(); + // Step 1: Open the search box from the header toggle + const searchBox = await openToolSearch(page); // Step 2: Type "merge" await searchBox.fill("merge"); @@ -31,9 +42,8 @@ test.describe("3. Tool Search", () => { test("should handle queries with no matching tools gracefully", async ({ page, }) => { - // Step 1: Click on the search box - const searchBox = page.getByPlaceholder(/search|cari/i).first(); - await searchBox.click(); + // Step 1: Open the search box from the header toggle + const searchBox = await openToolSearch(page); // Step 2: Type xyznonexistent123 await searchBox.fill("xyznonexistent123"); @@ -61,7 +71,7 @@ test.describe("3. Tool Search", () => { test.describe("3.3 Search - Special Characters", () => { test("should sanitize search input against XSS", async ({ page }) => { // Step 1: Type XSS payload into the search box - const searchBox = page.getByPlaceholder(/search|cari/i).first(); + const searchBox = await openToolSearch(page); await searchBox.fill(""); // Step 2: Verify no script execution occurs (no alert dialog) diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index ced2c5dcb6..c7b0ccb08f 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -5,9 +5,9 @@ :root, [data-theme="light"], html[data-app-theme="light"] { - --c-bg: var(--p-gray-50); + --c-bg: var(--p-paper); --c-bg-raised: var(--p-white); - --c-surface: var(--p-white); + --c-surface: var(--p-snow); --c-surface-raised: var(--p-white); --c-surface-sunken: var(--p-gray-100); --c-input-bg: var(--p-white); @@ -15,12 +15,17 @@ html[data-app-theme="light"] { --c-active: var(--p-gray-100); --c-overlay: rgba(0, 0, 0, 0.5); - --c-text: var(--p-gray-900); + --c-text: var(--p-ink); --c-text-muted: var(--p-gray-600); - --c-text-subtle: var(--p-gray-500); + --c-text-subtle: var(--p-gray-550); --c-text-on-primary: var(--p-white); - --c-border: var(--p-gray-250); + --c-btn-solid: var(--c-text); + --c-btn-inverse: var(--p-snow); + --c-btn-secondary: var(--c-btn-inverse); + --c-btn-secondary-border: var(--c-border); + + --c-border: var(--p-c-f0f0f0); --c-border-subtle: var(--p-gray-200); --c-border-strong: var(--p-gray-400); @@ -55,7 +60,8 @@ html[data-app-theme="light"] { marks, vendor colours, categorical avatar dots, static illustrations, and the multi-hue gradients on feature/upgrade/onboarding surfaces. Named here so components reference a --c-* token, never a raw --p-*. */ - --c-brand-mark: var(--p-brand-red-650); /* Stirling logo mark fill */ + --c-brand-mark: var(--p-brand-red-650); + --c-brand-mark-soft: var(--p-brand-red-400); /* Stirling logo mark fill */ --c-accent-stripe: var(--p-periwinkle-500); /* Stripe "connect" CTA */ /* Feature-accent hues (fixed) used as stops in multi-hue gradients. */ @@ -110,9 +116,9 @@ html[data-app-theme="light"] { /* ── MIDNIGHT (original navy) — also the default portal/Storybook dark ────── */ [data-theme="dark"], html[data-app-theme="midnight"] { - --c-bg: var(--p-zinc-900); + --c-bg: var(--p-c-141416); --c-bg-raised: var(--p-zinc-850); - --c-surface: var(--p-zinc-800); + --c-surface: var(--p-c-1a1a1d); --c-surface-raised: var(--p-zinc-650); --c-surface-sunken: var(--p-zinc-850); --c-input-bg: var(--p-zinc-650); @@ -120,12 +126,16 @@ html[data-app-theme="midnight"] { --c-active: var(--p-gray-800); --c-overlay: rgba(0, 0, 0, 0.6); - --c-text: var(--p-zinc-100); + --c-text: var(--p-snow); --c-text-muted: var(--p-zinc-200); --c-text-subtle: var(--p-zinc-300); --c-text-on-primary: var(--p-white); + --c-btn-solid: var(--c-text); + --c-btn-inverse: var(--p-ink); + --c-btn-secondary: var(--p-c-1a1a1d); + --c-btn-secondary-border: var(--p-c-343439); - --c-border: var(--p-zinc-650); + --c-border: var(--p-c-28282d); --c-border-subtle: rgba(255, 255, 255, 0.05); --c-border-strong: var(--p-zinc-500); @@ -157,9 +167,9 @@ html[data-app-theme="custom"] { --c-accent-fg: var(--c-primary); /* Primary-tinted surfaces (light base). Neutralised by the default override. */ - --c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-gray-50)); + --c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-paper)); --c-bg-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white)); - --c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-white)); + --c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-snow)); --c-surface-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white)); --c-surface-sunken: color-mix( in srgb, @@ -169,7 +179,7 @@ html[data-app-theme="custom"] { --c-input-bg: color-mix(in srgb, var(--c-primary) 2%, var(--p-white)); --c-hover: color-mix(in srgb, var(--c-primary) 9%, var(--p-gray-50)); --c-active: color-mix(in srgb, var(--c-primary) 13%, var(--p-gray-100)); - --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-gray-250)); + --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-c-f0f0f0)); --c-border-subtle: color-mix( in srgb, var(--c-primary) 10%, @@ -270,16 +280,20 @@ html[data-app-theme="custom"] { /* ── DARK — editor dark theme: neutral text/borders/icons + accent-tinted surfaces (default override opts out). After :root so it wins for dark. ── */ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] { /* Neutral text / borders / overlay (not accent-tinted). */ - --c-text: var(--p-zinc-100); + --c-text: var(--p-snow); --c-text-muted: var(--p-zinc-200); --c-text-subtle: var(--p-zinc-300); + --c-btn-solid: var(--c-text); + --c-btn-inverse: var(--p-ink); + --c-btn-secondary: var(--p-c-1a1a1d); + --c-btn-secondary-border: var(--p-c-343439); --c-border-strong: var(--p-zinc-500); --c-overlay: rgba(0, 0, 0, 0.6); /* Accent-tinted surfaces (dark base). Neutralised by the default override. */ - --c-bg: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-950)); + --c-bg: color-mix(in srgb, var(--c-primary) 8%, var(--p-c-141416)); --c-bg-raised: color-mix(in srgb, var(--c-primary) 9%, var(--p-zinc-850)); - --c-surface: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-800)); + --c-surface: color-mix(in srgb, var(--c-primary) 8%, var(--p-c-1a1a1d)); --c-surface-raised: color-mix( in srgb, var(--c-primary) 9%, @@ -293,7 +307,7 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] { --c-input-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-zinc-900)); --c-hover: color-mix(in srgb, var(--c-primary) 12%, var(--p-zinc-750)); --c-active: color-mix(in srgb, var(--c-primary) 15%, var(--p-zinc-700)); - --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-zinc-650)); + --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-c-28282d)); --c-border-subtle: color-mix( in srgb, var(--c-primary) 10%, @@ -335,27 +349,27 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] { /* ── DEFAULT (no tint) — surfaces opt out of the accent tint (neutral white/grey light, zinc dark); --c-primary stays for buttons. Extra [data-accent="default"] beats the tinted blocks. ── */ html[data-app-theme="custom"][data-accent="default"] { - --c-bg: var(--p-gray-50); + --c-bg: var(--p-paper); --c-bg-raised: var(--p-white); - --c-surface: var(--p-white); + --c-surface: var(--p-snow); --c-surface-raised: var(--p-white); --c-surface-sunken: var(--p-gray-100); --c-input-bg: var(--p-white); --c-hover: var(--p-gray-50); --c-active: var(--p-gray-100); - --c-border: var(--p-gray-250); + --c-border: var(--p-c-f0f0f0); --c-border-subtle: var(--p-gray-200); } html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme="dark"] { - --c-bg: var(--p-zinc-950); + --c-bg: var(--p-c-141416); --c-bg-raised: var(--p-zinc-850); - --c-surface: var(--p-zinc-800); + --c-surface: var(--p-c-1a1a1d); --c-surface-raised: var(--p-zinc-775); --c-surface-sunken: var(--p-zinc-900); --c-input-bg: var(--p-zinc-900); --c-hover: var(--p-zinc-750); --c-active: var(--p-zinc-700); - --c-border: var(--p-zinc-650); + --c-border: var(--p-c-28282d); --c-border-subtle: var(--p-zinc-700); } diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css index 8042fcade6..aa0a1b38cc 100644 --- a/frontend/editor/src/core/theme/dimensions.css +++ b/frontend/editor/src/core/theme/dimensions.css @@ -28,6 +28,10 @@ --radius-xl: 16px; --radius-pill: 9999px; + --radius-nav: 0.625rem; + --nav-gutter: 0.5rem; + --nav-rail-w: 3.5rem; + /* ── Layout sizing ── */ --footer-height: 2rem; --landing-stack-w: 224px; @@ -51,6 +55,7 @@ --motion-base: 0.2s cubic-bezier(0.4, 0, 0.2, 1); --motion-slow: 0.3s ease; --motion-enter: 0.22s cubic-bezier(0.4, 0, 0.2, 1); + --motion-spring: 0.22s cubic-bezier(0.32, 0.72, 0, 1); --fullscreen-anim-duration-in: 0.28s; --fullscreen-anim-duration-out: 0.22s; diff --git a/frontend/editor/src/core/theme/primitives.css b/frontend/editor/src/core/theme/primitives.css index 6ea844e9ed..239658dfeb 100644 --- a/frontend/editor/src/core/theme/primitives.css +++ b/frontend/editor/src/core/theme/primitives.css @@ -11,6 +11,10 @@ --p-gray-300: #d1d5db; --p-gray-400: #9ca3af; --p-gray-500: #6b7280; + /* Subtle body text in light mode. gray-500 clears 4.5:1 on pure white but + only reaches 4.39:1 on the --p-paper canvas; this is the same hue nudged + dark enough to pass (4.87:1) while staying lighter than gray-600. */ + --p-gray-550: #646b76; --p-gray-600: #4b5563; --p-gray-700: #374151; --p-gray-800: #1f2937; @@ -29,6 +33,10 @@ --p-zinc-300: #71717a; --p-zinc-200: #a1a1aa; --p-zinc-100: #f4f4f5; + --p-c-141416: #141416; + --p-c-1a1a1d: #1a1a1d; + --p-c-28282d: #28282d; + --p-c-343439: #343439; --p-blue-400: #60a5fa; --p-blue-500: #3b82f6; --p-blue-600: #2563eb; @@ -46,6 +54,7 @@ /* Brand-red + ai-accent scales, consumed by core/ui/accents.css. */ --p-brand-red-200: #d9a8a8; --p-brand-red-300: #d98a8a; + --p-brand-red-400: #ad7373; --p-brand-red-650: #8e3131; --p-brand-red-700: #7a2929; --p-brand-red-900: #5a2424; @@ -84,6 +93,10 @@ --p-tint-blue: #eef1fb; --p-tint-violet: #f6f4fc; --p-tint-pink: #fbf4f7; + --p-paper: #f5f4f1; + --p-ink: #373530; + --p-snow: #fafafa; + --p-c-f0f0f0: #f0f0f0; /* Notion-style procurement view palette. */ --p-notion-blue: #2383e2; @@ -92,7 +105,6 @@ --p-notion-ink: #37352f; --p-notion-gray: #9b9a97; --p-notion-gray-strong: #787774; - --p-notion-paper: #f5f4f1; --p-notion-paper-2: #f0eee9; --p-notion-border: #e3e1dc; --p-notion-border-2: #eae8e3; diff --git a/frontend/editor/src/core/tokens/tokens.css b/frontend/editor/src/core/tokens/tokens.css index c36fae35fc..2fe1fc3b70 100644 --- a/frontend/editor/src/core/tokens/tokens.css +++ b/frontend/editor/src/core/tokens/tokens.css @@ -326,16 +326,6 @@ opacity: 0.5; } } -@keyframes pulseRing { - 0% { - transform: scale(0.8); - opacity: 1; - } - 100% { - transform: scale(2.2); - opacity: 0; - } -} @keyframes spin { to { transform: rotate(360deg); diff --git a/frontend/editor/src/core/ui/ActionIcon.tsx b/frontend/editor/src/core/ui/ActionIcon.tsx index 682e4b41e5..ca8dd0b9ef 100644 --- a/frontend/editor/src/core/ui/ActionIcon.tsx +++ b/frontend/editor/src/core/ui/ActionIcon.tsx @@ -103,15 +103,22 @@ export const ActionIcon = forwardRef( "--ai-hover-color": "var(--c-text)", "--ai-bd": "1px solid transparent", } - : { - "--ai-bg": "transparent", - "--ai-hover": "var(--_tint)", - "--ai-color": "var(--_text)", - "--ai-bd": - variant === "secondary" - ? "1px solid var(--_bd)" - : "1px solid transparent", - }; + : variant === "secondary" + ? { + // Filled when the accent defines --_solid-2 (default = inverse + // ink/snow); otherwise falls back to the outlined look. + "--ai-bg": "var(--_solid-2, transparent)", + "--ai-hover": "var(--_solid-2-hover, var(--_tint))", + "--ai-color": "var(--_on-2, var(--_text))", + "--ai-bd": "1px solid var(--_bd-2, var(--_bd))", + } + : { + // tertiary (ghost) — neutral text + hover for the default accent. + "--ai-bg": "transparent", + "--ai-hover": "var(--_tert-tint, var(--_tint))", + "--ai-color": "var(--_tert-text, var(--_text))", + "--ai-bd": "1px solid transparent", + }; // Loosely-typed alias so the polymorphic `component={as}` doesn't fight Mantine's typing. const Comp = MantineActionIcon as ElementType; diff --git a/frontend/editor/src/core/ui/Button.tsx b/frontend/editor/src/core/ui/Button.tsx index 7d2ff6d457..8c04aa226c 100644 --- a/frontend/editor/src/core/ui/Button.tsx +++ b/frontend/editor/src/core/ui/Button.tsx @@ -193,15 +193,23 @@ const ButtonRoot = forwardRef( "--button-hover-color": "var(--c-text)", "--button-bd": "1px solid transparent", } - : { - "--button-bg": "transparent", - "--button-hover": "var(--_tint)", - "--button-color": "var(--_text)", - "--button-bd": - variant === "secondary" - ? "1px solid var(--_bd)" - : "1px solid transparent", - }; + : variant === "secondary" + ? { + // Filled when the accent defines --_solid-2 (default = inverse + // ink/snow); otherwise falls back to the outlined look. + "--button-bg": "var(--_solid-2, transparent)", + "--button-hover": "var(--_solid-2-hover, var(--_tint))", + "--button-color": "var(--_on-2, var(--_text))", + "--button-bd": "1px solid var(--_bd-2, var(--_bd))", + } + : { + // tertiary (ghost) — neutral text + hover when the accent + // defines --_tert-* (default); otherwise the accent link colour. + "--button-bg": "transparent", + "--button-hover": "var(--_tert-tint, var(--_tint))", + "--button-color": "var(--_tert-text, var(--_text))", + "--button-bd": "1px solid transparent", + }; // Loosely-typed alias so the polymorphic `component={as}` doesn't fight Mantine's typing. const Comp = MantineButton as ElementType; diff --git a/frontend/editor/src/core/ui/ChatFABButton.css b/frontend/editor/src/core/ui/ChatFABButton.css index 171abf8809..b68713aa1f 100644 --- a/frontend/editor/src/core/ui/ChatFABButton.css +++ b/frontend/editor/src/core/ui/ChatFABButton.css @@ -5,11 +5,10 @@ width: 56px; height: 56px; border-radius: 16px; - border: none; - background: var(--c-primary); - color: var(--c-text-on-primary); + border: 1px solid var(--c-btn-secondary-border); + background: var(--c-btn-secondary); cursor: pointer; - box-shadow: 0 4px 16px color-mix(in srgb, var(--c-primary) 40%, transparent); + box-shadow: var(--shadow-md); transition: transform 180ms cubic-bezier(0.32, 0.72, 0, 1), box-shadow 180ms ease; @@ -20,7 +19,7 @@ .chat-fab-btn:hover { transform: scale(1.09); - box-shadow: 0 6px 22px color-mix(in srgb, var(--c-primary) 52%, transparent); + box-shadow: var(--shadow-lg); } .chat-fab-btn:active { diff --git a/frontend/editor/src/core/ui/ChatFABButton.tsx b/frontend/editor/src/core/ui/ChatFABButton.tsx index 5ba3c8113b..672bd7c1c4 100644 --- a/frontend/editor/src/core/ui/ChatFABButton.tsx +++ b/frontend/editor/src/core/ui/ChatFABButton.tsx @@ -1,4 +1,5 @@ import type { ButtonHTMLAttributes } from "react"; +import { BrandMark } from "@app/components/shared/BrandMark"; import "@app/ui/ChatFABButton.css"; export interface ChatFABButtonProps extends ButtonHTMLAttributes { @@ -25,20 +26,10 @@ export function ChatFABButton({ return ( diff --git a/frontend/editor/src/core/ui/StatusBadge.css b/frontend/editor/src/core/ui/StatusBadge.css index 230c99b40d..4f64737b32 100644 --- a/frontend/editor/src/core/ui/StatusBadge.css +++ b/frontend/editor/src/core/ui/StatusBadge.css @@ -2,35 +2,21 @@ display: inline-flex; align-items: center; gap: 0.375rem; - border-radius: var(--radius-pill); font-family: var(--font-sans); font-weight: 500; letter-spacing: 0.01em; - border: 1px solid transparent; line-height: 1; color: var(--sui-status-c, var(--c-text-subtle)); - background: color-mix( - in srgb, - var(--sui-status-c, var(--c-text-subtle)) 12%, - transparent - ); - border-color: color-mix( - in srgb, - var(--sui-status-c, var(--c-text-subtle)) 28%, - transparent - ); } + .sui-status--sm { font-size: 0.6875rem; - padding: 0.125rem 0.5rem; } .sui-status--md { font-size: 0.75rem; - padding: 0.1875rem 0.625rem; } .sui-status--lg { font-size: 0.8125rem; - padding: 0.3125rem 0.75rem; } .sui-status__dot { @@ -38,25 +24,40 @@ height: 0.375rem; border-radius: 50%; background: currentColor; - position: relative; -} -.sui-status__dot--pulse::after { - content: ""; - position: absolute; - inset: -0.125rem; - border-radius: 50%; - border: 2px solid currentColor; - animation: pulseRing 1.4s ease-out infinite; } -/* Neutral keeps the plain muted surface rather than an accent tint. */ +.sui-status--pill { + border-radius: var(--radius-pill); + border: 1px solid + color-mix( + in srgb, + var(--sui-status-c, var(--c-text-subtle)) 28%, + transparent + ); + background: color-mix( + in srgb, + var(--sui-status-c, var(--c-text-subtle)) 12%, + transparent + ); +} +.sui-status--pill.sui-status--sm { + padding: 0.125rem 0.5rem; +} +.sui-status--pill.sui-status--md { + padding: 0.1875rem 0.625rem; +} +.sui-status--pill.sui-status--lg { + padding: 0.3125rem 0.75rem; +} + .sui-status--neutral { color: var(--c-text-subtle); +} +.sui-status--pill.sui-status--neutral { background: var(--c-surface-sunken); border-color: var(--c-border-subtle); } -/* Tones only pick the accent; the base rule builds the fill + border. `-dark` - is theme-adaptive, so text stays legible on the pale fill in both themes. */ + .sui-status--success { --sui-status-c: var(--color-green-dark); } diff --git a/frontend/editor/src/core/ui/StatusBadge.stories.tsx b/frontend/editor/src/core/ui/StatusBadge.stories.tsx index 8b77c58e41..9edeb00717 100644 --- a/frontend/editor/src/core/ui/StatusBadge.stories.tsx +++ b/frontend/editor/src/core/ui/StatusBadge.stories.tsx @@ -33,7 +33,7 @@ export const AllTones: Story = { }; export const Live: Story = { - args: { tone: "success", pulse: true, children: "Live" }, + args: { tone: "success", children: "Live" }, }; export const Sizes: Story = { diff --git a/frontend/editor/src/core/ui/StatusBadge.tsx b/frontend/editor/src/core/ui/StatusBadge.tsx index 5ffd5d44f9..7d80819edd 100644 --- a/frontend/editor/src/core/ui/StatusBadge.tsx +++ b/frontend/editor/src/core/ui/StatusBadge.tsx @@ -14,28 +14,21 @@ export type StatusSize = "sm" | "md" | "lg"; export interface StatusBadgeProps { tone?: StatusTone; size?: StatusSize; - /** Show a leading coloured dot. */ showDot?: boolean; - /** Render the dot with a pulse animation (active / live indicator). */ - pulse?: boolean; children?: ReactNode; className?: string; } -/** - * Inline status pill used across surfaces — pipeline rows, document status, - * deployments, audit logs. Tone maps to semantic meaning, not raw colour. - */ export function StatusBadge({ tone = "neutral", size = "md", showDot = true, - pulse = false, children, className, }: StatusBadgeProps) { const cls = [ "sui-status", + showDot ? "" : "sui-status--pill", `sui-status--${tone}`, `sui-status--${size}`, className ?? "", @@ -44,12 +37,7 @@ export function StatusBadge({ .join(" "); return ( - {showDot && ( - - )} + {showDot && } {children} ); diff --git a/frontend/editor/src/core/ui/accents.css b/frontend/editor/src/core/ui/accents.css index 404f98cc52..cf83edacaf 100644 --- a/frontend/editor/src/core/ui/accents.css +++ b/frontend/editor/src/core/ui/accents.css @@ -2,16 +2,26 @@ * accents derive from --color-* tokens (auto dark), neutral/brand/ai are explicit. */ .sui-acc-default { - --_solid: var(--c-primary); - --_solid-hover: var(--c-primary-hover); - --_on: #ffffff; + --_solid: var(--c-btn-solid); + --_solid-hover: color-mix( + in srgb, + var(--c-btn-solid) 85%, + var(--c-btn-inverse) + ); + --_on: var(--c-btn-inverse); --_text: var(--c-primary-hover); --_bd: color-mix(in srgb, var(--c-primary) 38%, var(--c-surface)); --_tint: color-mix(in srgb, var(--c-primary) 12%, transparent); -} - -html[data-app-theme="custom"] .sui-acc-default { - --_on: var(--c-text-on-primary); + --_solid-2: var(--c-btn-secondary); + --_solid-2-hover: color-mix( + in srgb, + var(--c-btn-secondary) 92%, + var(--c-btn-solid) + ); + --_on-2: var(--c-btn-solid); + --_bd-2: var(--c-btn-secondary-border); + --_tert-text: var(--c-text); + --_tert-tint: var(--c-hover); } /* Danger is pinned to a fixed deep red (not the theme-lightened coral), so the fill and the outline/text are the SAME red in both light and dark. */ diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts index d94dfab813..45212d8038 100644 --- a/frontend/editor/src/core/ui/index.ts +++ b/frontend/editor/src/core/ui/index.ts @@ -1,5 +1,6 @@ export * from "@app/ui/Button"; export * from "@app/ui/ActionIcon"; +export * from "@app/ui/Logo"; export * from "@app/ui/FilePicker"; export * from "@app/ui/SegmentedControl"; export * from "@app/ui/StatusBadge"; @@ -8,6 +9,7 @@ export * from "@app/ui/ToggleSwitch"; export * from "@app/ui/ProgressBar"; export * from "@app/ui/MetricCard"; export * from "@app/ui/NavItem"; +export * from "@app/ui/NavSurface"; export * from "@app/ui/PanelHeader"; export * from "@app/ui/CodeBlock"; export * from "@app/ui/SectionDivider"; diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx index 9d7c2c095e..21896d9a1d 100644 --- a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx +++ b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx @@ -1,9 +1,18 @@ +import { Logo } from "@app/ui/Logo"; +import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; + /** * Desktop inherits proprietary's layers but does not ship the portal (see - * desktop/routes/adminRouteExtensions), so shadow the switcher back to empty — - * otherwise the desktop bundle would reference @portal via the proprietary - * switcher's imports. + * desktop/routes/adminRouteExtensions), so there's nothing to switch to — + * shadow the brand header back to a plain logo. (Also avoids the desktop + * bundle referencing @portal via the proprietary switcher's imports.) */ -export function AppSwitcher() { - return null; +export function AppSwitcher({ collapsed }: AppSwitcherProps) { + return ( + + ); } diff --git a/frontend/editor/src/portal/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css index 6535832399..152c45f7c5 100644 --- a/frontend/editor/src/portal/components/AppShell.css +++ b/frontend/editor/src/portal/components/AppShell.css @@ -43,9 +43,6 @@ } .portal-shell__topbar-wordmark { - height: 1.375rem; - width: auto; - display: block; margin-right: auto; } diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index c50b4000e9..77c8f90686 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -3,11 +3,9 @@ import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; import { ActionIcon } from "@app/ui"; import { Sidebar } from "@portal/components/Sidebar"; -import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; import { MenuIcon, SearchIcon } from "@portal/components/icons"; -import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg"; -import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg"; +import { Logo } from "@app/ui/Logo"; import "@portal/components/AppShell.css"; /** @@ -17,7 +15,6 @@ import "@portal/components/AppShell.css"; */ function MobileTopbar() { const { t } = useTranslation(); - const { theme } = useTheme(); const { mobileNavOpen, toggleMobileNav, openSearch } = useUI(); return (
@@ -30,10 +27,11 @@ function MobileTopbar() { > - {t("portal.shell.sidebar.brandSuffix")}
diff --git a/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx b/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx index a83d560038..a61193f17a 100644 --- a/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx +++ b/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx @@ -69,11 +69,7 @@ export function InstanceHealthTable({ instances }: Props) { key: "status", header: t("portal.editorAdmin.health.columns.status"), render: (i) => ( - + {t(INSTANCE_STATUS_LABEL[i.status])} ), diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx index a415abf107..da9831ec4c 100644 --- a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx @@ -5,6 +5,7 @@ import { Card, EmptyState, MetricCard, + MetricStrip, StatusBadge, Table, Tabs, @@ -140,7 +141,7 @@ export function AuditTab() { /> {data && ( -
+ -
+ )} {!forbidden && ( diff --git a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx index 59e4b04d54..8e96e4d997 100644 --- a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx @@ -74,11 +74,7 @@ export function DeploymentsTab() { key: "status", header: t("portal.infrastructure.deployments.regionColumns.status"), render: (r) => ( - + {t(REGION_LABEL[r.status])} ), diff --git a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx b/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx index 86b563aabd..6d097b33af 100644 --- a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx @@ -5,6 +5,7 @@ import { Chip, EmptyState, MetricCard, + MetricStrip, ProgressBar, Select, StatusBadge, @@ -65,11 +66,7 @@ export function ModelsTab() { key: "status", header: t("portal.infrastructure.models.columns.status"), render: (m) => ( - + {t(MODEL_LABEL[m.status])} ), @@ -174,7 +171,7 @@ export function ModelsTab() { /> {data && ( -
+ -
+ )}
diff --git a/frontend/editor/src/portal/components/pipelines/KpiStrip.tsx b/frontend/editor/src/portal/components/pipelines/KpiStrip.tsx index 39bac738c9..a306536718 100644 --- a/frontend/editor/src/portal/components/pipelines/KpiStrip.tsx +++ b/frontend/editor/src/portal/components/pipelines/KpiStrip.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { MetricCard, MetricStrip } from "@app/ui"; +import { PipelinesIcon } from "@portal/components/icons"; import type { PipelinesOverviewResponse } from "@portal/api/pipelines"; /** @@ -22,7 +23,7 @@ interface KpiStripProps { export function KpiStrip({ data, loading }: KpiStripProps) { const { t } = useTranslation(); return ( - + }> {KPI_LABEL_KEYS.map((labelKey, i) => { const k = loading ? undefined : data?.kpis[i]; return ( diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx index 38c46547ca..e0ac5970f6 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx @@ -49,11 +49,7 @@ export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) { key: "status", header: t("portal.pipelines.table.status"), render: (p) => ( - + {t(`portal.pipelines.status.${p.status}`)} ), diff --git a/frontend/editor/src/portal/components/policies/CatalogueSummary.tsx b/frontend/editor/src/portal/components/policies/CatalogueSummary.tsx index aab40030d5..c7e2fd4f26 100644 --- a/frontend/editor/src/portal/components/policies/CatalogueSummary.tsx +++ b/frontend/editor/src/portal/components/policies/CatalogueSummary.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { MetricCard, MetricStrip } from "@app/ui"; +import { PoliciesIcon } from "@portal/components/icons"; import type { PoliciesResponse } from "@portal/api/policies"; interface CatalogueSummaryProps { @@ -16,7 +17,7 @@ export function CatalogueSummary({ data, loading }: CatalogueSummaryProps) { const { t } = useTranslation(); const s = loading ? undefined : data?.summary; return ( - + }> + {paused ? t("portal.policies.status.paused") : t("portal.policies.status.active")} diff --git a/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx b/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx index c5169217dd..58fee82d83 100644 --- a/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx @@ -87,7 +87,6 @@ export function PolicyCategoryCard({ {status === "paused" ? t("portal.policies.status.paused") diff --git a/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx index e198fd92c8..336097d9fa 100644 --- a/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx @@ -196,10 +196,7 @@ export function PolicyDetailPanel({ > {/* Status + trigger strip */}
- + {isPaused ? t("portal.policies.status.paused") : t("portal.policies.status.active")} diff --git a/frontend/editor/src/portal/components/sources/KpiStrip.tsx b/frontend/editor/src/portal/components/sources/KpiStrip.tsx index 16e8f547f3..970a0a7982 100644 --- a/frontend/editor/src/portal/components/sources/KpiStrip.tsx +++ b/frontend/editor/src/portal/components/sources/KpiStrip.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { MetricCard, MetricStrip } from "@app/ui"; +import { SourcesIcon } from "@portal/components/icons"; import type { SourcesResponse } from "@portal/api/sources"; /** @@ -22,7 +23,7 @@ interface KpiStripProps { export function KpiStrip({ data, loading }: KpiStripProps) { const { t } = useTranslation(); return ( - + }> {KPI_LABEL_KEYS.map((labelKey, i) => { const k = loading ? undefined : data?.kpis[i]; return ( diff --git a/frontend/editor/src/portal/components/sources/SourcesTable.tsx b/frontend/editor/src/portal/components/sources/SourcesTable.tsx index 23017299a1..9d99a8c8ea 100644 --- a/frontend/editor/src/portal/components/sources/SourcesTable.tsx +++ b/frontend/editor/src/portal/components/sources/SourcesTable.tsx @@ -61,11 +61,7 @@ export function SourcesTable({ sources, onRowClick }: SourcesTableProps) { key: "status", header: t("portal.sources.table.status"), render: (s) => ( - + {t(`portal.sources.status.${s.status}`)} ), diff --git a/frontend/editor/src/portal/contexts/UIContext.tsx b/frontend/editor/src/portal/contexts/UIContext.tsx index f55c2f5ee9..fbf572d3cc 100644 --- a/frontend/editor/src/portal/contexts/UIContext.tsx +++ b/frontend/editor/src/portal/contexts/UIContext.tsx @@ -17,6 +17,8 @@ interface UIContextValue { openMobileNav: () => void; closeMobileNav: () => void; toggleMobileNav: () => void; + sidebarCollapsed: boolean; + toggleSidebarCollapsed: () => void; assistantOpen: boolean; openAssistant: () => void; @@ -52,9 +54,29 @@ interface UIContextValue { const UIContext = createContext(null); +const SIDEBAR_COLLAPSED_KEY = "stirling.portalSidebarCollapsed"; + +function readSidebarCollapsed(): boolean { + try { + return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true"; + } catch { + return false; + } +} + +function writeSidebarCollapsed(collapsed: boolean): void { + try { + window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(collapsed)); + } catch { + // private mode / quota: silently no-op + } +} + export function UIProvider({ children }: { children: ReactNode }) { const [searchOpen, setSearchOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = + useState(readSidebarCollapsed); const [assistantOpen, setAssistantOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState< @@ -85,6 +107,14 @@ export function UIProvider({ children }: { children: ReactNode }) { closeMobileNav: () => setMobileNavOpen(false), toggleMobileNav: () => setMobileNavOpen((o) => !o), + sidebarCollapsed, + toggleSidebarCollapsed: () => + setSidebarCollapsed((c) => { + const next = !c; + writeSidebarCollapsed(next); + return next; + }), + assistantOpen, openAssistant: () => setAssistantOpen(true), closeAssistant: () => setAssistantOpen(false), @@ -129,6 +159,7 @@ export function UIProvider({ children }: { children: ReactNode }) { [ searchOpen, mobileNavOpen, + sidebarCollapsed, assistantOpen, settingsOpen, settingsInitialSection, diff --git a/frontend/editor/src/portal/views/Infrastructure.css b/frontend/editor/src/portal/views/Infrastructure.css index 2bff218e96..cd532daeeb 100644 --- a/frontend/editor/src/portal/views/Infrastructure.css +++ b/frontend/editor/src/portal/views/Infrastructure.css @@ -185,25 +185,6 @@ text-align: right; } -/* Metric strip (audit) */ -.portal-infra__metrics { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 0.75rem; -} - -@media (max-width: 50rem) { - .portal-infra__metrics { - grid-template-columns: repeat(2, 1fr); - } -} - -@media (max-width: 30rem) { - .portal-infra__metrics { - grid-template-columns: 1fr; - } -} - /* ── API keys ───────────────────────────────────────────────────────────── */ .portal-infra__keys { display: flex; diff --git a/frontend/editor/src/proprietary/auth/ui/AuthShell.module.css b/frontend/editor/src/proprietary/auth/ui/AuthShell.module.css index bb0e418d88..9690a8f08f 100644 --- a/frontend/editor/src/proprietary/auth/ui/AuthShell.module.css +++ b/frontend/editor/src/proprietary/auth/ui/AuthShell.module.css @@ -5,7 +5,7 @@ flex-direction: column; align-items: center; justify-content: center; - background-color: var(--auth-bg-color); + background-color: var(--c-bg); padding: 1.5rem 1.5rem 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; diff --git a/frontend/editor/src/proprietary/auth/ui/AuthSignupPrompt.tsx b/frontend/editor/src/proprietary/auth/ui/AuthSignupPrompt.tsx index baaad54d7b..3a9cbf45f4 100644 --- a/frontend/editor/src/proprietary/auth/ui/AuthSignupPrompt.tsx +++ b/frontend/editor/src/proprietary/auth/ui/AuthSignupPrompt.tsx @@ -9,7 +9,7 @@ interface AuthSignupPromptProps { /** * "Don't have an account? Sign up" row shown beneath the login form. The prompt - * is muted; the action reads as a brand-coloured link. + * is muted; the action reads as a blue link so it pops. */ export default function AuthSignupPrompt({ onSignUp }: AuthSignupPromptProps) { const { t } = useTranslation(); @@ -19,7 +19,7 @@ export default function AuthSignupPrompt({ onSignUp }: AuthSignupPromptProps) { diff --git a/frontend/editor/src/proprietary/auth/ui/auth-theme.css b/frontend/editor/src/proprietary/auth/ui/auth-theme.css index 8580d59e0b..520df4e510 100644 --- a/frontend/editor/src/proprietary/auth/ui/auth-theme.css +++ b/frontend/editor/src/proprietary/auth/ui/auth-theme.css @@ -2,7 +2,6 @@ :root { /* Auth page colors (light mode) */ - --auth-bg-color: var(--p-gray-100); --auth-card-bg: #ffffff; --auth-label-text: var(--p-gray-700); --auth-input-border: var(--p-gray-300); @@ -33,7 +32,6 @@ } [data-mantine-color-scheme="dark"] { - --auth-bg-color: var(--c-surface-sunken); --auth-card-bg: var(--c-surface); --auth-label-text: var(--c-text-muted); --auth-input-border: var(--c-border); diff --git a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx b/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx deleted file mode 100644 index 3192b69303..0000000000 --- a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline"; - -const meta = { - title: "Agents/StirlingLogoOutline", - component: StirlingLogoOutline, - parameters: { layout: "centered" }, - args: { - size: 20, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Large: Story = { - args: { - size: 64, - }, -}; diff --git a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx b/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx deleted file mode 100644 index c52b40df24..0000000000 --- a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/** - * A temp stirling logo, may change in future. - */ -export function StirlingLogoOutline({ size = 20 }: { size?: number }) { - return ( - - ); -} diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.css b/frontend/editor/src/proprietary/components/chat/ChatPanel.css index e95078bcdf..f301b822af 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatPanel.css +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.css @@ -145,15 +145,11 @@ /* Same treatment for the input pill and quick-action cards. */ [data-mantine-color-scheme="dark"] .chat-panel-input { background: transparent; - box-shadow: - 0 0 0 1px var(--c-border-subtle), - 0 6px 16px rgba(0, 0, 0, 0.25); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25); } [data-mantine-color-scheme="dark"] .chat-panel-input:focus-within { - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-3) 40%, transparent), - 0 8px 22px color-mix(in srgb, var(--mantine-color-blue-6) 18%, transparent); + box-shadow: 0 8px 22px color-mix(in srgb, var(--c-btn-solid) 18%, transparent); } [data-mantine-color-scheme="dark"] .chat-quick-action { @@ -331,16 +327,12 @@ border-radius: 1.1rem; background: var(--mantine-color-body); flex-shrink: 0; - box-shadow: - 0 0 0 1px rgba(0, 0, 0, 0.04), - 0 4px 14px rgba(0, 0, 0, 0.08); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); transition: box-shadow 160ms ease-out; } .chat-panel-input:focus-within { - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-6) 20%, transparent), - 0 6px 18px color-mix(in srgb, var(--mantine-color-blue-6) 12%, transparent); + box-shadow: 0 6px 18px color-mix(in srgb, var(--c-btn-solid) 12%, transparent); } /* Kill the Mantine Textarea's own border/outline — the wrapper owns the chrome. */ @@ -479,12 +471,12 @@ } .chat-bubble-user { - background: var(--mantine-color-blue-filled) !important; - color: white !important; + background: var(--c-btn-solid) !important; + color: var(--c-btn-inverse) !important; } .chat-bubble-user * { - color: white !important; + color: var(--c-btn-inverse) !important; } /* Assistant messages: no bubble, free-flowing with side padding */ @@ -517,12 +509,23 @@ padding: 0.2rem 0.5rem 0.35rem; } +[data-mantine-color-scheme="dark"] .chat-panel__header .sui-panelhdr__icon, +.chat-panel__header .sui-panelhdr__icon { + background: transparent; + border: none; +} + +.chat-panel__header .chat-panel__header-mark { + width: auto; + height: 26px; +} + .chat-progress-live__logo { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; - color: var(--mantine-color-blue-filled); + color: var(--c-brand-mark); } /* Shimmer: a soft highlight sweeps left-to-right across the muted label. */ diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx index d0ff7e19e3..2ff5ccfbe9 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx @@ -41,7 +41,8 @@ import { import { formatRelativeTime } from "@app/utils/timeUtils"; import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry"; import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated"; -import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline"; +import { BrandMark } from "@app/components/shared/BrandMark"; +import { Logo } from "@app/ui/Logo"; import { PanelHeader } from "@app/ui/PanelHeader"; import { ChatQuickActions } from "@app/components/chat/ChatQuickActions"; import "@app/components/chat/ChatPanel.css"; @@ -474,8 +475,14 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) { return ( } - title={t("agents.stirling_name", "Stirling")} + icon={} + title={ + + } loading={isLoading} className="chat-panel__header" barClassName="chat-panel__agent-pill-vt" diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx index 8e500a0c1a..2e02db3068 100644 --- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx +++ b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx @@ -1,28 +1,29 @@ import { useNavigate } from "react-router-dom"; -import { useMantineColorScheme } from "@mantine/core"; import { useAuth } from "@app/auth/context"; -import { AppSwitch } from "@app/components/shared/AppSwitch"; +import { Logo } from "@app/ui/Logo"; +import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; +import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; -/** - * Sidebar app switcher between the editor and the admin portal. Both are - * route-sets of one SPA (the portal mounts at PORTAL_BASENAME), so switching - * is a client-side navigation. Hidden for users without portal access — they - * have nowhere to switch to. Renders the same AppSwitch element as the - * portal's sidebar. - */ -export function AppSwitcher() { +export function AppSwitcher({ collapsed }: AppSwitcherProps) { const { portalAccess } = useAuth(); const navigate = useNavigate(); - const { colorScheme } = useMantineColorScheme(); - if (!portalAccess) return null; + if (!portalAccess) { + return ( + + ); + } return ( - navigate(PORTAL_BASENAME)} + collapsed={collapsed} /> ); } diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index 3e6764bf60..1686f8c795 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -16,10 +16,6 @@ import AuthLayout from "@app/routes/authShared/AuthLayout"; import { useBackendProbe } from "@app/hooks/useBackendProbe"; import { BASE_PATH, withBasePath } from "@app/constants/app"; import { updateSupportedLanguages } from "@app/i18n"; -import { - DEBUG_SHOW_ALL_PROVIDERS, - oauthProviderConfig, -} from "@app/auth/ui/OAuthButtons"; import SpringLoginForm from "@app/auth/ui/SpringLoginForm"; import AuthSignupPrompt from "@app/auth/ui/AuthSignupPrompt"; import AuthDefaultCredentials from "@app/auth/ui/AuthDefaultCredentials"; @@ -48,10 +44,9 @@ export default function Login() { const { refetch } = useAppConfig(); const { t } = useTranslation(); const [successMessage, setSuccessMessage] = useState(null); - const [showEmailForm, setShowEmailForm] = useState(false); + const [showEmailForm, setShowEmailForm] = useState(true); const [_enableLogin, setEnableLogin] = useState(null); const [ssoAutoLogin, setSsoAutoLogin] = useState(false); - const [hasSSOProviders, setHasSSOProviders] = useState(false); const backendProbe = useBackendProbe(); const [isFirstTimeSetup, setIsFirstTimeSetup] = useState(false); const [showDefaultCredentials, setShowDefaultCredentials] = useState(false); @@ -235,26 +230,13 @@ export default function Login() { } }, [backendProbe.status, refetch]); - // Update hasSSOProviders and showEmailForm when providers or loginMethod change + // The email/password form is always shown when username/password auth is + // allowed; SSO-only mode hides it. useEffect(() => { - // In debug mode, check if any providers exist in the config - const hasProviders = DEBUG_SHOW_ALL_PROVIDERS - ? Object.keys(oauthProviderConfig).length > 0 - : login.providers.length > 0; - setHasSSOProviders(hasProviders); - - // Check if username/password authentication is allowed const userPassAllowed = login.loginMethod === "all" || login.loginMethod === "normal"; - - // Show email form if no SSO providers exist AND username/password is allowed - if (!hasProviders && userPassAllowed) { - setShowEmailForm(true); - } else if (!userPassAllowed) { - // Hide email form if username/password auth is not allowed - setShowEmailForm(false); - } - }, [login.providers, login.loginMethod]); + setShowEmailForm(userPassAllowed); + }, [login.loginMethod]); // Auto-login to SSO when enabled and only one SSO option exists useEffect(() => { @@ -502,22 +484,6 @@ export default function Login() {
) : undefined } - beforeEmailForm={ - hasSSOProviders && !showEmailForm && isUserPassAllowed ? ( -
- -
- ) : undefined - } footer={ <> {isFirstTimeSetup && diff --git a/frontend/editor/src/proprietary/routes/Signup.tsx b/frontend/editor/src/proprietary/routes/Signup.tsx index 68be029969..69e2ef9929 100644 --- a/frontend/editor/src/proprietary/routes/Signup.tsx +++ b/frontend/editor/src/proprietary/routes/Signup.tsx @@ -135,6 +135,7 @@ export default function Signup() { variant="tertiary" onClick={() => navigate("/login")} className="auth-link-black" + style={{ color: "var(--c-primary)" }} > {t("login.logIn", "Log In")} diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 1da92bd190..32af9a6782 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -25,7 +25,7 @@ import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; // Import global styles import "@app/styles/tailwind.css"; -import "@app/styles/saas-theme.css"; +import "@app/auth/ui/auth-theme.css"; import "@app/styles/cookieconsent.css"; import "@app/styles/index.css"; diff --git a/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css index 344743473a..888401b47d 100644 --- a/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css +++ b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css @@ -1,9 +1,12 @@ +/* Sits as a direct child of the sidebar, so the rail's own padding + gap + handle the spacing; a margin here would inset it narrower than the + sibling nav-surface boxes. Matches those boxes' surface treatment — the + brand mark in the header is what draws the eye, so no louder border. */ .card { - margin: 0.5rem; + margin: 0; padding: 0.625rem 0.75rem 0.6875rem; - background: var(--mantine-color-body); - border: 1px solid - color-mix(in srgb, var(--mantine-color-default-border) 45%, transparent); + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); border-radius: 0.625rem; box-shadow: none; } diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index 3f7b4de5ab..0d534fa088 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -33,6 +33,8 @@ interface AppConfigModalProps { initialSection?: NavKey | null; /** Host-specific sections appended after the saas registry sections. */ extraSections?: ConfigNavSection[]; + /** Registry section keys to drop, for hosts a section can't run in. */ + hiddenSectionKeys?: NavKey[]; } const AppConfigModal: React.FC = ({ @@ -40,6 +42,7 @@ const AppConfigModal: React.FC = ({ onClose, initialSection, extraSections, + hiddenSectionKeys, }) => { const isMobile = useMediaQuery("(max-width: 1024px)"); @@ -153,8 +156,23 @@ const AppConfigModal: React.FC = ({ isAnonymous, t, }); - return extraSections?.length ? [...sections, ...extraSections] : sections; - }, [openLogoutConfirm, isDev, isAnonymous, t, extraSections]); + const base = hiddenSectionKeys?.length + ? sections + .map((sec) => ({ + ...sec, + items: sec.items.filter((i) => !hiddenSectionKeys.includes(i.key)), + })) + .filter((sec) => sec.items.length > 0) + : sections; + return extraSections?.length ? [...base, ...extraSections] : base; + }, [ + openLogoutConfirm, + isDev, + isAnonymous, + t, + extraSections, + hiddenSectionKeys, + ]); const activeLabel = useMemo(() => { for (const section of configNavSections) { diff --git a/frontend/editor/src/saas/components/shared/AppSwitcher.tsx b/frontend/editor/src/saas/components/shared/AppSwitcher.tsx new file mode 100644 index 0000000000..364f094478 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/AppSwitcher.tsx @@ -0,0 +1,41 @@ +import { useNavigate } from "react-router-dom"; +import { Logo } from "@app/ui/Logo"; +import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; +import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; +import { usePortalAccess } from "@app/hooks/usePortalAccess"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; + +/** + * SaaS sidebar brand header. When the backend says this user can open the + * processor (`/api/v1/auth/me` → `portalAccess` — the exact signal the + * processor's own gate uses), the Stirling logo doubles as the + * editor⇄processor switcher: the mark morphs into a chevron and opens the + * switch menu (same BrandSwitcher the processor sidebar uses). Users without + * access get a plain logo. + * + * Deliberately NOT gated on the editor's Supabase auth context: that context + * never fetches /me, so it can't know about portal access (and its session + * state doesn't always mirror the backend login that actually grants it). + */ +export function AppSwitcher({ collapsed }: AppSwitcherProps) { + const portalAccess = usePortalAccess(); + const navigate = useNavigate(); + + if (!portalAccess) { + return ( + + ); + } + + return ( + navigate(PORTAL_BASENAME)} + collapsed={collapsed} + /> + ); +} diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx new file mode 100644 index 0000000000..a0e8ba618d --- /dev/null +++ b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; + +const get = vi.fn(); +let currentUserId: string | null = null; + +vi.mock("@app/services/apiClient", () => ({ + default: { + get: (...args: unknown[]) => get(...args), + }, +})); + +vi.mock("@app/auth/UseSession", () => ({ + useAuth: () => ({ user: currentUserId ? { id: currentUserId } : null }), +})); + +const { usePortalAccess } = await import("@app/hooks/usePortalAccess"); + +function meReturning(portalAccess: boolean) { + return { data: { user: { portalAccess } } }; +} + +describe("usePortalAccess", () => { + beforeEach(() => { + get.mockReset(); + currentUserId = null; + }); + + it("reports the backend's answer for the signed-in user", async () => { + currentUserId = "admin-1"; + get.mockResolvedValue(meReturning(true)); + + const { result } = renderHook(() => usePortalAccess()); + + await waitFor(() => expect(result.current).toBe(true)); + }); + + it("re-asks the backend when a different user signs in without a reload", async () => { + // An admin gets a yes... + currentUserId = "admin-1"; + get.mockResolvedValue(meReturning(true)); + const { result, rerender } = renderHook(() => usePortalAccess()); + await waitFor(() => expect(result.current).toBe(true)); + + // ...then Supabase swaps the identity in place (signed out in another + // tab, revoked session, new sign-in) — no page load in between. + currentUserId = "member-2"; + get.mockResolvedValue(meReturning(false)); + rerender(); + + // The member must not inherit the admin's answer. + await waitFor(() => expect(result.current).toBe(false)); + expect(get).toHaveBeenCalledTimes(2); + }); + + it("drops the answer when the user signs out", async () => { + currentUserId = "admin-1"; + get.mockResolvedValue(meReturning(true)); + const { result, rerender } = renderHook(() => usePortalAccess()); + await waitFor(() => expect(result.current).toBe(true)); + + currentUserId = null; + rerender(); + + await waitFor(() => expect(result.current).toBe(false)); + }); + + it("reports no access, and asks nothing, for a guest", () => { + currentUserId = null; + + const { result } = renderHook(() => usePortalAccess()); + + expect(result.current).toBe(false); + expect(get).not.toHaveBeenCalled(); + }); + + it("treats a failed lookup as no access, and a later mount asks again", async () => { + currentUserId = "admin-1"; + get.mockRejectedValueOnce(new Error("401")); + const first = renderHook(() => usePortalAccess()); + await waitFor(() => expect(get).toHaveBeenCalledTimes(1)); + expect(first.result.current).toBe(false); + first.unmount(); + + // The failure isn't sticky. + get.mockResolvedValue(meReturning(true)); + const second = renderHook(() => usePortalAccess()); + await waitFor(() => expect(second.result.current).toBe(true)); + }); + + it("ignores a response that lands after unmount", async () => { + currentUserId = "admin-1"; + let resolveMe: (v: unknown) => void = () => {}; + get.mockReturnValue( + new Promise((resolve) => { + resolveMe = resolve; + }), + ); + + const { result, unmount } = renderHook(() => usePortalAccess()); + unmount(); + resolveMe(meReturning(true)); + + // No state update on an unmounted hook (React would warn); the stale + // answer is simply dropped. + expect(result.current).toBe(false); + }); +}); diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.ts b/frontend/editor/src/saas/hooks/usePortalAccess.ts new file mode 100644 index 0000000000..442061cbe1 --- /dev/null +++ b/frontend/editor/src/saas/hooks/usePortalAccess.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import apiClient from "@app/services/apiClient"; +import { useAuth } from "@app/auth/UseSession"; + +/** + * Whether the current user can open the processor (admin portal), straight + * from the backend (`/api/v1/auth/me` → `portalAccess`) — the same signal the + * processor's own SaasPortalGate uses. Components that must mirror processor + * access (e.g. the sidebar's editor⇄processor switcher) ask here. + * + * The editor's Supabase auth context can't *answer* this — it never fetches + * /me — so it is used only to identify who is asking. Keying the effect on + * that identity is what keeps the answer per-user: the SPA can swap users + * without a reload (Supabase fires SIGNED_OUT/SIGNED_IN in place; only the + * settings Logout button hard-navigates), so any answer held beyond the + * current identity would leak to whoever signs in next. + * + * Deliberately unmemoised beyond the mount: the one consumer (the sidebar + * switcher) mounts once, so a cross-mount cache would only add user-scoped + * state that has to be invalidated on identity change — the bug class this + * hook already had once. Guests skip the request entirely. + */ +export function usePortalAccess(): boolean { + const { user } = useAuth(); + const userId = user?.id ?? null; + const [access, setAccess] = useState(false); + + useEffect(() => { + // Signed out: nothing to ask, and any previous answer is void. + if (userId === null) { + setAccess(false); + return; + } + + let cancelled = false; + apiClient + .get<{ user?: { portalAccess?: boolean } }>("/api/v1/auth/me") + .then((res) => { + if (!cancelled) setAccess(res.data.user?.portalAccess === true); + }) + .catch(() => { + // Backend unreachable or guest (401): no access now; a remount or + // identity change asks again rather than trusting a failure. + if (!cancelled) setAccess(false); + }); + return () => { + cancelled = true; + }; + }, [userId]); + + return access; +} diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index ab75b7bb79..03f654f4d0 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -30,7 +30,6 @@ export default function Login() { const [isSigningIn, setIsSigningIn] = useState(false); const [error, setError] = useState(null); const [showMagicLinkForm, setShowMagicLinkForm] = useState(false); - const [showEmailForm, setShowEmailForm] = useState(false); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [magicLinkEmail, setMagicLinkEmail] = useState(""); @@ -43,7 +42,6 @@ export default function Login() { const emailFromQuery = url.searchParams.get("email"); if (emailFromQuery) { setEmail(emailFromQuery); - setShowEmailForm(true); } } catch (_) { // ignore @@ -256,15 +254,8 @@ export default function Login() { } }; - const toggleEmailForm = () => { - setShowEmailForm((v) => !v); - setShowMagicLinkForm(false); - setMagicLinkSent(false); - }; - const toggleMagicLink = () => { setShowMagicLinkForm((v) => !v); - setShowEmailForm(false); setMagicLinkSent(false); }; @@ -374,71 +365,30 @@ export default function Login() { - {/* Email & Password button */} - - - {/* Email form — animated expand */} -
-
-
- - -
-
-
- - {/* Skip */} -
+ {/* Email + password form — always visible (no expander toggle) */} +
+
- {/* Bottom */} + {/* Create an account — pushed to the bottom */}
{t("login.createAccount", "Create an account")}
+ + {/* Skip — small + muted, at the very bottom */} +
+ +
); } diff --git a/frontend/editor/src/saas/routes/Signup.tsx b/frontend/editor/src/saas/routes/Signup.tsx index 942cfefb5a..e9529944d6 100644 --- a/frontend/editor/src/saas/routes/Signup.tsx +++ b/frontend/editor/src/saas/routes/Signup.tsx @@ -29,7 +29,6 @@ export default function Signup() { const { t } = useTranslation(); const [isSigningUp, setIsSigningUp] = useState(false); const [error, setError] = useState(null); - const [showEmailForm, setShowEmailForm] = useState(false); const [name, setName] = useState(undefined as string | undefined); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -209,67 +208,26 @@ export default function Signup() { />
- {/* Email & Password button */} - - - {/* Email form — animated expand */} -
-
-
- -
-
+ {/* Sign-up form — always visible (no expander toggle) */} +
+
- {/* Skip */} -
- -
- - {/* Bottom */} + {/* Already have an account — pushed to the bottom */}
{t("signup.alreadyHaveAccount", "I already have an account")}
+ + {/* Skip — small + muted, at the very bottom */} +
+ +
); } diff --git a/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx b/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx index a7435eb43b..27963cff83 100644 --- a/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx +++ b/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx @@ -82,7 +82,7 @@ export default function EmailPasswordForm({ + {view?.host && ( + {view.host} + )} + {adoption && ( + <> + + + {t("portal.home.editor.activeOfDeployed", adoption)} + + )}
-
- {view.host} - {view.meta.map((item, i) => ( - - · - {item} - - ))} -
+ {view && view.meta.length > 0 && ( +
+ {view.meta.map((item, i) => ( + + {i > 0 && ( + · + )} + {item} + + ))} +
+ )} )} + {/* Open in browser is a left-seated secondary in every state, and carries no arrow + (marketing note D244); the deploy ask beside it is the only button that can go loud. + Reaching the editor never depends on deployment data — it falls back to the configured + editor URL — so this stays live even when the deployment endpoint is unavailable. */}
- {!hideChips && ( - - )} - +
diff --git a/frontend/editor/src/portal/components/HomeHero.stories.tsx b/frontend/editor/src/portal/components/HomeHero.stories.tsx index dd1ea07c04..8784bde97a 100644 --- a/frontend/editor/src/portal/components/HomeHero.stories.tsx +++ b/frontend/editor/src/portal/components/HomeHero.stories.tsx @@ -16,17 +16,10 @@ const meta = { export default meta; type Story = StoryObj; -/** Pay-as-you-go tier: welcome header + setup checklist until onboarding completes. */ -export const Default: Story = { - args: { tier: "pro" }, -}; - -/** Free tier renders the same welcome-header composition as pro. */ -export const FreeTier: Story = { - args: { tier: "free" }, -}; - -/** Enterprise tier hides the status chips — the procurement deal hero owns the invite step. */ -export const EnterpriseTier: Story = { - args: { tier: "enterprise" }, -}; +/** + * The hero is the Editor deployment rail on every tier and in both editions — it reports its own + * deployment state and deploy ask, so there is nothing tier-specific left to compose. A live + * procurement deal attaches the deal-status hero as the rail's footer; that comes from + * useProcurement, so it follows the mocked backend rather than a story arg. + */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/HomeHero.tsx b/frontend/editor/src/portal/components/HomeHero.tsx index 03bcc17ed0..e81d42dfef 100644 --- a/frontend/editor/src/portal/components/HomeHero.tsx +++ b/frontend/editor/src/portal/components/HomeHero.tsx @@ -1,60 +1,50 @@ -import type { Tier } from "@portal/contexts/TierContext"; +import { useEffect } from "react"; +import { Skeleton } from "@app/ui"; import { useUI } from "@portal/contexts/UIContext"; -import { WelcomeBanner } from "@portal/components/WelcomeBanner"; import { EditorStatusCard } from "@portal/components/EditorStatusCard"; -import { SetupChecklist } from "@portal/components/SetupChecklist"; -import { useOnboardingProgress } from "@portal/hooks/useOnboardingProgress"; import { ControlledDealStatusHero } from "@portal/components/procurement/ProcurementBanner"; import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; import { useProcurement } from "@portal/components/procurement/useProcurement"; /** - * The Home hero, composed with a procurement-aware, progress-aware footer: - * - * - no live deployment → welcome header (+ setup steps until complete) - * - deployment live → deployed-Editor status header (+ steps until complete) - * - onboarding complete → header only; the setup steps collapse away - * - enterprise → status header with chips hidden (the deal hero owns invite) - * - * The footer is the deal-status hero while a procurement deal is underway - * (procurement is a bolt-on to any tier); otherwise the setup checklist, until - * every step is done — then it collapses to just the header, matching the - * deployed-status card. The procurement takeover modals render alongside. + * The Home hero: always the Editor deployment rail, carrying the deal-status hero as its footer + * while a procurement deal is underway (procurement is a bolt-on to any tier). The rail states its + * own deployment status and deploy ask, so there is nothing for a tier to choose between. The + * procurement takeover modals render alongside. */ -export function HomeHero({ tier }: { tier: Tier }) { - const { openLinkModal } = useUI(); +export function HomeHero() { const procurement = useProcurement(); - const progress = useOnboardingProgress(); + const { trialSetupRequested, clearTrialSetupRequest } = useUI(); const dealActive = procurement.isLinked && procurement.started && !!procurement.data; - // Start the enterprise flow right here on Home: open the trial-setup modal when the account is - // linked, otherwise prompt to link first — no navigating off to the procurement view. - const onStartEnterprise = () => { - if (procurement.isLinked) procurement.onStartTrial(); - else openLinkModal(); - }; - - // Steps collapse once onboarding is complete; a live deal always keeps its - // hero. Otherwise the setup checklist carries the (progress-aware) steps. - const footer = dealActive ? ( - - ) : progress.allComplete ? undefined : ( - - ); - - // The live-status header (EditorStatusCard) needs a real deployment to show; - // without one it renders nothing, so route to it only when actually deployed. - // Everything else — including a step completed via the local download flag — - // keeps the always-present welcome header, so the card never vanishes. - const showStatus = progress.deployed; + // Someone said yes to enterprise elsewhere (the billing upsell, a sales link). Open trial setup + // once the snapshot has landed, so a buyer who already has a deal is not asked to start another. + useEffect(() => { + if (!trialSetupRequested || procurement.loading) return; + clearTrialSetupRequest(); + if (!procurement.started) procurement.onExploreEnterprise(); + }, [trialSetupRequested, procurement, clearTrialSetupRequest]); return ( <> - {showStatus ? ( - + {procurement.loading ? ( + // Hold the rail's shape rather than committing to a footer: branching before the snapshot + // lands paints the no-deal rail first, flashing on every refresh of an active deal. +
+
+ + +
+
) : ( - + + ) : undefined + } + /> )} diff --git a/frontend/editor/src/portal/components/SetupChecklist.css b/frontend/editor/src/portal/components/SetupChecklist.css deleted file mode 100644 index 0fcb71e678..0000000000 --- a/frontend/editor/src/portal/components/SetupChecklist.css +++ /dev/null @@ -1,122 +0,0 @@ -/* ──────────────────────────────────────────────────────────────────────── */ -/* Getting-started steps (home-hero body) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -.portal-setup { - display: flex; - flex-direction: column; -} - -.portal-setup__list { - list-style: none; - margin: 0; - padding: 0; -} - -.portal-setup__row { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - gap: 0.875rem; - width: 100%; - padding: 0.6875rem 1.25rem; - border: none; - border-top: 1px solid var(--c-border-subtle); - background: transparent; - text-align: left; - cursor: pointer; - transition: background var(--motion-fast); -} -.portal-setup__item:first-child .portal-setup__row { - border-top: none; -} -.portal-setup__row:hover { - background: var(--c-hover); -} - -/* Numbered step marker */ -.portal-setup__num { - display: grid; - place-items: center; - width: 1.5rem; - height: 1.5rem; - flex-shrink: 0; - border-radius: 50%; - border: 1px solid var(--c-border); - font-size: 0.75rem; - font-weight: 600; - color: var(--c-text-subtle); -} -/* Completed step: filled green check. */ -.portal-setup__num.is-done { - border-color: var(--color-green); - background: var(--color-green); - color: #fff; -} -.portal-setup__row.is-done .portal-setup__text strong { - color: var(--c-text-muted); -} - -.portal-setup__text { - display: flex; - flex-direction: column; - min-width: 0; -} -.portal-setup__text strong { - font-size: 0.875rem; - font-weight: 600; - color: var(--c-text); -} -.portal-setup__text span { - font-size: 0.75rem; - line-height: 1.4; - color: var(--c-text-subtle); -} - -/* ── Enterprise upsell rung ── */ -.portal-setup__enterprise { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; - padding: 0.75rem 1.25rem; - border-top: 1px solid var(--c-border-subtle); - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--c-primary) 5%, transparent) 0%, - transparent 55% - ); -} - -.portal-setup__enterprise-copy { - display: flex; - align-items: center; - gap: 0.75rem; - min-width: 0; - flex: 1; -} - -.portal-setup__enterprise-tag { - flex-shrink: 0; - padding: 0.1875rem 0.5625rem; - border-radius: var(--radius-md); - font-size: 0.59375rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--c-primary-hover); - background: var(--c-primary-tint); -} - -.portal-setup__enterprise-text { - margin: 0; - font-size: 0.8125rem; - line-height: 1.45; - color: var(--c-text-subtle); - min-width: 0; -} -.portal-setup__enterprise-text strong { - color: var(--c-text); - font-weight: 700; -} diff --git a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx deleted file mode 100644 index 432be155d7..0000000000 --- a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SetupChecklist } from "@portal/components/SetupChecklist"; -import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; - -const base: OnboardingProgress = { - loading: false, - deployed: false, - editorDone: false, - policiesDone: false, - inviteDone: false, - policiesActive: 0, - policiesRecommended: 6, - allComplete: false, -}; - -const meta: Meta = { - title: "Portal/Home/SetupChecklist", - component: SetupChecklist, - parameters: { layout: "padded" }, - args: { progress: base }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -/** A fresh workspace — no step complete yet. */ -export const NotStarted: Story = {}; - -/** Policies confirmed; editor + invite still open. */ -export const InProgress: Story = { - args: { - progress: { - ...base, - policiesDone: true, - policiesActive: 2, - policiesRecommended: 5, - }, - }, -}; - -/** Editor deployed + policies on; only the invite step remains. */ -export const AlmostDone: Story = { - args: { - progress: { - ...base, - editorDone: true, - policiesDone: true, - policiesActive: 4, - policiesRecommended: 3, - }, - }, -}; diff --git a/frontend/editor/src/portal/components/SetupChecklist.tsx b/frontend/editor/src/portal/components/SetupChecklist.tsx deleted file mode 100644 index d854beb771..0000000000 --- a/frontend/editor/src/portal/components/SetupChecklist.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; -import { useTier } from "@portal/contexts/TierContext"; -import { useView } from "@portal/contexts/ViewContext"; -import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; -import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; -import CheckRounded from "@mui/icons-material/CheckRounded"; -import "@portal/components/SetupChecklist.css"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Enterprise upsell rung */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** - * Enterprise on-ramp rung. The CTA differs by tier: free orgs start a guided - * trial, subscribed (paying) orgs jump straight to a quote — both open the - * procurement flow. When {@code onStart} is given the CTA opens the flow's setup - * modal over Home; otherwise it falls back to navigating to the procurement view. - */ -function EnterpriseRung({ - paying, - onStart, -}: { - paying: boolean; - onStart?: () => void; -}) { - const { t } = useTranslation(); - const { setActiveView } = useView(); - return ( -
-
- - {t("portal.home.onboarding.enterprise.tag")} - -

- {t("portal.home.onboarding.enterprise.lead")}{" "} - {t("portal.home.onboarding.enterprise.body")} -

-
- -
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Getting-started steps */ -/* ──────────────────────────────────────────────────────────────────────── */ - -interface Step { - id: string; - title: string; - blurb: string; - done: boolean; - onClick: () => void; -} - -/** - * Numbered getting-started steps, rendered as the body of the home hero. Each - * row opens its in-app surface; a completed step (from {@link OnboardingProgress}) - * swaps its number for a check. When every step is done the parent collapses the - * hero to the deployed-status header and stops rendering this list entirely. - */ -export function SetupChecklist({ - progress, - onStartEnterprise, -}: { - progress: OnboardingProgress; - /** Start the enterprise flow in place (opens the setup modal over Home). Falls back to - * navigating to the procurement view when omitted (e.g. in isolated stories). */ - onStartEnterprise?: () => void; -}) { - const { t } = useTranslation(); - const { tier } = useTier(); - const { setActiveView } = useView(); - const [downloadOpen, setDownloadOpen] = useState(false); - - const steps: Step[] = [ - { - id: "editor", - title: t("portal.home.onboarding.steps.editor.title"), - blurb: t("portal.home.onboarding.steps.editor.blurb"), - done: progress.editorDone, - // Downloads are per-OS, so open the install picker rather than route away. - onClick: () => setDownloadOpen(true), - }, - { - id: "policies", - title: t("portal.home.onboarding.steps.policies.title"), - blurb: t("portal.home.onboarding.steps.policies.blurb", { - active: progress.policiesActive, - recommended: progress.policiesRecommended, - }), - done: progress.policiesDone, - onClick: () => setActiveView("policies"), - }, - { - id: "invite", - title: t("portal.home.onboarding.steps.invite.title"), - blurb: t("portal.home.onboarding.steps.invite.blurb"), - done: progress.inviteDone, - onClick: () => setActiveView("users"), - }, - ]; - - return ( -
-
    - {steps.map((s, i) => ( -
  1. - -
  2. - ))} -
- - - - setDownloadOpen(false)} - /> -
- ); -} diff --git a/frontend/editor/src/portal/components/WelcomeBanner.css b/frontend/editor/src/portal/components/WelcomeBanner.css deleted file mode 100644 index 157ee24ac0..0000000000 --- a/frontend/editor/src/portal/components/WelcomeBanner.css +++ /dev/null @@ -1,103 +0,0 @@ -/* ──────────────────────────────────────────────────────────────────────── */ -/* Free-tier welcome hero — compact product header + steps */ -/* ──────────────────────────────────────────────────────────────────────── */ - -.portal-welcome { - border-radius: var(--radius-xl); - border: 1px solid var(--c-border-subtle); - overflow: hidden; - isolation: isolate; - background: var(--c-surface); -} - -/* ── Dark product header strip ── */ -.portal-welcome__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; - padding: 0.875rem 1.25rem; - background: color-mix(in srgb, var(--c-primary) 22%, var(--c-hero-dark)); -} - -.portal-welcome__brand { - display: flex; - align-items: center; - gap: 0.875rem; - min-width: 0; -} - -.portal-welcome__mark { - display: grid; - place-items: center; - flex-shrink: 0; -} -.portal-welcome__mark img { - display: block; - height: 1.75rem; - width: auto; -} - -.portal-welcome__brand-text { - display: flex; - align-items: baseline; - gap: 0.625rem; - min-width: 0; - flex-wrap: wrap; -} - -.portal-welcome__product { - font-size: 1.125rem; - font-weight: 700; - letter-spacing: -0.01em; - color: #fff; -} - -.portal-welcome__stats { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.8125rem; - color: rgba(255, 255, 255, 0.55); -} - -/* Header action group: icon buttons + the CTA. */ -.portal-welcome__actions { - display: flex; - align-items: center; - gap: 0.5rem; -} -.portal-welcome__icon-btn { - display: grid; - place-items: center; - width: 2.25rem; - height: 2.25rem; - flex-shrink: 0; - border-radius: var(--radius-md); - border: 1px solid rgba(255, 255, 255, 0.16); - background: rgba(255, 255, 255, 0.08); - color: #fff; - cursor: pointer; - transition: background var(--motion-fast); -} -.portal-welcome__icon-btn:hover { - background: rgba(255, 255, 255, 0.16); -} - -/* White CTA on the dark header, matching the marketing card. */ -.portal-welcome__header .portal-welcome__cta.sui-btn { - background: #ffffff; - border-color: #ffffff; - color: var(--c-hero-dark-cta-text); -} -.portal-welcome__header .portal-welcome__cta.sui-btn:hover { - background: rgba(255, 255, 255, 0.88); - border-color: rgba(255, 255, 255, 0.88); -} - -/* ── Steps + enterprise (setup checklist) sit directly under the header ── */ -.portal-welcome__footer { - background: var(--c-surface); -} diff --git a/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx b/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx deleted file mode 100644 index 8dba4caf36..0000000000 --- a/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { WelcomeBanner } from "@portal/components/WelcomeBanner"; -import { SetupChecklist } from "@portal/components/SetupChecklist"; -import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; - -const progress: OnboardingProgress = { - loading: false, - deployed: false, - editorDone: false, - policiesDone: true, - inviteDone: false, - policiesActive: 2, - policiesRecommended: 5, - allComplete: false, -}; - -const meta: Meta = { - title: "Portal/Home/WelcomeBanner", - component: WelcomeBanner, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -/** The hero on its own, no attached footer. */ -export const Default: Story = {}; - -/** The hero as it renders on the free-tier home: the "Finish setting up" - * checklist attached as the footer strip. */ -export const WithSetupChecklist: Story = { - args: { - footer: , - }, -}; diff --git a/frontend/editor/src/portal/components/WelcomeBanner.tsx b/frontend/editor/src/portal/components/WelcomeBanner.tsx deleted file mode 100644 index c1d6857bdb..0000000000 --- a/frontend/editor/src/portal/components/WelcomeBanner.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { ReactNode } from "react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; -import { useView } from "@portal/contexts/ViewContext"; -import { EDITOR_URL } from "@portal/auth/editorUrl"; -import { - DownloadIcon, - ExternalLinkIcon, - UserPlusIcon, -} from "@portal/components/icons"; -import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; -import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; -import "@portal/components/WelcomeBanner.css"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Free-tier welcome hero */ -/* */ -/* A compact product header — brand mark, "PDF Editor" + social-proof */ -/* stats, and a single "Open in browser" CTA — over the getting-started */ -/* steps (passed in as {@code footer}). Deliberately lean: the onboarding */ -/* steps, not marketing copy, are the point of the card. */ -/* ──────────────────────────────────────────────────────────────────────── */ - -interface WelcomeBannerProps { - /** - * The getting-started steps + enterprise rung, rendered inside the card - * below the header. Kept as a slot so the hero stays a presentational shell. - */ - footer?: ReactNode; -} - -export function WelcomeBanner({ footer }: WelcomeBannerProps) { - const { t } = useTranslation(); - const { setActiveView } = useView(); - const [installOpen, setInstallOpen] = useState(false); - - return ( -
-
-
- - - -
- - {t("portal.welcome.productName")} - - - {t("portal.welcome.stats")} - -
-
-
- - - -
-
- - {footer &&
{footer}
} - - setInstallOpen(false)} - /> -
- ); -} diff --git a/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx index b5d0d58a3b..7d322a7be6 100644 --- a/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx +++ b/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx @@ -665,7 +665,6 @@ export function BundleCheckoutModal({ {t("portal.billing.prepaid.buy.cancel", "Cancel")} - @@ -701,7 +700,6 @@ export function BundleCheckoutModal({ {t("portal.billing.prepaid.buy.back", "Back")} diff --git a/frontend/editor/src/portal/components/billing/FreePlanView.tsx b/frontend/editor/src/portal/components/billing/FreePlanView.tsx index d3a1baf036..e2bbb24a34 100644 --- a/frontend/editor/src/portal/components/billing/FreePlanView.tsx +++ b/frontend/editor/src/portal/components/billing/FreePlanView.tsx @@ -85,7 +85,6 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { const switchOnAction = isLeader ? ( } diff --git a/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx b/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx index c099836ee4..f07083f215 100644 --- a/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx +++ b/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx @@ -1,15 +1,14 @@ import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; -// The trademarked Stirling wordmark — the font is baked into the SVG (no brand webfont is loaded), so -// we render the same asset the portal nav uses rather than styled text. Theme-switched in CSS. -import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg"; -import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; /** - * Shared header for the prepay-flow modals — the prepaid wizard (activation → calculator → pay, of 3) - * and the metered checkout (spend limit → payment, of 2): Stirling brand + "Step N of M" badge + close, - * an M-segment progress bar, and the step title. Pass {@code step=undefined} to hide the badge + - * progress (e.g. a terminal confirmation). + * The prepay flows' stepped header — the prepaid wizard (activation → calculator → pay, of 3) and + * the metered checkout (spend limit → payment, of 2). + * + * Chrome and copy only: the layout is the shared {@link StepModalHeader}, so this flow reads the + * same as every other stepped modal. Keeps the billing root class, which the framed-checkout rule + * targets to own the header's padding. Pass {@code step=undefined} to hide the badge + progress + * (e.g. a terminal confirmation). */ export function PrepayModalHeader({ step, @@ -24,68 +23,27 @@ export function PrepayModalHeader({ onClose: () => void; }) { const { t } = useTranslation(); - const showSteps = step != null; - const filled = step ?? 0; return ( -
-
-
- Stirling - -
-
- {showSteps && ( - - {t( - "portal.billing.prepaid.buy.step", - "Step {{current}} of {{total}}", - { current: step, total }, - )} - - )} -
-
- {showSteps && ( -
- = 1 ? "is-filled" : ""} /> - = 2 ? "is-filled" : ""} /> - {total >= 3 && = 3 ? "is-filled" : ""} />} -
- )} -
{title}
-
+ ); } diff --git a/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx b/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx index 9ee2fa1f5e..5c7b510618 100644 --- a/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx +++ b/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx @@ -191,7 +191,7 @@ export function SpendLimitCard({ > {t("portal.billing.spendLimit.cancel", "Cancel")} - diff --git a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx index 4787c40b37..ee76a780c2 100644 --- a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx +++ b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx @@ -451,7 +451,6 @@ export function StripeCheckoutModal({ {t("portal.billing.checkout.cap.back", "Back")} - - - } - > -

{copy.body}

- {needsFile && ( -
- setFile(e.target.files?.[0] ?? null)} - /> - - - {file ? file.name : t("portal.procurement.modal.noFile")} - -
- )} - - ); -} diff --git a/frontend/editor/src/portal/components/procurement/DealJourney.stories.tsx b/frontend/editor/src/portal/components/procurement/DealJourney.stories.tsx deleted file mode 100644 index a59386dc3a..0000000000 --- a/frontend/editor/src/portal/components/procurement/DealJourney.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { DealJourney } from "@portal/components/procurement/DealJourney"; -import { buildProcurement } from "@portal/mocks/procurement"; -import type { Deal } from "@portal/api/procurement"; -import "@portal/views/Procurement.css"; - -const data = buildProcurement("enterprise"); -const deal = data.deal as Deal; - -const meta: Meta = { - title: "Portal/Procurement/DealJourney", - component: DealJourney, - parameters: { layout: "padded" }, - args: { deal, journey: data.journey, onAdvance: () => {} }, -}; -export default meta; -type Story = StoryObj; - -// Mid-journey at the Agreement stage, the seeded deal state. -export const Default: Story = {}; - -// Evaluating: the trial strip shows runway + key; next step builds the quote. -export const AtTrial: Story = { - args: { deal: { ...deal, currentStage: "trial" } }, -}; - -// Terminal stage, provisioning, no further CTA. -export const Live: Story = { - args: { deal: { ...deal, currentStage: "active" } }, -}; diff --git a/frontend/editor/src/portal/components/procurement/DealJourney.tsx b/frontend/editor/src/portal/components/procurement/DealJourney.tsx deleted file mode 100644 index 3e8c86e58d..0000000000 --- a/frontend/editor/src/portal/components/procurement/DealJourney.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card } from "@app/ui"; -import type { Deal, DealStage, JourneyStep } from "@portal/api/procurement"; -import { StageStepper } from "@portal/components/procurement/StageStepper"; - -/** - * The deal's commercial journey in one card: who's guiding it (the solutions - * engineer), where it sits (the stage stepper), trial runway while evaluating, - * and the single next action that advances the deal. Mirrors "one next action - * at a time"; the full per-stage checklist lives in the Documents card. - */ -export function DealJourney({ - deal, - journey, - onAdvance, - advancing = false, -}: { - deal: Deal; - journey: JourneyStep[]; - onAdvance: (stage: DealStage) => void; - advancing?: boolean; -}) { - const { t } = useTranslation(); - const { engineer, trial, currentStage } = deal; - const currentStep = journey.find((s) => s.stage === currentStage); - const isTerminal = - journey.length > 0 && journey[journey.length - 1].stage === currentStage; - - return ( - -
-
- - {t("portal.procurement.journey.eyebrow")} - -

- {t("portal.procurement.journey.title")} -

-

- {t("portal.procurement.journey.subtitle")} -

-
-
- - {t("portal.procurement.journey.engineerLabel")} - - {engineer.name} - - {engineer.email} - -
-
- -
- -
- - {currentStage === "trial" && ( -
- - {t("portal.procurement.journey.trialTitle")} - - - {t("portal.procurement.journey.daysLeft", { - count: trial.daysLeft, - })} - - {trial.key} -
- )} - -
-
- - - {isTerminal - ? t("portal.procurement.journey.live") - : t("portal.procurement.journey.nextStep", { - action: currentStep ? t(currentStep.gatingAction) : "", - })} - -
- {!isTerminal && currentStep && ( - - )} -
-
- ); -} diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx index 9421de28bb..77c008ce41 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx @@ -12,6 +12,10 @@ const base: ProcurementSnapshot = { trialExtensionsUsed: 0, licensed: false, licenseKey: null, + businessName: null, + contactName: null, + contactEmail: null, + agreementSignedVersion: null, latestQuote: null, }; @@ -23,11 +27,11 @@ const meta: Meta = { args: { canSchedule: true, onExpand: () => {}, + onAcceptQuote: () => {}, onLicense: () => {}, onInvite: () => {}, onSchedule: () => {}, onManageTrial: () => {}, - onNavigate: () => {}, }, }; export default meta; diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx index 092e259bb3..ef2172bb27 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx @@ -1,31 +1,62 @@ -import { useEffect } from "react"; +import { useEffect, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; -import type { ViewId } from "@portal/contexts/ViewContext"; import { FLOW_JOURNEY, + type DealStage, type ProcurementSnapshot, } from "@portal/api/procurement"; -import { StageStepper } from "@portal/components/procurement/StageStepper"; +import { + CalendarIcon, + CheckIcon, + DocumentsIcon, + KeyIcon, + UserPlusIcon, +} from "@portal/components/icons"; import { warmCalendly } from "@portal/components/procurement/CalendlyInline"; +import { openApiUrl } from "@portal/api/externalUrl"; import "@portal/views/Procurement.css"; +/** What each stage asks of the buyer, read out in the stage sentence. */ +const STAGE_SENTENCE: Record = { + // Exploring sits on the Trial rung: same sentence, since the ask is what differs. + exploring: "portal.procurement.hero.sentenceTrial", + trial: "portal.procurement.hero.sentenceTrial", + quote: "portal.procurement.hero.sentenceQuote", + security: "portal.procurement.hero.sentenceAgreement", + procurement: "portal.procurement.hero.sentencePayment", + active: "portal.procurement.hero.sentenceLive", +}; + +/** The primary action for each stage; expanding the flow runs it. */ +const STAGE_CTA: Record = { + exploring: "portal.procurement.hero.ctaExploring", + trial: "portal.procurement.hero.ctaTrial", + quote: "portal.procurement.hero.ctaQuote", + security: "portal.procurement.hero.ctaAgreement", + // Same label whether it links straight to Stripe or, lacking an invoice URL, opens the stage dialog + // where the invoice actions live — the buyer is being sent to the invoice either way. + procurement: "portal.procurement.payment.viewInvoice", + active: "portal.procurement.hero.open", +}; + /** - * The enterprise deal-status hero on Home (procurement lives here, not as a nav tab). Adapts to the - * deal stage: quick-action chips (trial countdown → manage, licence key, invite teammates, - * schedule a call), a rollout checklist during the trial, and a stage-specific primary CTA that - * expands the flow into the takeover modal. Matches the marketing prototype. + * The enterprise deal-status hero on Home (procurement lives here, not as a nav tab) — this card IS + * the procurement surface. It carries the journey as a segmented progress band plus a stage + * sentence, and one primary action with quiet icon buttons beside it; the flow itself opens in the + * takeover modal. Rollout setup lives in the non-procurement setup checklist, not here. */ export function DealStatusHero({ snapshot, busy = false, canSchedule, onExpand, + onAcceptQuote, onLicense, onInvite, onSchedule, onManageTrial, - onNavigate, + onDocuments, }: { snapshot: ProcurementSnapshot; busy?: boolean; @@ -33,11 +64,17 @@ export function DealStatusHero({ * "Schedule a call" action only appears when the org has linked its account. */ canSchedule: boolean; onExpand: () => void; + /** + * Accept the issued quote, which advances the deal to the agreement. Offered here rather than + * inside the quote review so the buyer can circulate the quote and come back to decide. + */ + onAcceptQuote: () => void; onLicense: () => void; onInvite: () => void; onSchedule: () => void; onManageTrial: () => void; - onNavigate: (view: ViewId) => void; + /** Open the Documents reference (agreement, quote, invoice, EULA, SLA, subprocessors). */ + onDocuments: () => void; }) { const { t } = useTranslation(); @@ -49,129 +86,181 @@ export function DealStatusHero({ const stage = snapshot.stage ?? "trial"; const inTrial = stage === "trial"; - const cta = - stage === "trial" - ? t("portal.procurement.hero.ctaTrial") - : stage === "quote" - ? t("portal.procurement.hero.ctaQuote") - : stage === "procurement" - ? t("portal.procurement.hero.ctaPayment") - : t("portal.procurement.hero.ctaLive"); + const isLive = stage === "active"; + // A live quote is sitting with the buyer. A draft (or an expired/cancelled one) is not something to + // accept — that stage still means "finish building it". + const quoteAwaitingDecision = + stage === "quote" && + (snapshot.latestQuote?.status === "sent" || + snapshot.latestQuote?.status === "open"); + // Paying happens on Stripe, so the card links straight there rather than opening a dialog whose only + // real action was the same link. Without an invoice URL there is nothing to link to, so the stage + // falls back to its dialog, where the signed agreement is still reachable. + const invoiceUrl = + stage === "procurement" ? snapshot.latestQuote?.invoiceUrl : null; + // Known from trial setup onward. The quote's own copy wins when present, since the buyer may have + // corrected it there; before either exists the eyebrow stands alone rather than inventing a name. + const company = + snapshot.latestQuote?.config.businessName?.trim() || + snapshot.businessName?.trim(); - const setupSteps: { title: string; sub: string; view: ViewId }[] = [ - { - title: t("portal.procurement.hero.setup1Title"), - sub: t("portal.procurement.hero.setup1Sub"), - view: "users", - }, - { - title: t("portal.procurement.hero.setup2Title"), - sub: t("portal.procurement.hero.setup2Sub"), - view: "sources", - }, - { - title: t("portal.procurement.hero.setup3Title"), - sub: t("portal.procurement.hero.setup3Sub"), - view: "policies", - }, - ]; + // Exploring is presented as the Trial rung — same position, sentence and next step — because the + // buyer has entered the journey; only the ask differs, since no trial has actually started. + const journeyStage = stage === "exploring" ? "trial" : stage; + const currentIdx = Math.max( + 0, + FLOW_JOURNEY.findIndex((s) => s.stage === journeyStage), + ); + const nextStage = FLOW_JOURNEY[currentIdx + 1]; return (
-
+
- {t("portal.procurement.hero.eyebrow")} + {company + ? t("portal.procurement.hero.eyebrowCompany", { company }) + : t("portal.procurement.hero.eyebrow")} - - {t("portal.procurement.hero.company")} - -
-
+ +
+ {FLOW_JOURNEY.map((s, i) => ( + + ))} +
+ +

+ {t(FLOW_JOURNEY[currentIdx].label)} + {` · ${t(STAGE_SENTENCE[stage])} `} + {nextStage && ( + + {t("portal.procurement.hero.next", { + stage: t(nextStage.label), + })} + + )} +

+ {inTrial && snapshot.trialEndsAt && ( - - )} - {snapshot.licenseKey && ( - - )} - {stage !== "active" && ( - - )} - {canSchedule && ( - +
+ +
)}
-
- -
- - {inTrial && ( -
    - {setupSteps.map((s) => ( -
  • - -
  • - ))} -
+ {isLive && ( +
+ + + + + + {t("portal.procurement.hero.liveTitle")} + + + {t("portal.procurement.hero.liveSub")} + + +
)} -
- - - {t("portal.procurement.hero.nextStep", { action: cta })} - -
- + + + ) : invoiceUrl ? ( + + ) : ( + + )} +
+ {snapshot.licenseKey && ( + + + + )} + + + + {!isLive && ( + + + + )} + {canSchedule && ( + + + + )}
); } +/** A quiet icon-only secondary action; its label carries in the tooltip and to screen readers. */ +function IconAction({ + label, + onClick, + children, +}: { + label: string; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + function daysLeft(iso: string): number { const end = new Date(iso).getTime(); return Math.max(0, Math.ceil((end - Date.now()) / 86_400_000)); diff --git a/frontend/editor/src/portal/components/procurement/DocRow.stories.tsx b/frontend/editor/src/portal/components/procurement/DocRow.stories.tsx deleted file mode 100644 index 695b8c6941..0000000000 --- a/frontend/editor/src/portal/components/procurement/DocRow.stories.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { DocRow } from "@portal/components/procurement/DocRow"; -import type { LedgerDoc } from "@portal/api/procurement"; -import "@portal/views/Procurement.css"; - -const meta: Meta = { - title: "Portal/Procurement/DocRow", - component: DocRow, - parameters: { layout: "padded" }, - args: { onAction: () => {} }, -}; -export default meta; -type Story = StoryObj; - -const sign: LedgerDoc = { - id: "d1", - name: "Stirling Enterprise Agreement", - sub: "One signature: MSA + order form + EULA + DPA.", - status: "action", - action: "sign", -}; - -const download: LedgerDoc = { - id: "d2", - name: "SOC 2 Type II report", - sub: "Independent audit of our security controls.", - status: "available", - action: "download", -}; - -const paidAddon: LedgerDoc = { - id: "d3", - name: "Onboarding & training", - sub: "Guided rollout and live training for your team.", - status: "request", - action: "request", - optional: true, - fee: 7_500, -}; - -const done: LedgerDoc = { - id: "d4", - name: "Formal quote", - sub: "Committed-volume pricing, term and line items.", - status: "complete", - action: "download", -}; - -// Deal-advancing action, filled purple CTA. -export const SignAction: Story = { args: { doc: sign } }; - -// Quiet outline action for a ready download. -export const Download: Story = { args: { doc: download } }; - -// Optional paid add-on, chips flag it and the fee folds into the CTA. -export const PaidAddon: Story = { args: { doc: paidAddon } }; - -// Completed paperwork keeps a record but offers no further action. -export const Complete: Story = { args: { doc: done } }; - -// A row in a future, not-yet-reached stage, dimmed, marked "Upcoming", inert. -export const Locked: Story = { args: { doc: sign, locked: true } }; diff --git a/frontend/editor/src/portal/components/procurement/DocRow.tsx b/frontend/editor/src/portal/components/procurement/DocRow.tsx deleted file mode 100644 index b4329fbfa4..0000000000 --- a/frontend/editor/src/portal/components/procurement/DocRow.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Chip, StatusBadge } from "@app/ui"; -import type { LedgerDoc } from "@portal/api/procurement"; -import { - ACTION_LABEL_KEY, - STATUS_LABEL_KEY, - STATUS_TONE, - USD, -} from "@portal/components/procurement/format"; - -/** Maps a document's action to the button accent + variant. */ -function buttonStyle(doc: LedgerDoc): { - variant: "primary" | "secondary"; - accent: "premium" | "default"; -} { - // The agreement signature and online payment are the deal-advancing actions; - // give them the filled premium CTA. Everything else is a quieter outline. - if (doc.action === "sign" || doc.action === "pay") { - return { variant: "primary", accent: "premium" }; - } - return { variant: "secondary", accent: "default" }; -} - -/** - * A single document in the ledger or supporting pool: name + sub-line on the - * left, status badge and action button on the right. Optional/fee-bearing docs - * carry a chip so the buyer sees a paid add-on before clicking. `locked` is for - * rows in a future, not-yet-reached stage: dimmed, marked "Upcoming", inert. - */ -export function DocRow({ - doc, - onAction, - locked = false, -}: { - doc: LedgerDoc; - onAction: (doc: LedgerDoc) => void; - locked?: boolean; -}) { - const { t } = useTranslation(); - const { variant, accent } = buttonStyle(doc); - // Locked (future-stage), in-progress (pending) and completed paperwork all - // offer no action; only "available", "action" and "request" docs do. - const actionable = - !locked && doc.status !== "complete" && doc.status !== "pending"; - const label = t(ACTION_LABEL_KEY[doc.action]); - const actionLabel = - doc.fee !== undefined ? `${label} · ${USD.format(doc.fee)}` : label; - - return ( -
-
-
- {doc.name} - {doc.optional && ( - - {t("portal.procurement.docs.optional")} - - )} - {doc.fee !== undefined && ( - - {t("portal.procurement.docs.paidAddon")} - - )} -
-

{doc.sub}

-
-
- - {locked - ? t("portal.procurement.docs.upcoming") - : t(STATUS_LABEL_KEY[doc.status])} - - {actionable && ( - - )} -
-
- ); -} diff --git a/frontend/editor/src/portal/components/procurement/DocumentLedger.stories.tsx b/frontend/editor/src/portal/components/procurement/DocumentLedger.stories.tsx deleted file mode 100644 index 5de6692999..0000000000 --- a/frontend/editor/src/portal/components/procurement/DocumentLedger.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { DocumentLedger } from "@portal/components/procurement/DocumentLedger"; -import { buildProcurement } from "@portal/mocks/procurement"; -import "@portal/views/Procurement.css"; - -const data = buildProcurement("enterprise"); - -const meta: Meta = { - title: "Portal/Procurement/DocumentLedger", - component: DocumentLedger, - parameters: { layout: "padded" }, - args: { - groups: data.ledger, - supporting: data.supporting, - journey: data.journey, - currentStage: data.deal?.currentStage ?? "trial", - onAction: () => {}, - }, -}; -export default meta; -type Story = StoryObj; - -// Mid-journey: the Agreement stage is open, earlier stages read as done, later -// stages are locked previews, and the supporting pool sits collapsed below. -export const Default: Story = {}; - -// Day one: only the Trial stage has been reached; everything ahead is locked. -export const AtTrial: Story = { - args: { currentStage: "trial" }, -}; diff --git a/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx b/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx deleted file mode 100644 index aa9e6ae8ce..0000000000 --- a/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Card, Chip, Collapsible } from "@app/ui"; -import type { - DealStage, - JourneyStep, - LedgerDoc, - LedgerGroup, - SupportingGroup, -} from "@portal/api/procurement"; -import { DocRow } from "@portal/components/procurement/DocRow"; - -/** - * The "Documents" card: every artifact the deal needs, as a stage accordion - * that mirrors the journey. Only the current stage is open by default; earlier - * stages read as done, later stages are locked previews. A collapsed-by-default - * "Supporting your evaluation" pool holds the stage-agnostic paperwork. - */ -export function DocumentLedger({ - groups, - supporting, - journey, - currentStage, - onAction, -}: { - groups: LedgerGroup[]; - supporting: SupportingGroup[]; - journey: JourneyStep[]; - currentStage: DealStage; - onAction: (doc: LedgerDoc) => void; -}) { - const { t } = useTranslation(); - const order = journey.map((s) => s.stage); - const curIdx = order.indexOf(currentStage); - // Follow the deal: the stage you're in opens first; any other stage can be - // peeked. null collapses them all. Advancing moves the open section along. - const [openStage, setOpenStage] = useState(currentStage); - const [supportingOpen, setSupportingOpen] = useState(false); - useEffect(() => setOpenStage(currentStage), [currentStage]); - - return ( - -
-

- {t("portal.procurement.docs.title")} -

-

- {t("portal.procurement.docs.subtitle")} -

-
- -
- {groups.map((group) => { - const idx = order.indexOf(group.stage); - const done = idx < curIdx; - const cur = group.stage === currentStage; - const locked = idx > curIdx; - const blurb = journey.find((s) => s.stage === group.stage)?.blurb; - const open = openStage === group.stage; - const count = group.docs.length; - - return ( - setOpenStage(open ? null : group.stage)} - header={ - <> - - - {t(group.label)} - - {blurb && ( - - · {t(blurb)} - - )} - {cur && ( - - {t("portal.procurement.docs.here")} - - )} - {done && ( - - {t("portal.procurement.docs.done")} - - )} - - } - aside={ - - {t("portal.procurement.docs.count", { count })} - - } - > -
- {group.docs.map((doc) => ( - - ))} -
-
- ); - })} - - {supporting.length > 0 && ( - setSupportingOpen((o) => !o)} - header={ - - - {t("portal.procurement.docs.supportingTitle")} - - - {t("portal.procurement.docs.supportingSubtitle")} - - - } - aside={ - - {supportingOpen - ? t("portal.procurement.docs.hide") - : t("portal.procurement.docs.show")} - - } - > -
- {supporting.map((group) => ( -
-
{group.label}
-
- {group.docs.map((doc) => ( - - ))} -
-
- ))} -
-
- )} -
-
- ); -} diff --git a/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx b/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx deleted file mode 100644 index eae9db4d99..0000000000 --- a/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { LockedState } from "@portal/components/procurement/LockedState"; -import { JOURNEY } from "@portal/api/procurement"; -import "@portal/views/Procurement.css"; - -const meta: Meta = { - title: "Portal/Procurement/LockedState", - component: LockedState, - parameters: { layout: "padded" }, - args: { onTalkToSales: () => {} }, -}; -export default meta; -type Story = StoryObj; - -// Shown to free/pro buyers, the journey preview behind the upgrade prompt. -export const Default: Story = { - args: { journey: JOURNEY }, -}; diff --git a/frontend/editor/src/portal/components/procurement/LockedState.tsx b/frontend/editor/src/portal/components/procurement/LockedState.tsx deleted file mode 100644 index 8e70ba18c4..0000000000 --- a/frontend/editor/src/portal/components/procurement/LockedState.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card, EmptyState } from "@app/ui"; -import type { JourneyStep } from "@portal/api/procurement"; -import { StageStepper } from "@portal/components/procurement/StageStepper"; - -/** - * Enterprise-only gate for free/pro buyers. Shows the journey as a greyed - * preview behind an upgrade prompt so the buyer understands what the - * commercial track looks like before they talk to sales. - */ -export function LockedState({ - journey, - onTalkToSales, -}: { - journey: JourneyStep[]; - onTalkToSales: () => void; -}) { - const { t } = useTranslation(); - return ( -
- - {t("portal.procurement.locked.talkToSales")} - - } - /> - - - -
- ); -} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx index a6ca8dfcf5..fd47d867c6 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx @@ -69,10 +69,9 @@ const meta: Meta = { args: { quote, busy: false, - downloading: false, onAgree: () => {}, - onDownload: () => {}, - onEdit: () => {}, + onRequestChanges: () => {}, + onClose: () => {}, }, }; export default meta; @@ -81,12 +80,7 @@ type Story = StoryObj; export const Default: Story = {}; -// Agreeing: the primary CTA shows its loading state while the accept call is in flight. -export const Agreeing: Story = { +// Signing: the primary CTA shows its loading state while the accept call is in flight. +export const Signing: Story = { args: { busy: true }, }; - -// Downloading: the secondary action shows its loading state instead. -export const Downloading: Story = { - args: { downloading: true }, -}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx index 336b6af5cb..318f23aca5 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx @@ -1,152 +1,267 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Card } from "@app/ui"; -import type { QuoteResult } from "@portal/api/procurement"; -import { money } from "@portal/components/procurement/format"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Button } from "@app/ui"; +import { + fetchAgreementDocument, + fetchAgreementPdf, + recordAgreementSignature, + type QuoteResult, +} from "@portal/api/procurement"; +import { DownloadIcon } from "@portal/components/icons"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; +import { useAsync } from "@portal/hooks/useAsync"; import "@portal/views/Procurement.css"; /** - * The agreement (security) step: a single combined Stirling Enterprise Agreement — Master Service - * Agreement + Order Form (from the issued quote) + EULA + Data Processing Agreement — that the buyer - * reviews and agrees to before it's accepted into a subscription. No e-signature for now: an explicit - * "I agree" click stands in (the terms reference the accepted quote). Document body is static legal - * copy; the surrounding UI is translated. + * The agreement (security) step: the buyer reviews the full Stirling Enterprise Agreement — Master + * Services Agreement + Order Form (from the quote) + Data Processing Addendum, one signature — then + * signs it. The document body is served by the backend from the versioned legal registry (static + * legal copy, English only); this component renders it, gates signing behind a scroll-through, and + * captures the typed legal name, signatory, title, and authority. On sign it records the signature + * (pinned to the exact document version + a hash) and then accepts the quote into a subscription. + * + * Presented as the document itself rather than a card about it: this step names the agreement in the + * dialog's own header and carries its download there, so the terms are read on paper-like stock + * instead of in app chrome. That is why it draws its own header — see ProcurementFlow. */ export function ProcurementAgreement({ quote, busy, - downloading, onAgree, - onDownload, - onEdit, + onRequestChanges, + onClose, }: { quote: QuoteResult; busy: boolean; - downloading: boolean; - /** Accept the quote straight into a committed subscription (this is also the agreement). */ + /** Accept the quote straight into a committed subscription (runs after the signature is saved). */ onAgree: () => void; - onDownload: () => void; - onEdit: () => void; + /** Hand the buyer to their SE to negotiate terms: closes this and opens scheduling. */ + onRequestChanges: () => void; + /** This step draws the dialog's header, so it carries the close too. */ + onClose?: () => void; }) { const { t } = useTranslation(); - const [checked, setChecked] = useState(false); - const annual = money(quote.annualNetMinor, quote.currency); - const tcv = money(quote.tcvMinor, quote.currency); - const renewal = money(quote.renewalAnnualNetMinor, quote.currency); - const years = quote.config.termYears; + const { data: doc, loading } = useAsync(fetchAgreementDocument, []); + + const [legalName, setLegalName] = useState(quote.config.businessName ?? ""); + const [signatory, setSignatory] = useState(quote.config.contactName ?? ""); + const [title, setTitle] = useState(""); + const [confirmed, setConfirmed] = useState(false); + const [scrolledToEnd, setScrolledToEnd] = useState(false); + const [signing, setSigning] = useState(false); + const [downloadingMsa, setDownloadingMsa] = useState(false); + const [error, setError] = useState(false); + const [downloadError, setDownloadError] = useState(false); + const docRef = useRef(null); + + const downloadMsa = async () => { + setDownloadingMsa(true); + setDownloadError(false); + try { + const blob = await fetchAgreementPdf(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "stirling-enterprise-agreement.pdf"; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } catch { + // Surface the failure — the PDF is rendered server-side, so a failure here means the + // render service is unavailable rather than something the buyer can retry around. + setDownloadError(true); + } finally { + setDownloadingMsa(false); + } + }; + + const onScroll = () => { + const el = docRef.current; + if (!el) return; + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 24) { + setScrolledToEnd(true); + } + }; + + const ready = + scrolledToEnd && + confirmed && + legalName.trim().length > 0 && + signatory.trim().length > 0; + + const sign = async () => { + setError(false); + setSigning(true); + try { + await recordAgreementSignature({ + customerLegalName: legalName.trim(), + signatoryName: signatory.trim(), + signatoryTitle: title.trim(), + authorityConfirmed: confirmed, + }); + onAgree(); // proceed into the committed subscription + } catch { + setError(true); + } finally { + // In `finally`, not only on failure: accepting can fail after the signature is recorded, and + // the controller deliberately keeps this dialog open on failure so the error is readable. With + // the flag left set, the button span the rest of the session and there was no way to retry. + setSigning(false); + } + }; return ( - - - {t("portal.procurement.agreement.eyebrow")} - -

- {t("portal.procurement.agreement.title")} -

-

- {t("portal.procurement.agreement.intro")} -

+
+ + {/* On the document, like the quote's: it downloads what is on screen. */} + + {/* Redlines are a conversation, not a form: this hands the buyer to their SE rather than + pretending the terms can be amended in the app. */} + +
+ } + /> -
-

1. Master Service Agreement

-

- This Stirling Enterprise Agreement ("Agreement") is entered into - between Stirling PDF Inc. ("Stirling") and the customer identified on - the Order Form ("Customer"). It governs Customer's access to and use - of the Stirling enterprise platform and related services (the - "Service"). Stirling will provide the Service with commercially - reasonable skill and care and in accordance with the service levels - set out in the Order Form. -

+ {/* The tray scrolls, not the paper. The paper is its natural height inside it, so mid-document it + runs flush to the footer with no grey beneath, and the tray's bottom padding only comes into + view once the buyer reaches the end — the page ending is what shows they got there. */} +
+
+ {loading &&

{t("portal.procurement.agreement.loading")}

} + {!loading && !doc && ( +

{t("portal.procurement.agreement.loadError")}

+ )} + {doc && ( + <> + {/* Letterhead: the reference ties the terms to the quote they price, and the version + label pins what was signed. The document's own heading follows, so this adds a + masthead rather than repeating the title. */} +
+ + {t("portal.procurement.agreement.confidential")} + + + {t("portal.procurement.agreement.ref", { + ref: quote.quoteNumber, + version: doc.versionLabel, + })} + +
+
+ {doc.markdown} +
+ + )} +
+
-

2. Order Form

-

- Quote {quote.quoteNumber} forms the Order Form for - this Agreement. Customer commits to a {years}-year term at{" "} - {annual} per year (total contract value{" "} - {tcv}), billed annually in advance by invoice. Fees - are exclusive of taxes. The committed volume, service level, and - add-ons are itemised below: + {error && ( +

+ {t("portal.procurement.agreement.signError")}

-
    - {quote.lineItems.map((li) => ( -
  • - {li.label} - - {li.kind === "INCLUDED" - ? t("portal.procurement.builder.included") - : money(li.amountMinor, quote.currency)} + )} + {downloadError && ( +

    + {t("portal.procurement.agreement.downloadDraftError")} +

    + )} + + {/* The signature block: who is bound, who signs, and the act of signing, on one line — the + shape of a paper signature block rather than a form above a button. The consent sits + directly under the fields it qualifies, with no rule between them: it is part of signing, + not a separate section, and boxing it cost the document a quarter of its height. */} +
    +
    +
    +
  • - ))} -
+ setLegalName(e.target.value)} + /> + + + +
+ +
-

3. Term, renewal and annual fee adjustment

-

- This Agreement runs for the committed {years}-year term set out in the - Order Form. It then renews automatically for successive one-year terms - unless either party gives written notice of non-renewal at least 30 - days before the end of the then-current term. On each renewal the - annual fee increases by {quote.cpiRatePct}%, a fixed CPI adjustment. - Based on this quote, the first renewal year would be approximately{" "} - {renewal} per year; the committed term above is - billed at the rate in the Order Form and is not affected. -

- -

4. End-User License Agreement

-

- Subject to the terms of this Agreement, Stirling grants Customer a - non-exclusive, non-transferable right to use the Service for its - internal business purposes during the term. Customer is responsible - for its users' compliance and for the content it processes. The - Service, and all intellectual property in it, remains Stirling's. -

- -

5. Data Processing Agreement

-

- Where Stirling processes personal data on Customer's behalf, it does - so only on Customer's documented instructions and applies appropriate - technical and organisational measures. Sub-processors, international - transfers, and security commitments are as described in Stirling's - Data Processing Agreement and Trust Center, incorporated here by - reference. -

- -

6. Acceptance

-

- By agreeing below, Customer accepts this Agreement and the Order Form. - On acceptance, Stirling will issue the committed annual subscription - and its first invoice. This preview stands in for e-signature during - the pilot. -

+ {/* Consent under the fields it qualifies; the gate's state under the button it gates, so the + reason signing is unavailable sits beside the unavailable thing. */} +
+ + {doc && !scrolledToEnd && ( + + {t("portal.procurement.agreement.scrollHint")} + + )} +
- - - -
- - - -
- + ); } diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx deleted file mode 100644 index 28e9d2f99a..0000000000 --- a/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ProcurementBanner } from "@portal/components/procurement/ProcurementBanner"; -import type { ProcurementController } from "@portal/components/procurement/useProcurement"; -import type { ProcurementSnapshot } from "@portal/api/procurement"; - -const snapshot: ProcurementSnapshot = { - dealId: 1, - stage: "trial", - deployment: "cloud", - seats: 250, - trialStartedAt: "2026-06-25T00:00:00Z", - trialEndsAt: "2026-07-09T00:00:00Z", - trialExtensionsUsed: 0, - licensed: false, - licenseKey: null, - latestQuote: null, -}; - -function makeController( - overrides: Partial = {}, -): ProcurementController { - return { - isLinked: true, - loading: false, - data: null, - started: false, - stage: undefined, - latest: null, - isIssued: false, - isDraft: true, - busy: false, - downloading: false, - downloadingLicense: false, - error: null, - setError: () => {}, - open: false, - setOpen: () => {}, - editing: false, - setEditing: () => {}, - extra: null, - setExtra: () => {}, - invoicePdf: null, - onStartTrial: () => {}, - onConfirmSetup: () => {}, - onExtendTrial: () => {}, - onReset: () => {}, - onGenerate: () => {}, - onAgree: () => {}, - onDownloadPdf: async () => {}, - onDownloadOfflineLicense: async () => {}, - ...overrides, - }; -} - -/** Deal-status hero once a deal is underway, otherwise the enterprise on-ramp. */ -const meta: Meta = { - title: "Portal/Procurement/ProcurementBanner", - component: ProcurementBanner, - parameters: { layout: "padded" }, -}; -export default meta; - -type Story = StoryObj; - -/** No deal yet: the enterprise on-ramp upsell. */ -export const Upsell: Story = { - args: { controller: makeController() }, -}; - -/** A deal is underway: the wired deal-status hero. */ -export const DealUnderway: Story = { - args: { - controller: makeController({ - started: true, - data: snapshot, - stage: snapshot.stage, - }), - }, -}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx index d47ffa0b48..626cc8e9fb 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx @@ -1,13 +1,10 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card } from "@app/ui"; import { useView } from "@portal/contexts/ViewContext"; import { DealStatusHero } from "@portal/components/procurement/DealStatusHero"; import type { ProcurementController } from "@portal/components/procurement/useProcurement"; /** - * The deal-status hero, wired to a shared ProcurementController. Rendered both - * standalone (the /procurement route) and as the Home hero card's footer once a - * deal is underway. Assumes an active deal (controller.data present). + * The deal-status hero, wired to a shared ProcurementController. Rendered as the Home hero card's + * footer once a deal is underway; assumes an active deal (controller.data present). */ export function ControlledDealStatusHero({ controller, @@ -21,60 +18,18 @@ export function ControlledDealStatusHero({ snapshot={controller.data} busy={controller.busy} canSchedule={controller.isLinked} - onExpand={() => controller.setOpen(true)} + onExpand={() => + // Exploring has no journey to expand into yet — its ask is to set the trial up. + controller.stage === "exploring" + ? controller.onStartTrial() + : controller.setOpen(true) + } + onAcceptQuote={() => void controller.onAcceptQuote()} onLicense={() => controller.setExtra("license")} onInvite={() => setActiveView("users")} onSchedule={() => controller.setExtra("schedule")} onManageTrial={() => controller.setExtra("trial")} - onNavigate={setActiveView} + onDocuments={() => controller.setExtra("documents")} /> ); } - -/** - * Enterprise on-ramp shown when no deal exists yet. Only used on the dedicated - * /procurement route — on Home the setup checklist's Enterprise rung owns the - * on-ramp, so this doesn't render there. - */ -export function ProcurementUpsell({ - controller, -}: { - controller: ProcurementController; -}) { - const { t } = useTranslation(); - return ( - -
- - {t("portal.procurement.upsell.homeBadge")} - -

- {t("portal.procurement.upsell.homeHeadline")} - {t("portal.procurement.upsell.homeBody")} -

-
- -
- ); -} - -/** Deal-status hero when a deal is underway, otherwise the enterprise on-ramp. */ -export function ProcurementBanner({ - controller, -}: { - controller: ProcurementController; -}) { - return controller.isLinked && controller.started && controller.data ? ( - - ) : ( - - ); -} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx index 06361da844..7a6c6ffb18 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx @@ -31,6 +31,10 @@ const SNAPSHOT: ProcurementSnapshot = { trialExtensionsUsed: 0, licensed: false, licenseKey: null, + agreementSignedVersion: null, + businessName: null, + contactName: null, + contactEmail: null, latestQuote: null, }; @@ -72,13 +76,14 @@ export const ScheduleCall: Story = { ), }; -// Deployment + seat count captured before the trial starts. +// Two steps before the trial starts: how they'll run it, then who is buying. export const TrialSetup: Story = { render: () => ( {}} busy={false} + onScheduleCall={() => {}} onConfirm={() => {}} /> ), diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx index 44bbea1264..10ea20ed0c 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx @@ -1,11 +1,20 @@ import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { Button } from "@app/ui"; -import type { ProcurementSnapshot } from "@portal/api/procurement"; +import { + fetchLegalDocument, + recordLegalConsent, + type ProcurementSnapshot, + type TrialSetupDetails, +} from "@portal/api/procurement"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; import { CalendlyInline } from "@portal/components/procurement/CalendlyInline"; import { LicensePanel } from "@portal/components/procurement/ProcurementStages"; -import { useFocusTrap } from "@portal/components/procurement/ProcurementModal"; +import { FlowModal } from "@portal/components/shared/FlowModal"; +import { useAsync } from "@portal/hooks/useAsync"; +import { openApiUrl } from "@portal/api/externalUrl"; import "@portal/views/Procurement.css"; /** @@ -14,6 +23,8 @@ import "@portal/views/Procurement.css"; * scheduler. The shells and wiring are real so the hero behaves like the marketing prototype. */ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + function SideModal({ open, onClose, @@ -21,56 +32,243 @@ function SideModal({ subtitle, children, footer, + headerAside, wide = false, }: { open: boolean; onClose: () => void; title: string; subtitle?: string; + /** Sits on the title row, before the close button (e.g. a "Step 1 of 2" badge). */ + headerAside?: React.ReactNode; children: React.ReactNode; footer?: React.ReactNode; wide?: boolean; }) { - const { t } = useTranslation(); - const trapRef = useFocusTrap(open); - - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [open, onClose]); - - if (!open) return null; - return createPortal( -
e.target === e.currentTarget && onClose()} - > -
- -
-

{title}

+ return ( + +
+

{title}

+ {headerAside} +
{subtitle &&

{subtitle}

} + + } + > + {children} +
+ ); +} + +/** + * Reader for a versioned legal document (EULA, SLA exhibit, subprocessors), fetched from the + * backend registry and rendered as markdown. Open when {@code docId} is set. Drafts are badged. + */ +export function LegalDocumentModal({ + docId, + onClose, +}: { + docId: string | null; + onClose: () => void; +}) { + const { t } = useTranslation(); + const { data, loading } = useAsync( + () => (docId ? fetchLegalDocument(docId) : Promise.resolve(null)), + [docId], + ); + return ( + + {loading && ( +

{t("portal.legal.loading")}

+ )} + {!loading && !data && ( +

{t("portal.legal.loadError")}

+ )} + {data && ( +
+ {data.markdown}
-
{children}
- {footer &&
{footer}
} + )} +
+ ); +} + +// ── Documents ──────────────────────────────────────────────────────────────── +/** + * The deal's paperwork in one place, reachable throughout the journey (not tied to the current + * stage): the enterprise agreement, the quote, the invoice, and the reference documents (EULA, SLA + * exhibit, subprocessors). Each row downloads or views the real artifact when it's available, and + * reads as "available later" until then. The per-stage download buttons remain the primary path; + * this is the secondary, always-on reference. + */ +export function DocumentsModal({ + open, + onClose, + agreementVersion, + downloadingAgreement, + onDownloadAgreement, + onViewAgreement, + quoteAvailable, + downloadingQuote, + onDownloadQuote, + invoiceUrl, + invoicePdf, +}: { + open: boolean; + onClose: () => void; + agreementVersion?: string | null; + downloadingAgreement?: boolean; + onDownloadAgreement: () => void; + /** Jump to the agreement/sign stage in the flow (used before it's signed). */ + onViewAgreement: () => void; + quoteAvailable: boolean; + downloadingQuote?: boolean; + onDownloadQuote: () => void; + invoiceUrl?: string | null; + invoicePdf?: string | null; +}) { + const { t } = useTranslation(); + const [legalDoc, setLegalDoc] = useState(null); + const invoice = invoiceUrl || invoicePdf || null; + + return ( + <> + +
    + + + openApiUrl(invoice), + } + : { + unavailable: t("portal.procurement.documents.laterInvoice"), + } + } + /> + setLegalDoc("eula"), + }} + /> + setLegalDoc("sla"), + }} + /> + setLegalDoc("subprocessors"), + }} + /> +
+
+ setLegalDoc(null)} /> + + ); +} + +/** One row in the Documents list: name + sub on the left, an action button or a muted note. */ +function DocItem({ + name, + sub, + action, +}: { + name: string; + sub: string; + action: + | { label: string; onClick: () => void; loading?: boolean } + | { unavailable: string }; +}) { + return ( +
  • +
    + {name} + {sub}
    -
  • , - document.body, + {"unavailable" in action ? ( + {action.unavailable} + ) : ( + + )} + ); } @@ -154,82 +352,233 @@ export function TrialSetupModal({ open, onClose, busy, + email, + onScheduleCall, onConfirm, }: { open: boolean; onClose: () => void; busy: boolean; - onConfirm: (deployment: string, seats: number) => void; + /** Linked-account email, prefilled as the work email on the details step. */ + email?: string; + /** Open the scheduler — the step-1 escape hatch for buyers who want to talk first. */ + onScheduleCall: () => void; + onConfirm: ( + deployment: string, + seats: number, + details: TrialSetupDetails, + ) => void; }) { const { t } = useTranslation(); + const [step, setStep] = useState(0); const [deployment, setDeployment] = useState("cloud"); const [seats, setSeats] = useState(""); + const [contactName, setContactName] = useState(""); + const [businessName, setBusinessName] = useState(""); + const [contactEmail, setContactEmail] = useState(""); + const [inviteEmails, setInviteEmails] = useState(""); + const [eula, setEula] = useState(false); + const [legalDoc, setLegalDoc] = useState(null); // Reset to defaults each time the dialog opens, so a cancelled setup doesn't linger. useEffect(() => { if (open) { + setStep(0); setDeployment("cloud"); setSeats(""); + setContactName(""); + setBusinessName(""); + setContactEmail(email ?? ""); + setInviteEmails(""); + setEula(false); } - }, [open]); + }, [open, email]); + + // The buying entity is what the quote and agreement are drawn against, so it is required here + // rather than deferred to the quote; invites are genuinely optional. + const detailsValid = + contactName.trim().length > 0 && + businessName.trim().length > 0 && + EMAIL_RE.test(contactEmail.trim()); + + const confirm = () => { + void recordLegalConsent("eula", "trial"); // clickwrap consent, best-effort + onConfirm(deployment, Math.max(0, Number(seats) || 0), { + businessName: businessName.trim(), + contactName: contactName.trim(), + contactEmail: contactEmail.trim(), + inviteEmails: inviteEmails.trim(), + }); + }; return ( - onConfirm(deployment, Math.max(0, Number(seats) || 0))} - > - {t("portal.procurement.setup.start")} - - } - > - + + + ) : ( + <> + + + + ) + } + > + - -

    - {t("portal.procurement.setup.seatsHint")} -

    -
    + {step === 0 && ( + <> + + + + )} + + {step === 1 && ( + <> +
    + + +
    + + + + + + )} + + setLegalDoc(null)} /> + ); } @@ -280,7 +629,6 @@ export function TrialManageModal({ } @@ -103,46 +113,63 @@ export function ProcurementFlow({ {isLinked && started && ( <> -
    - -
    - - {(editing || - (isDraft && (stage === "trial" || stage === "quote"))) && ( + {builderShowing && ( setOpen(false)} onGenerate={onGenerate} + // Null while re-editing: the buyer asked for the form, not the paper they just left. + issued={!editing && isIssued ? latest : null} + downloading={downloading} + onDownload={onDownloadPdf} /> )} - {/* Quote + agreement are one step: review the itemised quote and the agreement, then - accept straight into a committed subscription. Once accepted you can't go back. - ("security" is the retired agreement stage — still handled so an older deal that - stopped there isn't left blank.) */} - {!editing && - isIssued && - (stage === "quote" || stage === "security") && - latest && ( - setEditing(true)} - /> - )} + {/* Agreement step: review and sign the enterprise agreement. Signing accepts the quote + into a committed subscription (Stripe). */} + {agreementShowing && latest && ( + { + setOpen(false); + setExtra("schedule"); + }} + onClose={() => setOpen(false)} + /> + )} {!editing && stage === "procurement" && latest && ( )} - {!editing && stage === "active" && } + {!editing && stage === "active" && ( + + )} )} @@ -151,6 +178,8 @@ export function ProcurementFlow({ open={extra === "setup"} onClose={() => setExtra(null)} busy={busy} + email={scheduleEmail ?? undefined} + onScheduleCall={() => setExtra("schedule")} onConfirm={onConfirmSetup} /> {data?.licenseKey && ( @@ -185,6 +214,23 @@ export function ProcurementFlow({ }} /> )} + setExtra(null)} + agreementVersion={data?.agreementSignedVersion} + downloadingAgreement={downloadingAgreement} + onDownloadAgreement={onDownloadSignedAgreement} + onViewAgreement={() => { + setExtra(null); + setEditing(false); + setOpen(true); + }} + quoteAvailable={!!latest?.stripeQuoteId} + downloadingQuote={downloading} + onDownloadQuote={onDownloadPdf} + invoiceUrl={latest?.invoiceUrl} + invoicePdf={latest?.invoicePdf ?? invoicePdf} + /> ); } diff --git a/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx deleted file mode 100644 index ae424922ad..0000000000 --- a/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ProcurementHome } from "@portal/components/procurement/ProcurementHome"; - -/** - * The end-to-end procurement experience (Home hero + takeover modal), driven by the `procurementSaas` - * MSW handlers: start trial → build quote → generate (issue Stripe Quote) → milestone (download PDF / - * accept). `autoOpen` opens the modal so the flow is immediately clickable. - */ -const meta: Meta = { - title: "Portal/Procurement/ProcurementHome", - component: ProcurementHome, - parameters: { layout: "fullscreen" }, -}; -export default meta; - -type Story = StoryObj; - -export const Default: Story = { args: { autoOpen: true } }; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx b/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx deleted file mode 100644 index 1877e0bc3c..0000000000 --- a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { ProcurementBanner } from "@portal/components/procurement/ProcurementBanner"; -import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; -import { useProcurement } from "@portal/components/procurement/useProcurement"; -import "@portal/views/Procurement.css"; - -/** - * The standalone procurement experience: a deal-status hero (or enterprise - * on-ramp when no deal exists) above the full-screen takeover flow that holds - * the journey — build + issue a quote, review + agree to the enterprise - * agreement, then accept into a committed subscription. Rendered at - * /procurement (autoOpen). On Home the deal-status hero instead attaches to the - * tier hero card's footer (see HomeHero) so this component isn't used there. - */ -export function ProcurementHome({ autoOpen = false }: { autoOpen?: boolean }) { - const controller = useProcurement(autoOpen); - return ( - <> - - - - ); -} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx index 7fb8b865f2..14934905d6 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx @@ -31,9 +31,7 @@ export const Open: Story = { contract and go live.

    - +
    diff --git a/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx b/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx index 852c4e68c4..881bb778ce 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx @@ -1,104 +1,49 @@ -import { useEffect, useRef } from "react"; -import { createPortal } from "react-dom"; -import { useTranslation } from "react-i18next"; +import type { ReactNode } from "react"; +import { FlowModal } from "@portal/components/shared/FlowModal"; import "@portal/views/Procurement.css"; -/** Keep keyboard focus inside an open dialog: focus it on open and wrap Tab at the edges. */ -export function useFocusTrap(open: boolean) { - const ref = useRef(null); - useEffect(() => { - if (!open) return; - const panel = ref.current; - if (!panel) return; - const prev = document.activeElement as HTMLElement | null; - const focusables = () => - Array.from( - panel.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ), - ).filter((el) => !el.hasAttribute("disabled")); - (focusables()[0] ?? panel).focus(); - const onKey = (e: KeyboardEvent) => { - if (e.key !== "Tab") return; - const items = focusables(); - if (items.length === 0) return; - const first = items[0]; - const last = items[items.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - }; - panel.addEventListener("keydown", onKey); - return () => { - panel.removeEventListener("keydown", onKey); - prev?.focus?.(); - }; - }, [open]); - return ref; -} - /** - * Full-screen takeover modal for the procurement flow, copying the prototype's modal design - * (portaled to body, dimmed + blurred backdrop, rounded panel, close button). The Home deal-status - * hero expands into this. + * The procurement takeover: the shared {@link FlowModal} at takeover width. Chrome and copy only — + * the shell (portal, focus trap, Escape, close, header/body bands) is shared, so this dialog cannot + * drift from the trial and licence dialogs the way two hand-rolled shells did. */ export function ProcurementModal({ open, onClose, title, subtitle, + headerless = false, children, }: { open: boolean; onClose: () => void; + /** Dialog label. Omit `subtitle` (and pass `headerless`) when the step renders its own heading. */ title: string; subtitle?: string; - children: React.ReactNode; + /** + * Skip the title block, which takes the shell's close with it: the step inside supplies the + * heading, step badge and its own close (see StepModalHeader), so the shell would otherwise stack + * a second header and leave a stray close above it. Escape and the backdrop still dismiss. + */ + headerless?: boolean; + children: ReactNode; }) { - const { t } = useTranslation(); - const trapRef = useFocusTrap(open); - - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [open, onClose]); - - if (!open) return null; - - return createPortal( -
    e.target === e.currentTarget && onClose()} + return ( + +

    {title}

    + {subtitle &&

    {subtitle}

    } + + ) + } > -
    - -
    -

    {title}

    - {subtitle &&

    {subtitle}

    } -
    -
    {children}
    -
    -
    , - document.body, + {children} + ); } diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx index 35460a7ba5..fcf926f481 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx @@ -1,61 +1,92 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Card } from "@app/ui"; +import { Button } from "@app/ui"; +import { openApiUrl } from "@portal/api/externalUrl"; import "@portal/views/Procurement.css"; /** - * The stage-specific cards shown inside the procurement takeover modal once a quote exists: the - * issued-quote milestone, the subscription-created payment step, and the live confirmation. Each is - * a pure presentational view driven by props; ProcurementHome owns the state and the actions. + * The stage-specific views shown inside the procurement takeover modal once the agreement is signed: + * the subscription-created payment step and the live confirmation. Each is a pure presentational view + * driven by props; the controller owns the state and the actions. + * + * Neither is wrapped in a Card: the dialog is already the surface, and a card inside it drew a second + * border around content that filled it. Both wear the same eyebrow/title/description stack and put + * their actions in the flow's footer bar, so the last two steps of the journey read like the ones + * before them rather than like panels that wandered in. */ -/** The subscription-created step: pay or download the first invoice. */ +/** The subscription-created step: pay or download the first invoice, and the signed agreement. */ export function PaymentStageCard({ invoiceUrl, invoicePdf, + signedAgreementVersion, + downloadingAgreement, + onDownloadSignedAgreement, }: { invoiceUrl?: string | null; invoicePdf?: string | null; + /** Version label of the signed agreement PDF, if one is available to download. */ + signedAgreementVersion?: string | null; + downloadingAgreement?: boolean; + onDownloadSignedAgreement?: () => void; }) { const { t } = useTranslation(); return ( - +
    + + {t("portal.procurement.payment.eyebrow")} +

    {t("portal.procurement.payment.title")}

    {t("portal.procurement.payment.description")}

    - {(invoiceUrl || invoicePdf) && ( -
    - {invoiceUrl && ( - - )} - {invoicePdf && ( - - )} + {(invoiceUrl || invoicePdf || signedAgreementVersion) && ( +
    +
    + {signedAgreementVersion && onDownloadSignedAgreement && ( + + )} + {invoicePdf && ( + + )} + {invoiceUrl && ( + + )} +
    )} - +
    ); } /** The live confirmation once the deal is active. */ -export function LiveStageCard() { +export function LiveStageCard({ + signedAgreementVersion, + downloadingAgreement, + onDownloadSignedAgreement, +}: { + signedAgreementVersion?: string | null; + downloadingAgreement?: boolean; + onDownloadSignedAgreement?: () => void; +} = {}) { const { t } = useTranslation(); return ( - +
    {t("portal.procurement.live.eyebrow")} @@ -65,7 +96,20 @@ export function LiveStageCard() {

    {t("portal.procurement.live.description")}

    - + {signedAgreementVersion && onDownloadSignedAgreement && ( +
    +
    + +
    +
    + )} +
    ); } diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx index b392247563..98fcbd4f04 100644 --- a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx +++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx @@ -3,18 +3,24 @@ import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; import { DocumentsIcon, + DownloadIcon, PoliciesIcon, UsersIcon, } from "@portal/components/icons"; import { money } from "@portal/components/procurement/format"; import { buildQuote, + recordLegalConsent, type QuoteConfigInput, type QuoteResult, } from "@portal/api/procurement"; +import { LegalDocumentModal } from "@portal/components/procurement/ProcurementExtras"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; import "@portal/views/Procurement.css"; -const STEPS = ["volume", "plan", "details"] as const; +const STEPS = ["volume", "plan", "details", "review"] as const; +const DETAILS_STEP = 2; +const REVIEW_STEP = 3; const TERM_DISCOUNT = [0, 0.03, 0.05, 0.06, 0.07]; // 1..5 years — meter-only discount (D71) // Governance posture: the intensity (runs per PDF) fed to the committed-volume curve. const POSTURES = [ @@ -30,22 +36,57 @@ const SIZE_TIERS = [ ] as const; /** - * The enterprise quote builder — volume → commitment & service → details. A client-side preview - * drives the live footer total; the backend is authoritative. Completing the form generates the - * quote directly (build + issue in one step) — the issued quote is then shown as the milestone, so - * there's no redundant in-builder preview. + * The enterprise quote builder — volume → commitment & service → details → review. A client-side + * preview drives the live footer total; the backend is authoritative. Generating builds and issues in + * one go, and the issued quote comes back as the fourth step: the buyer reads the real itemised paper + * and can download it, but does not accept here. Accepting is a decision taken from the deal card, + * deliberately, so circulating the quote internally is not a dead end in a modal. */ export function QuoteBuilder({ deployment, seats = 0, + email, + onClose, + dealDetails, initial, + eulaAlreadyAgreed = false, onGenerate, + issued, + downloading = false, + onDownload, }: { deployment: string; /** Seat count from the trial setup; seeds the users field + volume estimate on a fresh quote. */ seats?: number; + /** Linked-account email; prefills the contact email on a fresh quote's details step. */ + email?: string | null; + /** Dismiss the dialog. The builder draws its own header, so it carries the close too. */ + onClose?: () => void; + /** + * The buying entity captured at trial setup. Seeds a fresh quote's details step so it confirms + * what is already known rather than asking twice; the buyer can still correct it here, since a + * deal can change hands between trial and quote. + */ + dealDetails?: { + businessName?: string | null; + contactName?: string | null; + contactEmail?: string | null; + }; /** Seed the builder from an existing quote's config (re-editing a quote). */ initial?: QuoteConfigInput; + /** + * The issued quote, which is what the review step shows. Its arrival is also what opens that step: + * the parent issues the quote and it lands by snapshot refresh, so there is no synchronous result + * to advance on. Null while re-editing, so editing reopens the form rather than the paper. + */ + issued?: QuoteResult | null; + downloading?: boolean; + onDownload?: () => void; + /** + * The buyer already accepted the EULA (e.g. at trial start). When true, the EULA clickwrap is + * hidden here and no consent is recorded at quote time — it's only collected once. + */ + eulaAlreadyAgreed?: boolean; /** Called with the priced DRAFT quote; the parent issues it as a Stripe Quote. */ onGenerate: (quote: QuoteResult) => void; }) { @@ -65,9 +106,9 @@ export function QuoteBuilder({ indemnification: false, training: false, qbr: false, - businessName: "", - contactName: "", - contactEmail: "", + businessName: dealDetails?.businessName ?? "", + contactName: dealDetails?.contactName ?? "", + contactEmail: dealDetails?.contactEmail ?? email ?? "", addressLine1: "", addressLine2: "", city: "", @@ -79,29 +120,81 @@ export function QuoteBuilder({ ); // A seeded quote carries a volume but no user count, so treat it as manually set. const [manualVolume, setManualVolume] = useState(initial != null); - const [eula, setEula] = useState(initial != null); + // Never pre-ticked, even when re-editing a quote: a consent the buyer did not tick in this session + // is not a consent, and recordLegalConsent would have logged one as though they had. + const [eula, setEula] = useState(false); + const [legalDoc, setLegalDoc] = useState(null); const [busy, setBusy] = useState(false); + // Only surface field errors once the buyer tries to generate — no red fields on first sight. + const [showErrors, setShowErrors] = useState(false); function set(k: K, v: QuoteConfigInput[K]) { setCfg((c) => ({ ...c, [k]: v })); } - // Re-editing an existing quote: everything is seeded, so jump to the last step (details) with the + // Required buyer details before a quote can be generated (Order Form / invoice need these). + const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( + (cfg.contactEmail ?? "").trim(), + ); + const valid = { + businessName: cfg.businessName.trim().length > 0, + contactName: (cfg.contactName ?? "").trim().length > 0, + contactEmail: emailOk, + addressLine1: (cfg.addressLine1 ?? "").trim().length > 0, + city: (cfg.city ?? "").trim().length > 0, + region: (cfg.region ?? "").trim().length > 0, + postalCode: (cfg.postalCode ?? "").trim().length > 0, + }; + const detailsValid = Object.values(valid).every(Boolean); + const eulaOk = eulaAlreadyAgreed || eula; + const canGenerate = detailsValid && eulaOk; + + // Re-editing an existing quote: everything is seeded, so jump to the details step with the // agreement pre-accepted — one click re-generates, or Back to change a field. No walking from step 1. // Mount-only: seed the step from `initial` once (deliberately no deps). useEffect(() => { - if (initial) setStep(STEPS.length - 1); + if (initial) setStep(DETAILS_STEP); }, []); + // Issuing lands by snapshot refresh rather than as a return value, so the arrival of the issued + // quote is what opens the review step. Keyed on the quote's id, not the object: React Query hands + // back a fresh object on every refetch, which would yank a buyer who had walked Back to the form. + useEffect(() => { + if (issued) setStep(REVIEW_STEP); + }, [issued?.quoteId]); + const preview = previewAnnualMinor(cfg); const tcvPreview = preview * cfg.termYears + (cfg.training ? 750_000 : 0); + // On the review step the footer quotes the issued figures rather than the client-side preview, so + // the running total never disagrees with the paper directly above it. + const onPaper = issued != null && step === REVIEW_STEP; + const running = onPaper + ? { + annual: money(issued.annualNetMinor, issued.currency), + years: issued.config.termYears, + tcv: money(issued.tcvMinor, issued.currency), + } + : { + annual: money(preview), + years: cfg.termYears, + tcv: money(tcvPreview), + }; + // Fully filled → price + hand the draft to the parent to issue as a Stripe Quote (which then shows // as the milestone). No separate in-builder preview step. async function generate() { + if (!canGenerate) { + setShowErrors(true); + return; + } setBusy(true); try { - onGenerate(await buildQuote(cfg)); + const quote = await buildQuote(cfg); + // Record the EULA clickwrap only when it's collected here — i.e. the buyer didn't already + // accept it at trial start. Best-effort. + if (!eulaAlreadyAgreed) void recordLegalConsent("eula", "quote"); + onGenerate(quote); } finally { setBusy(false); } @@ -109,22 +202,16 @@ export function QuoteBuilder({ return (
    -
    -

    - {t("portal.procurement.builder.title")} -

    - - {t("portal.procurement.builder.stepOf", { - n: step + 1, - total: STEPS.length, - })} - -
    -
    - {STEPS.map((s, i) => ( - - ))} -
    +
    {step === 0 && ( @@ -171,15 +258,6 @@ export function QuoteBuilder({ ? t("portal.procurement.builder.volManual") : t("portal.procurement.builder.volNoUsers")}

    - - )} - - {step === 1 && ( - } - title={t("portal.procurement.builder.s2Title")} - sub={t("portal.procurement.builder.s2Sub")} - >
    {POSTURES.map((p) => ( @@ -209,7 +287,15 @@ export function QuoteBuilder({ ))}
    +
    + )} + {step === 1 && ( + } + title={t("portal.procurement.builder.s2Title")} + sub={t("portal.procurement.builder.s2Sub")} + >
    {[1, 2, 3, 4, 5].map((y) => ( @@ -287,7 +373,11 @@ export function QuoteBuilder({ sub={t("portal.procurement.builder.s3Sub")} >
    - + set("businessName", e.target.value)} /> - +
    - + set("contactEmail", e.target.value)} /> - +
    - + set("city", e.target.value)} /> - + set("region", e.target.value)} /> - +
    - + {!eulaAlreadyAgreed && ( + + )} + {showErrors && !canGenerate && ( +

    + {t("portal.procurement.builder.completeRequired")} +

    + )} )} + + {/* No step heading here, unlike the form steps: the quote is the content, and a heading over + it only repeats what the paper already says. The real issued figures, not the footer's + client-side preview — this is the document the buyer circulates, so it has to match the + PDF and the Stripe quote exactly. */} + {step === REVIEW_STEP && issued && ( +
    +
    +
    +
    +
    Stirling PDF
    +
    + {t("portal.procurement.builder.paperEyebrow")} +
    +
    +
    +
    + {issued.quoteNumber} +
    + {issued.validUntil && ( +
    + {t("portal.procurement.review.validUntil", { + date: new Date(issued.validUntil).toLocaleDateString(), + })} +
    + )} + {/* On the document rather than in the footer: it downloads this paper, so it + belongs to it, and the footer stays the flow's own Back/Done. */} + +
    +
    + + {issued.config.businessName?.trim() && ( +
    +
    + {t("portal.procurement.builder.paperFor")} +
    +
    + {issued.config.businessName} +
    +
    + )} + +
      + {issued.lineItems.map((li) => ( +
    • + {li.label} + {money(li.amountMinor, issued.currency)} +
    • + ))} +
    + +
    +
    +
    + {t("portal.procurement.review.annual")} +
    +
    + {t("portal.procurement.review.tcv", { + years: issued.config.termYears, + tcv: money(issued.tcvMinor, issued.currency), + })} +
    +
    + {t("portal.procurement.review.renewal", { + amount: money( + issued.renewalAnnualNetMinor, + issued.currency, + ), + pct: issued.cpiRatePct, + })} +
    + {issued.config.poNumber?.trim() && ( +
    + {t("portal.procurement.review.poNumber", { + po: issued.config.poNumber.trim(), + })} +
    + )} +
    +
    + {money(issued.annualNetMinor, issued.currency)} +
    +
    +
    +
    + )}
    - {t("portal.procurement.builder.running", { - annual: money(preview), - years: cfg.termYears, - tcv: money(tcvPreview), - })} + {t("portal.procurement.builder.running", running)}
    {step > 0 && ( @@ -408,7 +630,6 @@ export function QuoteBuilder({ {step === 0 && ( )} {step === 1 && ( - )} - {step === 2 && ( - )} + {/* No Accept here: the review step ends on the deal card, where accepting is one of two + deliberate choices rather than the only way out of a modal. Download lives on the + document itself. */} + {step === REVIEW_STEP && ( + + )}
    + setLegalDoc(null)} />
    ); } @@ -470,14 +690,25 @@ function Step({ function Field({ label, + required, + invalid, children, }: { label: string; + required?: boolean; + invalid?: boolean; children: React.ReactNode; }) { return ( -
    ); diff --git a/frontend/editor/src/portal/views/Procurement.css b/frontend/editor/src/portal/views/Procurement.css index e26fd74a31..b721768952 100644 --- a/frontend/editor/src/portal/views/Procurement.css +++ b/frontend/editor/src/portal/views/Procurement.css @@ -1,34 +1,10 @@ -.portal-proc { - display: flex; - flex-direction: column; - gap: 1.25rem; - padding: 1.5rem; - max-width: 84rem; - margin: 0 auto; -} - -/* Page header */ -.portal-proc__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; -} - -.portal-proc__title { - margin: 0; - font-size: 1.375rem; - font-weight: 600; - color: var(--c-text); -} - .portal-proc__subtitle { margin: 0.25rem 0 0; font-size: 0.8125rem; color: var(--c-text-subtle); } -/* Eyebrow label shared by the journey header + SE block */ +/* Eyebrow label above a stage panel's heading */ .portal-proc__eyebrow { display: block; font-size: 0.6875rem; @@ -38,613 +14,33 @@ color: var(--c-text-subtle); margin-bottom: 0.25rem; } - -/* ── Journey card ─────────────────────────────────────────────────────── */ -/* Stacked, border-divided sections: header / stepper / trial / next step. */ -.portal-proc__journey-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - padding: 1.25rem 1.5rem; - border-bottom: 1px solid var(--c-border-subtle); - flex-wrap: wrap; -} - -.portal-proc__journey-title { - margin: 0; - font-size: 1.0625rem; - font-weight: 700; - color: var(--c-text); -} - -.portal-proc__journey-sub { - margin: 0.25rem 0 0; - max-width: 36rem; - font-size: 0.8125rem; - line-height: 1.5; - color: var(--c-text-subtle); -} - -.portal-proc__se { - display: flex; - flex-direction: column; - text-align: right; - flex-shrink: 0; -} - -.portal-proc__se .portal-proc__eyebrow { - margin-bottom: 0.25rem; -} - -.portal-proc__se-name { - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text); -} - -.portal-proc__se-email { - font-size: 0.75rem; - color: var(--c-primary); - text-decoration: none; -} - -.portal-proc__se-email:hover { - text-decoration: underline; -} - -/* Stepper band */ -.portal-proc__journey-stepper { - padding: 1.125rem 1.5rem; - border-bottom: 1px solid var(--c-border-subtle); -} - -.portal-proc__steps { - display: flex; - align-items: flex-start; -} - -.portal-proc__steps--locked { - opacity: 0.55; - filter: grayscale(0.4); - pointer-events: none; -} - -.portal-proc__step { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.4rem; - min-width: 4rem; -} - -.portal-proc__step-dot { - width: 0.875rem; - height: 0.875rem; - border-radius: 50%; - background: var(--c-border); -} - -.portal-proc__step--complete .portal-proc__step-dot { - background: var(--color-green); -} - -.portal-proc__step--current .portal-proc__step-dot { - background: var(--color-purple); - box-shadow: 0 0 0 4px var(--color-purple-light); -} - -.portal-proc__step-label { - font-size: 0.6875rem; - font-weight: 500; - text-align: center; - white-space: nowrap; - color: var(--c-text-subtle); -} - -.portal-proc__step--complete .portal-proc__step-label { - color: var(--c-text-subtle); -} - -.portal-proc__step--current .portal-proc__step-label { - font-weight: 700; - color: var(--c-text); -} - -/* Connector aligns with the 0.875rem dots: (14px − 2px) / 2 = 6px down */ -.portal-proc__step-line { - flex: 1; - height: 2px; - margin: 0.375rem 0.375rem 0; - background: var(--c-border-subtle); -} - -.portal-proc__step-line[data-filled="true"] { - background: var(--color-green); -} - -/* Trial status strip (shown while evaluating) */ -.portal-proc__trial { - display: flex; - align-items: center; - gap: 0.625rem; - flex-wrap: wrap; - padding: 0.75rem 1.5rem; - border-bottom: 1px solid var(--c-border-subtle); - background: var(--color-bg-code); -} - -.portal-proc__trial-title { - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text); -} - -.portal-proc__trial-dim { - font-size: 0.78125rem; - color: var(--c-text-subtle); -} - -.portal-proc__trial-key { - font-family: - ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; - font-size: 0.6875rem; - color: var(--c-text-subtle); - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: 0.375rem; - padding: 0.1875rem 0.5rem; -} - -/* Next-step row: one primary action at a time */ -.portal-proc__next { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; - padding: 1rem 1.5rem; -} - -.portal-proc__next-label { - display: flex; - align-items: center; - gap: 0.625rem; - font-size: 0.84375rem; - font-weight: 600; - color: var(--c-text); -} - -.portal-proc__next-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; - background: var(--color-amber); - flex-shrink: 0; -} - -.portal-proc__next-dot[data-live="true"] { - background: var(--color-green); -} - -/* ── Documents card ───────────────────────────────────────────────────── */ -.portal-proc__docs-head { - padding: 1.125rem 1.5rem; - border-bottom: 1px solid var(--c-border-subtle); -} - -.portal-proc__docs-title { - margin: 0; - font-size: 0.9375rem; - font-weight: 700; - color: var(--c-text); -} - -.portal-proc__docs-sub { - margin: 0.25rem 0 0; - font-size: 0.78125rem; - color: var(--c-text-subtle); -} - -.portal-proc__docs-body { - padding: 0.375rem 1.5rem 1.125rem; -} - -/* Accordion spacing — the disclosure chrome itself lives in shared Collapsible - (.sui-collapsible); here we only space the stacked sections. */ -.portal-proc__docs-body .sui-collapsible { - margin-top: 0.875rem; -} - -/* Stage header bits */ -.portal-proc__stage-dot { - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; - flex-shrink: 0; -} - -.portal-proc__stage-dot[data-state="done"] { - background: var(--color-green); -} - -.portal-proc__stage-dot[data-state="current"] { - background: var(--color-purple); -} - -.portal-proc__stage-dot[data-state="upcoming"] { - background: var(--c-border); -} - -.portal-proc__stage-label { - font-size: 0.75rem; - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; - color: var(--c-text-subtle); -} - -.portal-proc__stage-label[data-current] { - color: var(--c-text); -} - -.portal-proc__stage-hint { - font-size: 0.71875rem; - color: var(--c-text-subtle); -} - -.portal-proc__stage-count { - font-size: 0.6875rem; - color: var(--c-text-subtle); -} - -/* Document lists inside the accordion */ -.portal-proc__doc-list { - border-top: 1px solid var(--c-border-subtle); -} - -.portal-proc__doc-list--boxed { - border: 1px solid var(--c-border-subtle); - border-radius: 0.5rem; - overflow: hidden; -} - -/* Supporting section — extra separation from the stage accordion above it. - Scoped to match the general .sui-collapsible spacing rule's specificity. */ -.portal-proc__docs-body .portal-proc__supporting-acc { - margin-top: 1.5rem; -} - -.portal-proc__supporting-head { - display: flex; - flex-direction: column; - gap: 0.125rem; - min-width: 0; -} - -.portal-proc__supporting-sub { - font-size: 0.71875rem; - font-weight: 400; - line-height: 1.45; - color: var(--c-text-subtle); -} - -.portal-proc__acc-toggle-label { - font-size: 0.75rem; - font-weight: 600; - color: var(--c-primary); -} - -.portal-proc__supporting-groups { - display: flex; - flex-direction: column; - gap: 1rem; - border-top: 1px solid var(--c-border-subtle); - padding: 0.875rem; -} - -.portal-proc__group-label { - font-size: 0.6875rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--c-text-subtle); - margin-bottom: 0.5rem; -} - -/* ── Document rows (ledger + supporting) ──────────────────────────────── */ -.portal-proc__doc { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.75rem 0.875rem; - border-bottom: 1px solid var(--c-border-subtle); -} - -.portal-proc__doc:last-child { - border-bottom: none; -} - -.portal-proc__doc[data-locked] { - opacity: 0.6; -} - -.portal-proc__doc-text { - min-width: 0; -} - -.portal-proc__doc-name-row { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -.portal-proc__doc-name { - font-size: 0.84375rem; - font-weight: 600; - color: var(--c-text); -} - -.portal-proc__doc-sub { - margin: 0.0625rem 0 0; - font-size: 0.71875rem; - line-height: 1.4; - color: var(--c-text-subtle); -} - -.portal-proc__doc-actions { - display: flex; - align-items: center; - gap: 0.75rem; - flex-shrink: 0; -} - -/* ── Locked state ─────────────────────────────────────────────────────── */ -.portal-proc__locked { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -/* ── Action modal ─────────────────────────────────────────────────────── */ -.portal-proc__modal-body { - margin: 0 0 1rem; - font-size: 0.875rem; - line-height: 1.55; - color: var(--c-text-muted); -} - -.portal-proc__modal-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.625rem; -} - -.portal-proc__upload { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.portal-proc__upload-input { - display: none; -} - -.portal-proc__upload-name { - font-size: 0.75rem; - color: var(--c-text-subtle); -} - -@media (max-width: 48rem) { - .portal-proc__journey-head { - flex-direction: column; - } - - .portal-proc__se { - text-align: left; - } - - .portal-proc__steps { - overflow-x: auto; - } -} - -/* ── Enterprise upsell CTA (Home / Usage on-ramp) ─────────────────────────── */ -.portal-proc__upsell { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; -} -.portal-proc__upsell-badge { - display: inline-block; - font-size: 0.625rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--c-primary); - background: var(--c-primary-subtle); - padding: 0.15rem 0.5rem; - border-radius: 0.375rem; - margin-bottom: 0.4rem; -} -.portal-proc__upsell-copy { - margin: 0; - font-size: 0.8125rem; - line-height: 1.45; - color: var(--c-text-subtle); - max-width: 44rem; -} -.portal-proc__upsell-copy strong { - color: var(--c-text); -} - -/* ── Quote builder ────────────────────────────────────────────────────────── */ -.portal-proc__builder-head { - display: flex; - align-items: baseline; - justify-content: space-between; - margin-bottom: 1rem; -} .portal-proc__builder-title { margin: 0; font-size: 1rem; font-weight: 650; color: var(--c-text); } -.portal-proc__builder-step { - font-size: 0.75rem; - color: var(--c-text-subtle); -} -.portal-proc__builder-body { - display: flex; - flex-direction: column; - gap: 0.85rem; -} -.portal-proc__field { - display: flex; - flex-direction: column; - gap: 0.3rem; - font-size: 0.8125rem; - color: var(--c-text-subtle); -} -.portal-proc__field input, -.portal-proc__field select { - padding: 0.45rem 0.6rem; - border: 1px solid var(--c-border); - border-radius: 0.5rem; - font-size: 0.875rem; - background: var(--c-input-bg); - color: var(--c-text); -} -.portal-proc__builder-addons { - display: flex; - flex-direction: column; - gap: 0.4rem; - font-size: 0.8125rem; - color: var(--c-text-muted); -} -.portal-proc__builder-addons label { - display: flex; - align-items: center; - gap: 0.5rem; -} -.portal-proc__builder-actions { - display: flex; - justify-content: flex-end; - gap: 0.6rem; - margin-top: 0.5rem; -} -.portal-proc__quote-head { - display: flex; - align-items: baseline; - justify-content: space-between; - border-bottom: 1px solid var(--c-border); - padding-bottom: 0.5rem; -} -.portal-proc__quote-number { - font-weight: 650; - color: var(--c-text); -} -.portal-proc__quote-valid { - font-size: 0.75rem; - color: var(--c-text-subtle); -} -.portal-proc__quote-lines { - list-style: none; - margin: 0; - padding: 0; -} -.portal-proc__quote-lines li { - display: flex; - justify-content: space-between; - padding: 0.4rem 0; - font-size: 0.8125rem; - color: var(--c-text-muted); - border-bottom: 1px solid var(--c-border-subtle); -} -.portal-proc__quote-lines li[data-kind="DISCOUNT"] { - color: var(--c-success); -} -.portal-proc__quote-total { - display: flex; - justify-content: space-between; - align-items: baseline; - padding: 0.6rem 0 0.2rem; - font-size: 0.9375rem; -} -.portal-proc__quote-total strong { - font-size: 1.25rem; - color: var(--c-text); -} -.portal-proc__quote-tcv { - font-size: 0.75rem; - color: var(--c-text-subtle); -} /* ══ Quote builder — copied from the marketing prototype (tight density) ═════ */ .portal-qb { - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: 14px; - box-shadow: inset 0 0 0 1px var(--c-border-subtle); - overflow: hidden; -} -.portal-qb__head { display: flex; - align-items: center; - justify-content: space-between; - padding: 18px 24px 14px; - border-bottom: 1px solid var(--c-border-subtle); - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--c-primary) 5.5%, transparent) 0%, - transparent 100% - ); -} -.portal-qb__title { - margin: 0; - font-size: 16px; - font-weight: 700; - color: var(--c-text); -} -.portal-qb__stepchip { - font-size: 11px; - font-weight: 700; - color: var(--c-text-subtle); - background: var(--c-surface-sunken); - padding: 3px 10px; - border-radius: 999px; -} -.portal-qb__progress { - display: flex; - gap: 6px; - padding: 12px 24px 0; -} -.portal-qb__progress span { - flex: 1; - height: 6px; - border-radius: 999px; - background: var(--c-surface-sunken); - transition: background 0.3s; -} -.portal-qb__progress span[data-on] { - background: var(--c-primary); + flex-direction: column; } +/* No padding or scroll of its own: FlowModal's panel supplies both, and nesting a second scroll + container inside a scrolling panel gave the builder two scrollbars. */ +/* This gap is the builder's only vertical rhythm: the blocks inside carry no bottom margins of their + own, so spacing cannot double up the way a margin plus a gap did. The top margin is the breathing + room under the stepped header, which deliberately has no bottom margin so its host sets this. */ .portal-qb__body { - padding: 20px 24px; - max-height: 56vh; - overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1.05rem; } .portal-qb__intro { display: flex; align-items: center; gap: 12px; - margin-bottom: 18px; } .portal-qb__intro-icon { width: 38px; @@ -667,9 +63,10 @@ color: var(--c-text-subtle); margin-top: 1px; } +/* No bottom margin: inside .portal-qb__body the gap spaces these, and inside .portal-qb__row a + margin only reserved dead space under the inputs. */ .portal-qb__field { display: block; - margin-bottom: 18px; } .portal-qb__field-label { display: block; @@ -691,6 +88,18 @@ background: var(--c-input-bg); color: var(--c-text); } +.portal-qb__req { + color: var(--c-danger); +} +.portal-qb__field[data-invalid] input, +.portal-qb__field[data-invalid] select { + border-color: var(--c-danger); +} +.portal-qb__error { + margin: 10px 0 0; + font-size: 12px; + color: var(--c-danger); +} .portal-qb__row { display: flex; gap: 14px; @@ -701,7 +110,7 @@ min-width: 190px; } .portal-qb__hint { - margin: 7px 0 0; + margin: 0; font-size: 11.5px; color: var(--c-text-subtle); line-height: 1.4; @@ -736,11 +145,22 @@ gap: 10px; flex-wrap: wrap; } +/* Short-label options that should sit across one row rather than wrapping 2 + 1. Tighter padding + and label size so each caption fits on a single line at three-across. Deliberately no `nowrap`: + a longer translation should wrap rather than clip or push the card out of the row. */ +.portal-qb__opts--across .portal-qb__opt { + min-width: 0; + padding: 11px 12px; +} +.portal-qb__opts--across .portal-qb__opt-sub { + font-size: 11px; + line-height: 1.35; +} .portal-qb__opt { text-align: left; flex: 1; min-width: 150px; - padding: 12px 14px; + padding: 9px 11px; border-radius: 9px; border: 1px solid var(--c-border); background: var(--c-surface); @@ -834,7 +254,11 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 14px 24px; + /* Bleeds to the panel's edges by reading the shell's own inset, so changing FlowModal's padding + can no longer leave this footer stopping short of them. */ + margin: 0.85rem calc(-1 * var(--flowmodal-inset)) + calc(-1 * var(--flowmodal-body-end)); + padding: 0.8rem var(--flowmodal-inset) 0.9rem; border-top: 1px solid var(--c-border-subtle); flex-wrap: wrap; } @@ -846,13 +270,13 @@ display: flex; gap: 10px; } -/* Step 4 — the itemised quote paper */ +/* Step 4 — the itemised quote paper, on a sunken tray that runs to the panel's edges. No scroll of + its own: the dialog body already scrolls, and nesting a second scroller gave the builder two + scrollbars. The bleed reads the shell's inset rather than hard-coding a copy of it. */ .portal-qb__papertray { background: var(--c-surface-sunken); - padding: 18px; - max-height: 56vh; - overflow-y: auto; - margin: -20px -24px; + padding: 14px var(--flowmodal-inset); + margin: 0 calc(-1 * var(--flowmodal-inset)); } .portal-qb__paper { background: var(--c-surface); @@ -880,6 +304,11 @@ .portal-qb__paper-meta { text-align: right; } +/* Reads as a link on the document rather than a button in a toolbar: right-aligned under the quote's + own metadata, with the row's padding trimmed so it sits tight to the date above it. */ +.portal-qb__paper-download { + margin: 0.2rem -0.5rem -0.25rem 0; +} .portal-qb__quote-number { font-size: 12.5px; font-weight: 700; @@ -955,27 +384,24 @@ margin-top: 3px; } -/* ── Enterprise upsell text wrapper (Home on-ramp) ────────────────────────── */ -.portal-proc__upsell-text { - flex: 1 1 20rem; -} - /* ── Deal-status hero (Home, active deal) ─────────────────────────────────── */ .portal-hero { border: 1px solid var(--c-border); border-radius: 12px; padding: 1.1rem 1.25rem; - background: - radial-gradient( - 120% 140% at 100% 0%, - color-mix(in srgb, var(--c-hue-violet) 8%, transparent), - transparent 55% - ), - var(--c-surface); + background: var(--c-surface); display: flex; flex-direction: column; gap: 1rem; } + +/* Attached under the editor rail the two read as one card, so the hero drops its standalone frame + and lets the footer's top border be the only seam. It keeps the frame on the procurement view, + where it stands alone. */ +.portal-editor-hero__footer .portal-hero { + border: none; + border-radius: 0; +} .portal-hero__top { display: flex; align-items: flex-start; @@ -983,25 +409,56 @@ gap: 1rem; flex-wrap: wrap; } +.portal-hero__ident { + flex: 1; + min-width: 0; +} .portal-hero__eyebrow { display: block; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--c-primary); + color: var(--c-text-subtle); + margin-bottom: 0.55rem; } -.portal-hero__company { - display: block; - font-size: 1.0625rem; - font-weight: 650; +/* The journey band: one segment per stage, filled through the current one. A progress indicator, + so it lives inside the block whose progress it reports and deliberately does not pulse. */ +.portal-hero__bar { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 0.625rem; +} +.portal-hero__bar span { + flex: 1; + height: 6px; + border-radius: 999px; + background: var(--c-hover); + transition: background 0.3s ease; +} +.portal-hero__bar span[data-on] { + background: var(--c-primary); +} +/* The hero's one-line status: bold stage · what it asks, then Next: … */ +.portal-hero__sentence { + margin: 0; + font-size: 0.84375rem; + line-height: 1.5; + color: var(--c-text-muted); +} +.portal-hero__sentence strong { + font-weight: 700; color: var(--c-text); - margin-top: 0.2rem; +} +.portal-hero__sentence-next { + color: var(--c-text-subtle); } .portal-hero__chips { display: flex; gap: 0.4rem; flex-wrap: wrap; + margin-top: 0.5rem; } .portal-hero__chip { font-size: 0.6875rem; @@ -1011,88 +468,74 @@ border-radius: 999px; padding: 0.2rem 0.6rem; } -.portal-hero__stepper { - overflow-x: auto; -} -.portal-hero__next { +/* One action row: the stage's primary CTA leads, quiet icon actions sit beside it. No dividers. */ +.portal-hero__cta { display: flex; align-items: center; - justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; - padding-top: 0.85rem; - border-top: 1px solid var(--c-border-subtle); } -.portal-hero__next-label { +.portal-hero__icons { + display: flex; + align-items: center; + gap: 0.5rem; +} +.portal-hero__iconbtn { + width: 34px; + height: 34px; display: inline-flex; align-items: center; - gap: 0.45rem; - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text-muted); -} -.portal-hero__next-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 999px; - background: var(--c-primary); - box-shadow: 0 0 0 3px var(--c-primary-subtle); -} - -/* ── Full-screen takeover modal (procurement flow) ────────────────────────── */ -.portal-procmodal { - position: fixed; - inset: 0; - z-index: 1000; - display: flex; - align-items: flex-start; justify-content: center; - padding: clamp(0.5rem, 4vh, 3rem) 1rem; - overflow-y: auto; - background: var(--c-overlay); - backdrop-filter: blur(6px) saturate(160%); - -webkit-backdrop-filter: blur(6px) saturate(160%); - animation: portal-procmodal-fade 0.15s ease-out; -} -@keyframes portal-procmodal-fade { - from { - opacity: 0; - } - to { - opacity: 1; - } -} -.portal-procmodal__panel { - position: relative; - width: 100%; - max-width: 62rem; - background: var(--c-surface); + border-radius: 9px; border: 1px solid var(--c-border); - border-radius: 14px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28); - padding: 1.5rem 1.5rem 1.75rem; -} -.portal-procmodal__close { - position: absolute; - top: 0.85rem; - right: 0.85rem; - border: none; - background: var(--c-border-subtle); - color: var(--c-text-subtle); - width: 1.9rem; - height: 1.9rem; - border-radius: 8px; - font-size: 0.85rem; + background: var(--c-surface); + color: var(--c-text-muted); cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; } -.portal-procmodal__close:hover { - background: var(--c-border); +.portal-hero__iconbtn:hover { + background: var(--c-hover); + border-color: var(--c-border-strong); color: var(--c-text); } -.portal-procmodal__header { - margin-bottom: 1.25rem; - padding-right: 2.5rem; +/* Terminal state: the deal is done, so the row reports rather than asks. */ +.portal-hero__live { + display: flex; + align-items: center; + gap: 0.75rem; } +.portal-hero__live-tile { + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: 11px; + display: flex; + align-items: center; + justify-content: center; + /* Holds a CheckIcon, which takes its stroke from `color` and its size from its own prop. */ + color: var(--c-success); + background: var(--c-success-subtle); +} +.portal-hero__live-text { + display: flex; + flex-direction: column; + min-width: 0; +} +.portal-hero__live-title { + font-size: 0.90625rem; + font-weight: 700; + color: var(--c-text); +} +.portal-hero__live-sub { + font-size: 0.78125rem; + color: var(--c-text-subtle); + margin-top: 1px; +} + +/* ── Procurement takeover: heading type only, the shell is the shared Modal ── */ .portal-procmodal__title { margin: 0; font-size: 1.35rem; @@ -1104,15 +547,6 @@ font-size: 0.875rem; color: var(--c-text-subtle); } -.portal-procmodal__body { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -.portal-proc__modal-stepper { - overflow-x: auto; -} .portal-proc__payment-actions { display: flex; gap: 0.6rem; @@ -1124,7 +558,7 @@ padding: 1rem; border: 1px solid var(--c-border); border-radius: 0.6rem; - background: var(--color-surface-2, rgba(0, 0, 0, 0.02)); + background: var(--c-surface-sunken); } .portal-proc__license-label { display: block; @@ -1139,7 +573,7 @@ margin-top: 0.4rem; padding: 0.55rem 0.7rem; border-radius: 0.4rem; - background: var(--color-surface-3, rgba(0, 0, 0, 0.05)); + background: var(--c-surface-raised); font-family: var(--font-mono, monospace); font-size: 0.85rem; word-break: break-all; @@ -1150,45 +584,9 @@ font-size: 0.75rem; color: var(--c-text-subtle, var(--c-text-muted)); } -.portal-proc__milestone-for { - margin: 0.15rem 0 0; - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text-muted); -} -.portal-proc__milestone-lines { - margin: 0.85rem 0 0.5rem; -} -.portal-proc__milestone-totals { - display: flex; - align-items: baseline; - gap: 1rem; - flex-wrap: wrap; - margin: 0.75rem 0 0.25rem; -} -.portal-proc__milestone-annual { - font-size: 1.75rem; - font-weight: 700; - color: var(--c-text); -} -.portal-proc__milestone-annual small { - font-size: 0.8125rem; - font-weight: 500; - color: var(--c-text-subtle); -} -.portal-proc__milestone-tcv { - font-size: 0.8125rem; - color: var(--c-text-subtle); -} /* Hero next-step action row (primary CTA + optional extend-trial). */ -.portal-hero__next-actions { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -} - -/* Hero quick-action chips (clickable pills next to the company name). */ +/* Hero quick-action chips (the trial countdown pill under the stage sentence). */ .portal-hero__chip--action { border: 1px solid var(--c-border); cursor: pointer; @@ -1203,101 +601,8 @@ transform: translateY(-1px); } -/* Hero rollout checklist (trial): the "do this now" setup steps. */ -.portal-hero__checklist { - list-style: none; - margin: 0; - padding: 0; - border-top: 1px solid var(--c-border-subtle); -} -.portal-hero__checklist li { - border-bottom: 1px solid var(--c-border-subtle); -} -.portal-hero__checklist button { - display: flex; - align-items: center; - gap: 0.85rem; - width: 100%; - padding: 0.7rem 0.25rem; - background: none; - border: none; - cursor: pointer; - text-align: left; -} -.portal-hero__checklist button:hover { - background: var(--c-hover, var(--c-border-subtle)); -} -.portal-hero__check-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 999px; - background: var(--c-border); - flex-shrink: 0; -} -.portal-hero__check-text { - flex: 1; - min-width: 0; -} -.portal-hero__check-title { - display: block; - font-size: 0.85rem; - font-weight: 600; - color: var(--c-text); -} -.portal-hero__check-sub { - display: block; - font-size: 0.75rem; - color: var(--c-text-subtle); - margin-top: 0.05rem; -} -.portal-hero__check-pill { - font-size: 0.6875rem; - font-weight: 600; - color: var(--c-text-subtle); - background: var(--c-border-subtle); - border-radius: 999px; - padding: 0.15rem 0.55rem; - flex-shrink: 0; -} - /* ── Side dialogs (Key documents / Schedule a call / Trial) ───────────────── */ -.portal-sidemodal { - position: fixed; - inset: 0; - z-index: 1100; - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; - overflow-y: auto; - background: var(--c-overlay); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - animation: portal-procmodal-fade 0.15s ease-out; - /* Portaled to , outside .portal-scope, so set the portal UI font explicitly. */ - font-family: var(--font-sans); -} -.portal-sidemodal__panel { - position: relative; - width: 100%; - max-width: 30rem; - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: 14px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28); - padding: 1.35rem 1.4rem 1.4rem; - max-height: 86vh; - overflow-y: auto; -} -/* Wide enough for Calendly's two-pane layout (its single-column layout below ~680px inner width is - tall and scrolls); paired with the embed's taller fixed height so the time view needs no scroll. */ -.portal-sidemodal__panel--wide { - max-width: 52rem; -} -.portal-sidemodal__header { - margin-bottom: 1rem; - padding-right: 2rem; -} +/* Chrome and width come from the shared Modal via FlowModal; only type and content live here. */ .portal-sidemodal__title { margin: 0; font-size: 1.05rem; @@ -1316,15 +621,6 @@ color: var(--c-text-subtle); line-height: 1.55; } -.portal-sidemodal__footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - margin-top: 1.1rem; - padding-top: 0.9rem; - border-top: 1px solid var(--c-border-subtle); -} .portal-sidemodal__ghost { border: none; background: none; @@ -1336,63 +632,6 @@ color: var(--c-text-muted); } -/* Key documents ledger. */ -.portal-docs__group + .portal-docs__group { - margin-top: 1rem; -} -.portal-docs__group-title { - font-size: 0.6875rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--c-text-subtle); - margin-bottom: 0.4rem; -} -.portal-docs__list { - list-style: none; - margin: 0; - padding: 0; -} -.portal-docs__row { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.55rem 0; - border-top: 1px solid var(--c-border-subtle); -} -.portal-docs__row-text { - flex: 1; - min-width: 0; -} -.portal-docs__row-name { - display: block; - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text); -} -.portal-docs__row-sub { - display: block; - font-size: 0.72rem; - color: var(--c-text-subtle); - margin-top: 0.05rem; -} -.portal-docs__row-action { - font-size: 0.6875rem; - font-weight: 600; - border-radius: 999px; - padding: 0.2rem 0.6rem; - flex-shrink: 0; - color: var(--c-text-subtle); - background: var(--c-border-subtle); -} -.portal-docs__row-action[data-status="action"] { - color: var(--c-primary); - background: var(--c-primary-subtle); -} -.portal-docs__row-action[data-status="request"] { - color: var(--c-text-subtle); -} - /* Calendly scheduler embed (Schedule a call). The widget is always light (Calendly renders inputs on white), so give it a white surface — it reads as a clean card even inside the dark-mode modal. */ .portal-calendly { @@ -1418,17 +657,173 @@ } /* ── Agreement (security) step ────────────────────────────────────────────── */ -.portal-agreement__doc { - margin: 1rem 0; - max-height: 22rem; - overflow-y: auto; - padding: 1rem 1.1rem; - border: 1px solid var(--c-border); - border-radius: 10px; - background: var(--color-bg-subtle, var(--c-bg)); - font-size: 0.8125rem; - line-height: 1.55; +/* The terminal steps (payment, live): a stacked eyebrow/title/description with the flow's own footer + bar beneath. No card of their own — the dialog is already the surface. */ +.portal-procstage { + display: flex; + flex-direction: column; + gap: 0.4rem; +} +/* Nothing to report on the left of these, so the actions take the whole bar. */ +.portal-procstage__foot { + justify-content: flex-end; +} + +/* The two document actions read as a pair, close together and set apart from the close beside them. */ +.portal-agreement__actions { + display: flex; + align-items: center; + gap: 0.1rem; +} + +/* The signature block: the three fields that name the bound party and its signatory, on the same line + as the act of signing, with the consent directly beneath them and no rule between. Fields shrink + below the row's usual floor so all three plus the button hold one line at the takeover's width; + they wrap rather than clip if a translation runs long. */ +/* No rule above it: the tray's grey ends where the signature block begins, which is boundary enough, + and a border there cut the document off from the page it sits on. */ +.portal-agreement__signbar { + flex-direction: column; + align-items: stretch; + gap: 0.6rem; + border-top: none; +} +/* Bottom-aligned: a field is a label above an input, so aligning to the block's centre or its top + leaves the button off the input row. Sharing the input's bottom edge puts its centre on theirs, both + being 37px. The gate note is deliberately NOT in this row — hanging it off the button would make the + column taller than the fields and drag the button back up off the row. */ +.portal-agreement__signrow { + display: flex; + align-items: flex-end; + gap: 0.9rem; +} +/* Consent on the left, the gate's state on the right so it lands under the button it explains. */ +.portal-agreement__signfoot { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} +.portal-agreement__gate { + flex: 0 0 auto; + font-size: 0.6875rem; color: var(--c-text-subtle); + white-space: nowrap; +} +.portal-agreement__signrow .portal-agreement__signfields { + flex: 1; + gap: 0.6rem; + min-width: 0; +} +.portal-agreement__signrow .portal-qb__field { + min-width: 8.5rem; +} +/* Unboxed: a line of small print under the fields, not a panel competing with the document. */ +.portal-agreement__accept { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.75rem; + line-height: 1.45; + color: var(--c-text-subtle); + cursor: pointer; +} +.portal-agreement__accept input { + margin: 0.1rem 0 0; + flex: 0 0 auto; +} + +/* The agreement is presented as paper, not as app copy in a box: a sunken tray running to the panel's + edges, with the terms on white stock in a serif face. Signing is the most consequential thing a + buyer does here, so the document should read like the document it is. */ +/* The document takes every pixel the dialog can spare: it is what the buyer is here to read, and a + fixed height left it a small white box floating in a tall panel. The chain has to carry the fill — + each link needs min-height:0 or a flex child refuses to shrink below its content. */ +.portal-agreement { + display: flex; + flex-direction: column; + gap: 0.75rem; + flex: 1 1 auto; + min-height: 0; +} +/* The tray is the scroll container, so the paper inside it behaves like a page being scrolled past a + window: flush to the footer while there is more to read, and revealing the tray's bottom padding + only at the end. Scrolling the paper instead left a permanent grey band under it, which read as a + box with contents rather than a document. */ +/* `scroll`, not `auto`, and an explicitly styled bar: signing is gated on reaching the end of the + document, so the buyer has to be able to see there is more of it and how far through they are. + Overlay scrollbars fade out when idle, which hid both. Styling the bar also opts Chromium out of + overlay behaviour, so it stays put. Matches the treatment on the files page. */ +.portal-agreement__tray { + background: var(--c-surface-sunken); + padding: 14px var(--flowmodal-inset); + margin: 0 calc(-1 * var(--flowmodal-inset)); + flex: 1 1 auto; + min-height: 0; + overflow-y: scroll; + scrollbar-width: thin; + scrollbar-color: var(--c-text-subtle) var(--c-border-subtle); +} +.portal-agreement__tray::-webkit-scrollbar { + width: 0.625rem; +} +.portal-agreement__tray::-webkit-scrollbar-track { + background: var(--c-border-subtle); + border-radius: 999px; +} +.portal-agreement__tray::-webkit-scrollbar-thumb { + background: var(--c-text-subtle); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; +} +.portal-agreement__tray::-webkit-scrollbar-thumb:hover { + background: var(--c-text-muted); + background-clip: content-box; +} +.portal-agreement__doc { + /* A floor so a short document still reads as a page; no ceiling, so a long one runs on and the tray + does the scrolling. */ + min-height: 16rem; + padding: 1.35rem 1.6rem; + border: 1px solid var(--c-border-subtle); + border-radius: 12px; + background: var(--c-surface); + box-shadow: var(--shadow-sm); + /* No serif token exists in the theme — the serif is specific to rendering legal terms as paper. */ + font-family: Georgia, "Times New Roman", "Liberation Serif", serif; + font-size: 0.84rem; + line-height: 1.62; + color: var(--c-text-muted); +} +/* Masthead above the document's own heading: confidentiality on the left, the quote reference and + signed version on the right, over the rule that opens the terms. */ +.portal-agreement__letterhead { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + padding-bottom: 0.6rem; + margin-bottom: 1rem; + border-bottom: 2px solid var(--c-text); + font-family: var(--font-sans); + font-size: 0.6875rem; + letter-spacing: 0.04em; + color: var(--c-text-subtle); +} +.portal-agreement__confidential { + font-weight: 700; + text-transform: uppercase; +} +/* The document's own title, centred like an executed agreement's. */ +.portal-agreement__md > h1:first-child, +.portal-agreement__md > h2:first-child { + text-align: center; + font-size: 1.05rem; + letter-spacing: 0.02em; + text-transform: uppercase; + margin-bottom: 1rem; } .portal-agreement__doc h4 { margin: 1rem 0 0.35rem; @@ -1445,29 +840,129 @@ .portal-agreement__doc strong { color: var(--c-text); } -.portal-agreement__accept { - margin-top: 0.25rem; +.portal-agreement__md h1 { + font-size: 0.95rem; + font-weight: 700; + color: var(--c-text); + margin: 1.1rem 0 0.5rem; } -.portal-agreement__lines { - margin: 0.4rem 0 0.6rem; +.portal-agreement__md h2 { + font-size: 0.875rem; + font-weight: 650; + color: var(--c-text); + margin: 1rem 0 0.4rem; } -.portal-proc__reset { - display: flex; - justify-content: center; - padding-top: 0.5rem; +.portal-agreement__md h3 { + font-size: 0.8125rem; + font-weight: 650; + color: var(--c-text); + margin: 0.9rem 0 0.3rem; } -.portal-proc__reset button { +.portal-agreement__md h1:first-child, +.portal-agreement__md h2:first-child { + margin-top: 0; +} +.portal-agreement__md p { + margin: 0 0 0.55rem; +} +.portal-agreement__md strong { + color: var(--c-text); +} +.portal-agreement__md ul { + margin: 0 0 0.6rem; + padding-left: 1.1rem; +} +.portal-agreement__md li { + margin-bottom: 0.2rem; +} +.portal-agreement__md table { + border-collapse: collapse; + width: 100%; + margin: 0.4rem 0 0.8rem; + font-size: 0.78rem; +} +.portal-agreement__md th, +.portal-agreement__md td { + border: 1px solid var(--c-border); + padding: 0.35rem 0.5rem; + text-align: left; + vertical-align: top; +} +.portal-agreement__md th { + background: var(--c-bg); + font-weight: 650; + color: var(--c-text); +} +.portal-agreement__signfields { + margin-top: 0.75rem; +} +.portal-proc__error { + color: var(--c-danger); + font-size: 0.8125rem; + margin: 0.5rem 0 0; +} +.portal-legal__link { border: none; background: none; + padding: 0; + font: inherit; + color: var(--c-text); + text-decoration: underline; + cursor: pointer; +} +.portal-legal__link:hover { + color: var(--c-text); +} +.portal-docmodal { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.portal-docmodal__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 0; + border-bottom: 1px solid var(--c-border); +} +.portal-docmodal__row:last-child { + border-bottom: none; +} +.portal-docmodal__text { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} +.portal-docmodal__name { + font-weight: 650; + font-size: 0.875rem; + color: var(--c-text); +} +.portal-docmodal__sub { + font-size: 0.75rem; + color: var(--c-text-muted); +} +.portal-docmodal__later { + flex-shrink: 0; font-size: 0.75rem; color: var(--c-text-subtle); - cursor: pointer; - text-decoration: underline; + white-space: nowrap; } -.portal-proc__reset button:hover:not(:disabled) { + +/* Title row: the step badge rides beside the heading, not on a line of its own. */ +.portal-sidemodal__title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +/* Quiet hint sat opposite the primary action in a dialog footer. */ +.portal-sidemodal__foot-hint { + font-size: 0.75rem; color: var(--c-text-subtle); } -.portal-proc__reset button:disabled { - opacity: 0.5; - cursor: default; -} diff --git a/frontend/editor/src/portal/views/Procurement.tsx b/frontend/editor/src/portal/views/Procurement.tsx deleted file mode 100644 index 1f6e2329a9..0000000000 --- a/frontend/editor/src/portal/views/Procurement.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ProcurementHome } from "@portal/components/procurement/ProcurementHome"; -import "@portal/views/Procurement.css"; - -/** - * /procurement — procurement is no longer a nav tab; it lives on Home as the deal-status hero. - * This route is kept for deep links (and the Usage "Build your quote" CTA): it renders the same - * surface, opening the takeover modal once a deal is underway. - */ -export function Procurement() { - return ( -
    - -
    - ); -} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index d2853d8f1e..256bdf640a 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -258,12 +258,7 @@ export default defineConfig( // can't represent. Exempt ONLY the raw- ); diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 7d09261226..742334015c 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -81,9 +81,10 @@ const EXPANDED_WIDTH = "16.25rem"; // ~260px const WATCHED_FOLDER_VIEW_ID = "watchedFolder"; const WATCHED_FOLDER_WORKBENCH_ID = "custom:watchedFolder"; -// Stable empty props for rows without folders, so the memoized FileItem -// isn't re-rendered by a fresh `?? []` identity on every list render. +// Stable empty props for rows without folders/policies, so the memoized +// FileItem isn't re-rendered by a fresh `?? []` identity on every list render. const NO_FOLDERS: never[] = []; +const NO_POLICIES: never[] = []; /** Only surface the "Adding files…" progress row for drops big enough that the * pre-dispatch scan is user-visible; small adds finish before it would paint. */ @@ -790,7 +791,7 @@ const FileSidebar = forwardRef( onDragStart={handleWatchedFolderDragStart} folders={memberFolders} onFolderClick={openWatchedFolder} - policies={policyFileBadges.get(stub.id as string) ?? []} + policies={policyFileBadges.get(stub.id as string) ?? NO_POLICIES} onDelete={isWatchedFoldersActive ? undefined : handleSidebarDelete} onSaveToCloud={isWatchedFoldersActive ? undefined : handleSaveToCloud} canSaveToCloud={storageEnabled && fileOrigin !== "shared-with-me"} diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index a289a245f1..c1a93594c5 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -167,7 +167,9 @@ export interface FileItemProps { const MAX_VISIBLE_FOLDER_TAGS = 2; -export function FileItem({ +// Memoized: sidebar rows bail out unless THEIR props change, so one file's +// update (e.g. a new version landing) re-renders one row, not the whole list. +export const FileItem = React.memo(function FileItem({ fileId, name, size, @@ -509,4 +511,4 @@ export function FileItem({ )} ); -} +}); diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.css b/frontend/editor/src/core/components/shared/PolicyBadges.css index 3a919e24c9..8526de44c6 100644 --- a/frontend/editor/src/core/components/shared/PolicyBadges.css +++ b/frontend/editor/src/core/components/shared/PolicyBadges.css @@ -41,22 +41,3 @@ transform: rotate(360deg); } } - -.policy-badge--recent { - animation: policy-badge-pulse 4.5s ease-in-out forwards; -} -@keyframes policy-badge-pulse { - 0%, - 24%, - 48% { - box-shadow: 0 0 0 0 transparent; - } - 12%, - 36% { - box-shadow: 0 0 5px 2px currentColor; - } - 60%, - 100% { - box-shadow: 0 0 0 0 transparent; - } -} diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx b/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx index 81969bf615..c3646f774a 100644 --- a/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx +++ b/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx @@ -2,10 +2,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { PolicyBadges } from "@app/components/shared/PolicyBadges"; import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; +// Real catalog category ids, so each badge renders its own shared glyph +// (policyCategoryIcon) rather than the unknown-category fallback. Accents mirror +// policyAccentVar's mapping — that lives in the proprietary layer, which a core +// story can't import. const mockPolicies: FileItemPolicyRef[] = [ - { id: "policy-1", name: "Redact PII", accentColor: "#e03131", recent: true }, - { id: "policy-2", name: "Sanitize", accentColor: "#2f9e44", recent: false }, - { id: "policy-3", name: "Watermark", accentColor: "#4263eb", recent: false }, + { id: "security", name: "Redact PII", accentColor: "var(--color-purple)" }, + { id: "compliance", name: "Sanitize", accentColor: "var(--color-green)" }, + { id: "ingestion", name: "Watermark", accentColor: "var(--color-blue)" }, ]; const meta = { @@ -22,15 +26,25 @@ export const Default: Story = { }, }; +/** A blocking policy mid-run: spinner, and the file's exit points are gated. */ export const Enforcing: Story = { + args: { + policies: [ + { ...mockPolicies[0], enforcing: true }, + ...mockPolicies.slice(1), + ], + }, +}; + +/** A non-blocking run (classification tagging): same spinner, nothing gated. */ +export const Background: Story = { args: { policies: [ { - id: "policy-1", - name: "Redact PII", - accentColor: "#e03131", - recent: false, - enforcing: true, + id: "classification", + name: "Classification", + accentColor: "var(--color-orange)", + background: true, }, ...mockPolicies.slice(1), ], diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.tsx b/frontend/editor/src/core/components/shared/PolicyBadges.tsx index f8661b9176..1fa57ca70a 100644 --- a/frontend/editor/src/core/components/shared/PolicyBadges.tsx +++ b/frontend/editor/src/core/components/shared/PolicyBadges.tsx @@ -1,6 +1,6 @@ import { Tooltip } from "@mantine/core"; -import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import AutorenewIcon from "@mui/icons-material/Autorenew"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; import { useTranslation } from "react-i18next"; import "@app/components/shared/PolicyBadges.css"; @@ -10,20 +10,20 @@ export interface FileItemPolicyRef { name: string; /** CSS colour for the badge (matches the policy's accent). */ accentColor: string; - /** True only just after the policy was applied — drives the one-off glow, so - * it doesn't replay on every reload of an already-enforced file. */ - recent: boolean; - /** True while the policy run is actively in-flight on this file. */ + /** True while a BLOCKING policy run is in-flight on this file (gates actions). */ enforcing?: boolean; + /** True while a non-blocking run (e.g. classification) is in-flight — shows + * the same spinner but never gates anything. */ + background?: boolean; } const MAX_VISIBLE = 3; /** - * The canonical policy badge row: one accent-tinted shield per policy that has - * run on a file, spinning while a run is in flight, glowing briefly after it - * lands. Every surface that shows per-file policy badges (file sidebar, file - * editor thumbnails, files page) renders this so they stay identical. + * The canonical policy badge row: one accent-tinted category icon per policy + * that has run on a file, spinning while a run is in flight. Every surface that + * shows per-file policy badges (file sidebar, file editor thumbnails, files + * page) renders this so they stay identical. */ export function PolicyBadges({ policies, @@ -40,33 +40,40 @@ export function PolicyBadges({ className={`policy-badges${className ? ` ${className}` : ""}`} data-no-select > - {policies.slice(0, MAX_VISIBLE).map((policy) => ( - - { + const running = policy.enforcing || policy.background; + return ( + - {policy.enforcing ? ( - - ) : ( - - )} - - - ))} + + {running ? ( + + ) : ( + policyCategoryIcon(policy.id, { fontSize: "0.7rem" }) + )} + + + ); + })} ); } diff --git a/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx b/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx index cb9e931e80..9d5d2475b3 100644 --- a/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx +++ b/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx @@ -4,6 +4,8 @@ export function PolicyEnforcingOverlay(_props: { zIndex?: number; /** CSS colour var for the enforcing policy's accent; tints the icon/spinner. */ accentVar?: string; + /** Category of the enforcing policy — picks its icon in the real overlay. */ + categoryId?: string; }) { return null; } diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 5cc1e6cabd..55ca01d307 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -19,7 +19,8 @@ import { } from "@app/components/filesPage/filesPageReturnRoute"; import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext"; import { - useFileState, + useAllFiles, + useFileSelectors, useFileSelection, useFileActions, } from "@app/contexts/FileContext"; @@ -121,10 +122,10 @@ export default function WorkbenchBar({ const { sharingEnabled } = useSharingEnabled(); const viewerContext = React.useContext(ViewerContext); - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const { selectedFiles, selectedFileIds } = useFileSelection(); const { actions: fileActions } = useFileActions(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const { activeFileId, setActiveFileId } = useViewer(); const policyFileBadges = usePolicyFileBadges(); // Block print/export while any file the export would touch is under active diff --git a/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx b/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx index cc33ccbbb4..fe4a6f8e48 100644 --- a/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx +++ b/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx @@ -11,7 +11,7 @@ import { } from "@app/utils/convertUtils"; import { getConversionEndpoints } from "@app/data/toolsTaxonomy"; import { useFileSelection } from "@app/contexts/FileContext"; -import { useFileState } from "@app/contexts/FileContext"; +import { useFileSelector, useFileSelectors } from "@app/contexts/FileContext"; import { detectFileExtension } from "@app/utils/fileUtils"; import { usePreferences } from "@app/contexts/PreferencesContext"; import { useConversionCloudStatus } from "@app/hooks/useConversionCloudStatus"; @@ -62,8 +62,8 @@ const ConvertSettings = ({ const { t } = useTranslation(); const theme = useMantineTheme(); const { setSelectedFiles } = useFileSelection(); - const { state, selectors } = useFileState(); - const activeFiles = state.files.ids; + const selectors = useFileSelectors(); + const activeFiles = useFileSelector((s) => s.files.ids); const { preferences } = usePreferences(); const allEndpoints = useMemo(() => { diff --git a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx index 8c89029913..7918bac7ca 100644 --- a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx @@ -11,7 +11,7 @@ import { Tooltip } from "@app/components/shared/Tooltip"; import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; import { saveOperationResults } from "@app/services/operationResultsSaveService"; -import { useFileActions, useFileState } from "@app/contexts/FileContext"; +import { useFileActions, useFileSelectors } from "@app/contexts/FileContext"; import { FileId } from "@app/types/fileContext"; import i18n from "@app/i18n"; @@ -40,7 +40,7 @@ function ReviewStepContent({ const DownloadIcon = icons.download; const stepRef = useRef(null); const { actions: fileActions } = useFileActions(); - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const handleUndo = async () => { try { diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index cc4a04598e..be4a4a9971 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -12,7 +12,12 @@ import { ActionIcon } from "@app/ui/ActionIcon"; import CloseIcon from "@mui/icons-material/Close"; import LockIcon from "@mui/icons-material/Lock"; -import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { + useAllFiles, + useFileSelector, + useFileSelectors, + useFileActions, +} from "@app/contexts/FileContext"; import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; import { useViewer } from "@app/contexts/ViewerContext"; import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF"; @@ -259,9 +264,9 @@ const EmbedPdfViewerContent = ({ const redactionTrackerRef = useRef(null); // Get current file from FileContext - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const { actions } = useFileActions(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const activeFilesRef = useRef(activeFiles); activeFilesRef.current = activeFiles; const activeFileIds = activeFiles.map((f) => f.fileId); @@ -392,11 +397,11 @@ const EmbedPdfViewerContent = ({ }, [previewFile, fileWithUrl]); // Check if the current file is encrypted (gate the viewer to prevent PDFium crash) - const isCurrentFileEncrypted = React.useMemo(() => { - if (!currentFile || !isStirlingFile(currentFile)) return false; - const stub = selectors.getStirlingFileStub(currentFile.fileId); - return stub?.processedFile?.isEncrypted === true; - }, [currentFile, selectors]); + const isCurrentFileEncrypted = useFileSelector((s) => + currentFile && isStirlingFile(currentFile) + ? s.files.byId[currentFile.fileId]?.processedFile?.isEncrypted === true + : false, + ); const bookmarkCacheKey = React.useMemo(() => { if (currentFile && isStirlingFile(currentFile)) { diff --git a/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx b/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx index ed4a2e2263..bd2704963a 100644 --- a/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx @@ -4,7 +4,7 @@ import { Button } from "@app/ui/Button"; import ArticleIcon from "@mui/icons-material/Article"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { @@ -126,8 +126,7 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) { // ─── Wrapper that resolves the active file from FileContext ─────────────────── export function NonPdfViewerWrapper(props: ViewerProps) { - const { selectors } = useFileState(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const { activeFileIndex } = useViewer(); const file = diff --git a/frontend/editor/src/core/components/viewer/Viewer.tsx b/frontend/editor/src/core/components/viewer/Viewer.tsx index 08103ca1f5..36a1080dbb 100644 --- a/frontend/editor/src/core/components/viewer/Viewer.tsx +++ b/frontend/editor/src/core/components/viewer/Viewer.tsx @@ -5,7 +5,7 @@ import { NonPdfViewerWrapper, type ViewerProps, } from "@app/components/viewer/NonPdfViewer"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { isStirlingFile } from "@app/types/fileContext"; import { isPdfFile } from "@app/utils/fileUtils"; @@ -26,8 +26,7 @@ type SignatureOverlayPassThrough = Pick< >; const Viewer = (props: ViewerProps & SignatureOverlayPassThrough) => { - const { selectors } = useFileState(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const { activeFileId } = useViewer(); // Determine the active file — previewFile takes priority, then look up by stable ID diff --git a/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx b/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx index 32b4b1ce11..fda6c2d2a5 100644 --- a/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx +++ b/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx @@ -5,7 +5,11 @@ import { ActionIcon } from "@app/ui/ActionIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; import { ViewerContext } from "@app/contexts/ViewerContext"; import { useSignature } from "@app/contexts/SignatureContext"; -import { useFileState, useFileContext } from "@app/contexts/FileContext"; +import { + useAllFiles, + useFileSelectors, + useFileContext, +} from "@app/contexts/FileContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { useNavigationState, @@ -39,9 +43,9 @@ export default function ViewerAnnotationControls({ const { historyApiRef, isPlacementMode } = useSignature(); // File state for save functionality - const { state, selectors } = useFileState(); + const selectors = useFileSelectors(); + const { files: activeFiles, fileIds } = useAllFiles(); const { actions: fileActions } = useFileContext(); - const activeFiles = selectors.getFiles(); // Check if we're in sign mode or redaction mode const { selectedTool } = useNavigationState(); @@ -83,7 +87,7 @@ export default function ViewerAnnotationControls({ !historyApiRef?.current?.canUndo() ) return; - if (activeFiles.length === 0 || state.files.ids.length === 0) return; + if (activeFiles.length === 0 || fileIds.length === 0) return; try { const arrayBuffer = await viewerContext.exportActions.saveAsCopy(); @@ -92,7 +96,7 @@ export default function ViewerAnnotationControls({ const file = new File([new Blob([arrayBuffer])], activeFiles[0].name, { type: "application/pdf", }); - const parentStub = selectors.getStirlingFileStub(state.files.ids[0]); + const parentStub = selectors.getStirlingFileStub(fileIds[0]); if (!parentStub) return; const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( @@ -100,11 +104,7 @@ export default function ViewerAnnotationControls({ parentStub, "redact", ); - await fileActions.consumeFiles( - [state.files.ids[0]], - stirlingFiles, - stubs, - ); + await fileActions.consumeFiles([fileIds[0]], stirlingFiles, stubs); // Clear unsaved changes flags after successful save setHasUnsavedChanges(false); diff --git a/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx b/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx index f063cbffe2..4ef4b30cda 100644 --- a/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx +++ b/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx @@ -9,7 +9,7 @@ import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import { Tooltip } from "@app/components/shared/Tooltip"; import ShareManagementModal from "@app/components/shared/ShareManagementModal"; import { useViewer } from "@app/contexts/ViewerContext"; -import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { useAllFiles, useFileActions } from "@app/contexts/FileContext"; import { uploadHistoryChain } from "@app/services/serverStorageUpload"; import { fileStorage } from "@app/services/fileStorage"; import { alert } from "@app/components/toast"; @@ -39,7 +39,7 @@ export default function ViewerShareButton({ }: ViewerShareButtonProps) { const { t } = useTranslation(); const { activeFileId } = useViewer(); - const { selectors } = useFileState(); + const { fileStubs } = useAllFiles(); const { actions } = useFileActions(); const [confirmOpen, setConfirmOpen] = useState(false); const [saving, setSaving] = useState(false); @@ -49,7 +49,7 @@ export default function ViewerShareButton({ // Resolve strictly to the file shown in the viewer. Never fall back to an // arbitrary file — sharing the wrong document would be worse than not // sharing. If there's no active file, the button is disabled (see isDisabled). - const stubs = selectors.getStirlingFileStubs(); + const stubs = fileStubs; const stub = activeFileId ? stubs.find((s) => s.id === activeFileId) : undefined; diff --git a/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx b/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx index 2501ddfb79..0cb11837cc 100644 --- a/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx +++ b/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx @@ -3,7 +3,7 @@ import { useZoom, ZoomMode } from "@embedpdf/plugin-zoom/react"; import { useSpread, SpreadMode } from "@embedpdf/plugin-spread/react"; import { useViewer } from "@app/contexts/ViewerContext"; import { useActiveDocumentId } from "@app/components/viewer/useActiveDocumentId"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { determineAutoZoom, DEFAULT_FALLBACK_ZOOM, @@ -36,7 +36,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { const { provides: zoom, state: zoomState } = useZoom(documentId); const { spreadMode } = useSpread(documentId); const { registerBridge, triggerImmediateZoomUpdate } = useViewer(); - const { selectors } = useFileState(); + const { fileStubs } = useAllFiles(); const hasSetInitialZoom = useRef(false); const lastSpreadMode = useRef(spreadMode ?? SpreadMode.None); @@ -62,7 +62,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { } }, []); - const stubs = selectors.getStirlingFileStubs(); + const stubs = fileStubs; const firstFileStub = stubs[0]; const firstFileId = firstFileStub?.id; diff --git a/frontend/editor/src/core/components/viewer/useViewerReadAloud.ts b/frontend/editor/src/core/components/viewer/useViewerReadAloud.ts index c2ccdec1be..5352ffc748 100644 --- a/frontend/editor/src/core/components/viewer/useViewerReadAloud.ts +++ b/frontend/editor/src/core/components/viewer/useViewerReadAloud.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { computeReadAloudHighlightRect } from "@app/components/viewer/readAloudHighlight"; -import { useFileState } from "@app/contexts/FileContext"; +import { useFileSelectors } from "@app/contexts/FileContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useStopReadAloudOnNavigation } from "@app/components/viewer/useStopReadAloudOnNavigation"; import { pdfWorkerManager } from "@app/services/pdfWorkerManager"; @@ -60,7 +60,7 @@ function createHighlightElement( export function useViewerReadAloud(defaultLanguage?: string) { const viewer = useViewer(); - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const [isReadingAloud, setIsReadingAloud] = useState(false); const [speechRate, setSpeechRate] = useState(1); diff --git a/frontend/editor/src/core/contexts/FileContext.tsx b/frontend/editor/src/core/contexts/FileContext.tsx index d0808665cd..a982c347ca 100644 --- a/frontend/editor/src/core/contexts/FileContext.tsx +++ b/frontend/editor/src/core/contexts/FileContext.tsx @@ -16,6 +16,7 @@ import { useReducer, useCallback, useEffect, + useLayoutEffect, useRef, useMemo, useState, @@ -23,7 +24,6 @@ import { import { FileContextProviderProps, FileContextSelectors, - FileContextStateValue, FileContextActionsValue, FileContextActions, FileId, @@ -36,6 +36,7 @@ import { import { fileContextReducer, initialFileContextState, + withReducerIdentityGuard, } from "@app/contexts/file/FileReducer"; import { createFileSelectors } from "@app/contexts/file/fileSelectors"; import { @@ -49,8 +50,9 @@ import { } from "@app/contexts/file/fileActions"; import { FileLifecycleManager } from "@app/contexts/file/lifecycle"; import { - FileStateContext, + FileStoreContext, FileActionsContext, + type FileStateStore, } from "@app/contexts/file/contexts"; import { IndexedDBProvider, @@ -75,10 +77,13 @@ function FileContextInner({ children, enablePersistence = true, }: FileContextProviderProps) { - const [state, dispatch] = useReducer( - fileContextReducer, - initialFileContextState, + // Guarded in dev: warns if a reducer case reallocates a slice without changing + // it, which would silently defeat the selector-subscription bail-out. + const guardedReducer = useMemo( + () => withReducerIdentityGuard(fileContextReducer), + [], ); + const [state, dispatch] = useReducer(guardedReducer, initialFileContextState); // Always call the hook unconditionally to satisfy React's rules of hooks. // IndexedDB context is only used when enablePersistence is true. @@ -657,14 +662,28 @@ function FileContextInner({ ], ); - // Split context values to minimize re-renders - const stateValue = useMemo( + // Subscription store bridge: the context value is STABLE, so consumers only + // re-render when the slice they select (via useFileSelector) changes — not on + // every state change. Listeners are notified after each committed state. + const listenersRef = useRef void>>(new Set()); + const store = useMemo( () => ({ - state, + getState: () => stateRef.current, + subscribe: (listener) => { + listenersRef.current.add(listener); + return () => { + listenersRef.current.delete(listener); + }; + }, selectors, }), - [state, selectors], + [selectors], ); + // Layout effect (not passive): subscribers re-render before the browser + // paints, so a state change can never show a frame with stale consumers. + useLayoutEffect(() => { + for (const listener of listenersRef.current) listener(); + }, [state]); const actionsValue = useMemo( () => ({ @@ -698,7 +717,7 @@ function FileContextInner({ }, [lifecycleManager]); return ( - + {children} - + ); } @@ -758,6 +777,10 @@ export function FileContextProvider({ export { useFileState, useFileActions, + useFileSelector, + useFileSelectors, + useFileIndex, + shallowEqual, useCurrentFile, useFileSelection, useFileManagement, diff --git a/frontend/editor/src/core/contexts/ViewerContext.tsx b/frontend/editor/src/core/contexts/ViewerContext.tsx index 22d95606c1..5e5d08dbb4 100644 --- a/frontend/editor/src/core/contexts/ViewerContext.tsx +++ b/frontend/editor/src/core/contexts/ViewerContext.tsx @@ -9,7 +9,11 @@ import React, { useCallback, } from "react"; import { useNavigation } from "@app/contexts/NavigationContext"; -import { useFileState } from "@app/contexts/FileContext"; +import { + useFileIndex, + useFileSelector, + useFileSelectors, +} from "@app/contexts/FileContext"; import { isStirlingFile } from "@app/types/fileContext"; import type { FileId } from "@app/types/file"; import { enforceExportPolicies } from "@app/services/policyExport"; @@ -244,25 +248,21 @@ export const ViewerProvider: React.FC = ({ children }) => { const [activeFileId, setActiveFileId] = useState(null); // activeFileIndex is derived from activeFileId so they can never desync. - // ViewerProvider sits inside FileContextProvider so useFileState is valid here. - const { selectors, state } = useFileState(); + // ViewerProvider sits inside FileContextProvider so these hooks are valid here. + const selectors = useFileSelectors(); + const fileIds = useFileSelector((s) => s.files.ids); // Clear activeFileId when its file is removed from the workbench. // Dep on state.files.ids so the effect re-runs on every add/remove. useEffect(() => { if (!activeFileId) return; - const stillInWorkbench = state.files.ids.some( + const stillInWorkbench = fileIds.some( (id) => (id as string) === activeFileId, ); if (!stillInWorkbench) setActiveFileId(null); - }, [activeFileId, state.files.ids]); + }, [activeFileId, fileIds]); - const activeFileIndex = useMemo(() => { - if (!activeFileId) return 0; - const files = selectors.getFiles(); - const idx = files.findIndex((f) => f.fileId === activeFileId); - return idx >= 0 ? idx : 0; - }, [activeFileId, selectors]); + const activeFileIndex = useFileIndex(activeFileId); const setActiveFileIndex = useCallback( (index: number) => { const files = selectors.getFiles(); diff --git a/frontend/editor/src/core/contexts/file/FileReducer.ts b/frontend/editor/src/core/contexts/file/FileReducer.ts index 91bd62cf4d..2697974823 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.ts @@ -425,3 +425,83 @@ export function fileContextReducer( return state; } } + +// ── Dev-only structural-sharing guard ────────────────────────────────────── +// +// The file hooks bail a consumer out of re-rendering when the slice it selects +// keeps its object identity across a dispatch. That optimisation silently +// breaks if a reducer case returns a NEW identity for a slice it didn't +// actually change (e.g. an unnecessary `{ ...state.files }`): every consumer of +// that slice re-renders for nothing, with no test failure. This wrapper warns +// when that happens. No-op in production. + +function idsUnchanged(a: FileId[], b: FileId[]): boolean { + return a.length === b.length && a.every((id, i) => id === b[i]); +} + +function byIdUnchanged( + a: Record, + b: Record, +): boolean { + const keysA = Object.keys(a); + return ( + keysA.length === Object.keys(b).length && + keysA.every((id) => a[id as FileId] === b[id as FileId]) + ); +} + +function uiUnchanged( + a: FileContextState["ui"], + b: FileContextState["ui"], +): boolean { + return (Object.keys(a) as Array).every( + (k) => a[k] === b[k], + ); +} + +function setUnchanged(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +} + +/** + * Wrap a reducer so, outside production, it warns when an action reallocates a + * top-level state slice without changing its contents — which would defeat the + * selector-subscription bail-out in the file hooks. + */ +export function withReducerIdentityGuard( + reducer: (s: FileContextState, a: FileContextAction) => FileContextState, +): (s: FileContextState, a: FileContextAction) => FileContextState { + if (process.env.NODE_ENV === "production") return reducer; + return (state, action) => { + const next = reducer(state, action); + if (next === state) return next; + if ( + next.files !== state.files && + idsUnchanged(next.files.ids, state.files.ids) && + byIdUnchanged(next.files.byId, state.files.byId) + ) { + console.error( + `[FileReducer] '${action.type}' reallocated state.files without changing it — ` + + "this re-renders every file consumer for nothing. Return the existing slice unchanged.", + ); + } + if (next.ui !== state.ui && uiUnchanged(next.ui, state.ui)) { + console.error( + `[FileReducer] '${action.type}' reallocated state.ui without changing it — ` + + "this re-renders every UI consumer for nothing. Return the existing slice unchanged.", + ); + } + if ( + next.pinnedFiles !== state.pinnedFiles && + setUnchanged(next.pinnedFiles, state.pinnedFiles) + ) { + console.error( + `[FileReducer] '${action.type}' reallocated state.pinnedFiles without changing it — ` + + "this re-renders every pinned-files consumer for nothing.", + ); + } + return next; + }; +} diff --git a/frontend/editor/src/core/contexts/file/classificationToolRace.test.ts b/frontend/editor/src/core/contexts/file/classificationToolRace.test.ts new file mode 100644 index 0000000000..faf6501a0c --- /dev/null +++ b/frontend/editor/src/core/contexts/file/classificationToolRace.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; +import { fileContextReducer } from "@app/contexts/file/FileReducer"; +import type { + FileContextAction, + FileContextState, + StirlingFileStub, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +/** + * Classification is non-blocking: while it runs, the user can manually run a + * tool on the same file. Classification's only write is a metadata-only, + * shallow-merged UPDATE_FILE_RECORD stamping `classificationLabels`; a manual + * tool run produces a NEW document via CONSUME_FILES (new id + version). These + * tests drive the REAL reducer through every interleaving (classification lands + * before / during / after the tool run) and prove the invariant the design + * relies on: the tool's output document is byte-for-byte what the tool produced, + * regardless of when classification lands. (Label PLACEMENT in the mid-run race + * is the orchestration's job — usePolicyAutoRun resolves targets at write time; + * see usePolicyAutoRun.race.test.tsx. Here we lock the reducer backstop.) + */ + +const stub = ( + id: string, + extra: Partial = {}, +): StirlingFileStub => + ({ + id: id as FileId, + name: "doc.pdf", + versionNumber: 1, + ...extra, + }) as StirlingFileStub; + +function stateWith(...stubs: StirlingFileStub[]): FileContextState { + return { + files: { + ids: stubs.map((s) => s.id), + byId: Object.fromEntries(stubs.map((s) => [s.id, s])) as Record< + FileId, + StirlingFileStub + >, + }, + pinnedFiles: new Set(), + ui: { + selectedFileIds: [], + selectedPageNumbers: [], + isProcessing: false, + processingProgress: 0, + hasUnsavedChanges: false, + errorFileIds: [], + }, + }; +} + +const LABELS = ["Invoice"]; + +// A manual tool run on `inputId` producing a new versioned document `outputId`. +// Mirrors what useToolOperation dispatches: the reducer stamps provenance +// (derivedFromTool, sourceFileIds) and inherits labels itself. +const toolRun = (inputId: string, outputId: string): FileContextAction => ({ + type: "CONSUME_FILES", + payload: { + inputFileIds: [inputId as FileId], + outputStirlingFileStubs: [stub(outputId, { versionNumber: 2 })], + silent: false, + }, +}); + +// Classification stamping labels onto a target id (the reducer merges shallowly). +const classify = (targetId: string): FileContextAction => ({ + type: "UPDATE_FILE_RECORD", + payload: { + id: targetId as FileId, + updates: { classificationLabels: LABELS }, + }, +}); + +describe("classification landing vs a manually-run tool", () => { + it("PRE: classification lands first — tool output is correct AND inherits the label", () => { + let s = stateWith(stub("orig")); + s = fileContextReducer(s, classify("orig")); + s = fileContextReducer(s, toolRun("orig", "out")); + + const out = s.files.byId["out" as FileId]; + expect(out).toBeDefined(); + expect(out.versionNumber).toBe(2); // the document the tool produced + expect(s.files.byId["orig" as FileId]).toBeUndefined(); // input consumed + // Label carried forward onto the tool's new version. + expect(out.classificationLabels).toEqual(LABELS); + }); + + it("POST: classification lands after the tool run, targeting the new leaf — output untouched, label applied, nothing else clobbered", () => { + let s = stateWith(stub("orig")); + s = fileContextReducer(s, toolRun("orig", "out")); + + const before = s.files.byId["out" as FileId]; + // classificationLabelTargets resolves the run's descendants: "out" matches + // because its sourceFileIds includes "orig". + expect(before.sourceFileIds).toContain("orig" as FileId); + + s = fileContextReducer(s, classify("out")); + const after = s.files.byId["out" as FileId]; + + // The label write is a shallow merge: ONLY classificationLabels changes. + expect(after.classificationLabels).toEqual(LABELS); + expect({ ...after, classificationLabels: undefined }).toEqual({ + ...before, + classificationLabels: undefined, + }); + expect(after.versionNumber).toBe(2); + }); + + it("MID (the race): a label write aimed at an already-consumed id no-ops — output document is CORRECT, nothing is resurrected", () => { + // In production this stale-id write no longer happens: usePolicyAutoRun + // resolves the label targets AT WRITE TIME, so the labels land on the live + // leaf instead (see usePolicyAutoRun.race.test.tsx). This test locks the + // reducer-level BACKSTOP behind that: even if a stale id does get written, + // it cannot corrupt or resurrect anything. + let s = stateWith(stub("orig")); + + const staleTargetId = "orig"; + + // During that window the user runs a tool: orig -> out. orig had no labels + // yet, so the new leaf inherits none. + s = fileContextReducer(s, toolRun("orig", "out")); + const out = s.files.byId["out" as FileId]; + expect(out.versionNumber).toBe(2); + expect(out.classificationLabels).toBeUndefined(); + + // Classification's write finally lands — on the now-consumed snapshot id. + const beforeWrite = s; + s = fileContextReducer(s, classify(staleTargetId)); + + // No-op on a missing record: reducer returns the SAME state reference, so no + // zombie "orig" record is resurrected and nothing is corrupted. + expect(s).toBe(beforeWrite); + expect(s.files.byId["orig" as FileId]).toBeUndefined(); + + // The tool's output document is intact and exactly what the tool produced. + const finalOut = s.files.byId["out" as FileId]; + expect(finalOut.versionNumber).toBe(2); + expect(finalOut.sourceFileIds).toContain("orig" as FileId); + // At the reducer level the stale write leaves the leaf unlabelled — which + // is why the orchestration resolves targets at write time instead. The + // DOCUMENT is unaffected either way. + expect(finalOut.classificationLabels).toBeUndefined(); + }); + + it("classification can never overwrite a tool output's document fields (only the label)", () => { + // Tool output already carries its own state; classification must not disturb it. + let s = stateWith( + stub("out", { + versionNumber: 7, + thumbnailUrl: "blob:thumb", + isPinned: true, + } as Partial), + ); + + s = fileContextReducer(s, classify("out")); + const after = s.files.byId["out" as FileId]; + + expect(after.versionNumber).toBe(7); + expect(after.thumbnailUrl).toBe("blob:thumb"); + expect((after as { isPinned?: boolean }).isPinned).toBe(true); + expect(after.classificationLabels).toEqual(LABELS); + }); +}); diff --git a/frontend/editor/src/core/contexts/file/contexts.ts b/frontend/editor/src/core/contexts/file/contexts.ts index c17a178043..6bc74e11ae 100644 --- a/frontend/editor/src/core/contexts/file/contexts.ts +++ b/frontend/editor/src/core/contexts/file/contexts.ts @@ -4,14 +4,28 @@ import { createContext } from "react"; import { + FileContextState, + FileContextSelectors, FileContextStateValue, FileContextActionsValue, } from "@app/types/fileContext"; -// Split contexts for performance -export const FileStateContext = createContext< - FileContextStateValue | undefined ->(undefined); +/** + * Subscription store for file state. The context VALUE is stable — consumers + * subscribe and select slices (see useFileSelector), re-rendering only when + * their selected slice changes, instead of on every state change. + */ +export interface FileStateStore { + getState: () => FileContextState; + subscribe: (listener: () => void) => () => void; + /** Stable selector API (reads live state via refs). */ + selectors: FileContextSelectors; +} + +export const FileStoreContext = createContext( + undefined, +); + export const FileActionsContext = createContext< FileContextActionsValue | undefined >(undefined); diff --git a/frontend/editor/src/core/contexts/file/fileActions.ts b/frontend/editor/src/core/contexts/file/fileActions.ts index 77447003f6..db99e1c673 100644 --- a/frontend/editor/src/core/contexts/file/fileActions.ts +++ b/frontend/editor/src/core/contexts/file/fileActions.ts @@ -370,32 +370,57 @@ export async function addFiles( // Collect hydrations to schedule after dispatch so updateStirlingFileStub finds files in state. const pendingHydrations: Array<() => Promise> = []; + // Per-chunk persistence promises (kicked off as chunks flush, awaited before + // return). See flushChunk — we stream writes instead of one batch at the end. + const persistPromises: Array> = []; - // Stream the batch into the workspace in chunks. The per-file pre-scan below - // (dedupe, encryption sniff — which reads each PDF's bytes) takes real time - // for a big folder drop; a single end-of-loop dispatch would leave the UI - // frozen-looking for seconds and then dump hundreds of rows in one render. - // Chunked dispatch keeps rows (and their thumbnail hydrations) streaming in, - // and the progress store drives the sidebar's "Adding files…" indicator. - const DISPATCH_CHUNK = 25; + // Dispatch stubs in chunks so rows (and thumbnail hydrations) stream in + // rather than dumping the whole drop in one render. + const DISPATCH_CHUNK = 5; let flushedStubs = 0; let flushedHydrations = 0; - const flushChunk = () => { - if ( - !options.skipWorkspaceDispatch && - stirlingFileStubs.length > flushedStubs - ) { - dispatch({ - type: "ADD_FILES", - payload: { stirlingFileStubs: stirlingFileStubs.slice(flushedStubs) }, - }); + // Flushes the pending chunk and returns this chunk's persistence promises, + // so the caller can await the writes (see the loop's yield) before the policy + // auto-run tries to read the file back from storage. + const flushChunk = (): Array> => { + const chunkWrites: Array> = []; + if (stirlingFileStubs.length > flushedStubs) { + const from = flushedStubs; + const newStubs = stirlingFileStubs.slice(from); flushedStubs = stirlingFileStubs.length; + if (!options.skipWorkspaceDispatch) { + dispatch({ + type: "ADD_FILES", + payload: { stirlingFileStubs: newStubs }, + }); + } + // Persist each chunk as it flushes, not one batch at the end: the policy + // auto-run reads files from IndexedDB with no in-memory fallback. + if (enablePersistence) { + const newFiles = stirlingFiles.slice(from); + for (let i = 0; i < newFiles.length; i++) { + const sf = newFiles[i]; + const stub = newStubs[i]; + const write = fileStorage + .storeStirlingFile(sf, stub) + .catch((error) => { + console.error( + "Failed to persist file to storage:", + sf.name, + error, + ); + }); + chunkWrites.push(write); + persistPromises.push(write); + } + } } // Hydrations only after their chunk is dispatched, so // updateStirlingFileStub finds the files in state. while (flushedHydrations < pendingHydrations.length) { scheduleMetadataHydration(pendingHydrations[flushedHydrations++]); } + return chunkWrites; }; reportBulkAddProgress(0, filesToProcess.length); @@ -554,38 +579,25 @@ export async function addFiles( reportBulkAddProgress(++scannedCount, filesToProcess.length); if (stirlingFileStubs.length - flushedStubs >= DISPATCH_CHUNK) { - flushChunk(); + const chunkWrites = flushChunk(); + // Yield a MACROTASK so React commits this chunk and runs its effects + // (incl. the policy-enforcement dispatch) before the next chunk scans. + // The per-file awaits above are only microtasks, which don't give React + // a turn — without this, all dispatches batch and processing can't begin + // until the whole drop is scanned. Awaiting the chunk's writes first means + // the auto-run finds each file's bytes already committed in storage. + await Promise.all(chunkWrites); + await new Promise((resolve) => setTimeout(resolve)); } } // Flush the remainder (also the sole dispatch for small batches). flushChunk(); - // Persist to storage if enabled using fileStorage service - if (enablePersistence && stirlingFiles.length > 0) { - await Promise.all( - stirlingFiles.map(async (stirlingFile, index) => { - try { - // Get corresponding stub with all metadata - const fileStub = stirlingFileStubs[index]; - - // Store using the cleaner signature - pass StirlingFile + StirlingFileStub directly - await fileStorage.storeStirlingFile(stirlingFile, fileStub); - - if (DEBUG) - console.log( - `📄 addFiles: Stored file ${stirlingFile.name} with metadata:`, - fileStub, - ); - } catch (error) { - console.error( - "Failed to persist file to storage:", - stirlingFile.name, - error, - ); - } - }), - ); + // Wait for the per-chunk writes (streamed in flushChunk) to commit, so + // addFiles only resolves once every file is durably stored. + if (enablePersistence && persistPromises.length > 0) { + await Promise.all(persistPromises); } if (!options.skipUploadTracking && stirlingFiles.length > 0) { diff --git a/frontend/editor/src/core/contexts/file/fileHooks.selector.test.tsx b/frontend/editor/src/core/contexts/file/fileHooks.selector.test.tsx new file mode 100644 index 0000000000..5a8f3243f9 --- /dev/null +++ b/frontend/editor/src/core/contexts/file/fileHooks.selector.test.tsx @@ -0,0 +1,223 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, act } from "@testing-library/react"; +import { useEffect } from "react"; +import { MantineProvider } from "@mantine/core"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import { + useAllFiles, + useFileContext, + useFileSelection, + useFileSelectors, + useStirlingFileStub, + useFileActions, +} from "@app/contexts/file/fileHooks"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; +import type { FileContextAction } from "@app/types/fileContext"; + +/** + * Proves the selector-subscription contract: a consumer re-renders only when + * the slice it selects changes — a single file's update doesn't re-render + * other files' consumers, and selection changes don't re-render list consumers. + */ + +const stub = (id: string): StirlingFileStub => + ({ + id: id as FileId, + name: `${id}.pdf`, + type: "application/pdf", + size: 1, + lastModified: 0, + }) as StirlingFileStub; + +const renders: Record = {}; +let dispatchRef: React.Dispatch | null = null; + +function Controller() { + const { dispatch } = useFileActions(); + dispatchRef = dispatch; + return null; +} + +function StubWatcher({ fileId }: { fileId: string }) { + useStirlingFileStub(fileId as FileId); + renders[`stub-${fileId}`] = (renders[`stub-${fileId}`] ?? 0) + 1; + return null; +} + +function ListWatcher() { + useAllFiles(); + renders.list = (renders.list ?? 0) + 1; + return null; +} + +function SelectionWatcher() { + useFileSelection(); + renders.selection = (renders.selection ?? 0) + 1; + return null; +} + +function setup() { + for (const key of Object.keys(renders)) delete renders[key]; + dispatchRef = null; + render( + + + + + + + + + , + ); + act(() => { + dispatchRef!({ + type: "ADD_FILES", + payload: { stirlingFileStubs: [stub("a"), stub("b")] }, + }); + }); + return { ...renders }; +} + +describe("file hooks — selector subscriptions", () => { + it("updating one file re-renders that file's consumer, not the other's", () => { + const before = setup(); + act(() => { + dispatchRef!({ + type: "UPDATE_FILE_RECORD", + payload: { id: "b" as FileId, updates: { name: "renamed.pdf" } }, + }); + }); + expect(renders["stub-b"]).toBeGreaterThan(before["stub-b"]); + expect(renders["stub-a"]).toBe(before["stub-a"]); + }); + + it("selection changes don't re-render file-list or per-file consumers", () => { + const before = setup(); + act(() => { + dispatchRef!({ + type: "SET_SELECTED_FILES", + payload: { fileIds: ["a" as FileId] }, + }); + }); + expect(renders.selection).toBeGreaterThan(before.selection); + expect(renders.list).toBe(before.list); + expect(renders["stub-a"]).toBe(before["stub-a"]); + expect(renders["stub-b"]).toBe(before["stub-b"]); + }); + + it("file-list changes don't re-render selection-only consumers", () => { + const before = setup(); + act(() => { + dispatchRef!({ + type: "UPDATE_FILE_RECORD", + payload: { id: "a" as FileId, updates: { name: "x.pdf" } }, + }); + }); + expect(renders.selection).toBe(before.selection); + }); +}); + +describe("useFileSelectors — render-phase misuse guard", () => { + const guardErrors = (spy: ReturnType) => + spy.mock.calls.filter((args) => + String(args[0]).includes("[useFileSelectors]"), + ); + + function RenderTimeMisuse() { + const selectors = useFileSelectors(); + selectors.getAllFileIds(); // during render — must be flagged + return null; + } + + function EffectTimeUse() { + const selectors = useFileSelectors(); + useEffect(() => { + selectors.getAllFileIds(); // after commit — legitimate + }, [selectors]); + return null; + } + + it("flags a selector invoked during render", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + + + , + ); + expect(guardErrors(spy).length).toBeGreaterThan(0); + spy.mockRestore(); + }); + + it("does not flag selector reads from effects", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + + + , + ); + expect(guardErrors(spy)).toHaveLength(0); + spy.mockRestore(); + }); +}); + +describe("useFileContext — render-phase misuse guard", () => { + // useFileContext subscribes to files + pinnedFiles only, so a render-time read + // of the SELECTION slice through its exposed selectors would silently go + // stale. The guard covers exactly those selectors and nothing else. + const guardErrors = (spy: ReturnType) => + spy.mock.calls.filter((args) => + String(args[0]).includes("[useFileContext]"), + ); + + const renderWithGuard = (node: React.ReactNode) => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + {node} + , + ); + const errors = guardErrors(spy); + spy.mockRestore(); + return errors; + }; + + function SelectionReadDuringRender() { + const { selectors } = useFileContext(); + selectors.getSelectedFiles(); // unsubscribed slice — must be flagged + return null; + } + + function FilesReadDuringRender() { + const { selectors } = useFileContext(); + selectors.getStirlingFileStubs(); // files slice IS subscribed — legitimate + return null; + } + + function SelectionReadFromEffect() { + const { selectors } = useFileContext(); + useEffect(() => { + selectors.getSelectedFiles(); // after commit — legitimate + }, [selectors]); + return null; + } + + it("flags a selection read during render", () => { + expect( + renderWithGuard().length, + ).toBeGreaterThan(0); + }); + + it("does not flag reads of a slice it subscribes to", () => { + expect(renderWithGuard()).toHaveLength(0); + }); + + it("does not flag selection reads from effects", () => { + expect(renderWithGuard()).toHaveLength(0); + }); +}); diff --git a/frontend/editor/src/core/contexts/file/fileHooks.ts b/frontend/editor/src/core/contexts/file/fileHooks.ts index fcd3ba7ef7..f5c1bfc460 100644 --- a/frontend/editor/src/core/contexts/file/fileHooks.ts +++ b/frontend/editor/src/core/contexts/file/fileHooks.ts @@ -1,27 +1,187 @@ /** - * Performant file hooks - Clean API using FileContext + * Performant file hooks — selector subscriptions over the FileStateStore. + * Each hook re-renders its consumer only when the slice it selects changes, + * not on every file-state change. */ -import { useContext, useMemo } from "react"; +import { useContext, useLayoutEffect, useMemo, useRef } from "react"; +import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector.js"; import { - FileStateContext, + FileStoreContext, FileActionsContext, + FileStateStore, FileContextStateValue, FileContextActionsValue, } from "@app/contexts/file/contexts"; -import { StirlingFileStub, StirlingFile } from "@app/types/fileContext"; +import { + StirlingFileStub, + StirlingFile, + FileContextState, + FileContextSelectors, +} from "@app/types/fileContext"; import { FileId } from "@app/types/file"; +const GUARD_MISUSE = process.env.NODE_ENV !== "production"; + +/** Shallow equality over object/array slices assembled by selectors. */ +export function shallowEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if ( + typeof a !== "object" || + a === null || + typeof b !== "object" || + b === null + ) { + return false; + } + const keysA = Object.keys(a); + if (keysA.length !== Object.keys(b).length) return false; + return keysA.every((key) => + Object.is( + (a as Record)[key], + (b as Record)[key], + ), + ); +} + +function useFileStore(): FileStateStore { + const store = useContext(FileStoreContext); + if (!store) { + throw new Error("File hooks must be used within a FileContextProvider"); + } + return store; +} + +/** + * Subscribe to a slice of file state. The component re-renders only when the + * selected value changes (Object.is by default; pass shallowEqual for slices + * assembled into fresh objects/arrays). + */ +export function useFileSelector( + selector: (state: FileContextState) => T, + isEqual?: (a: T, b: T) => boolean, +): T { + const store = useFileStore(); + return useSyncExternalStoreWithSelector( + store.subscribe, + store.getState, + store.getState, + selector, + isEqual, + ); +} + +/** Selectors that read `ui.selectedFileIds`. A hook that doesn't subscribe to + * that slice must not let consumers call these during render. */ +const SELECTION_SELECTORS: ReadonlyArray = [ + "getSelectedFiles", + "getSelectedStirlingFileStubs", +]; + +/** Wrap selectors so a call made during render logs loudly (dev/test only). + * Render-time vs event-time isn't statically lintable, so this is the guard. + * `keys` limits the wrap to the selectors whose slice the calling hook does NOT + * subscribe to — the rest are safe to read during render and pass through. */ +function guardSelectors( + selectors: FileContextSelectors, + isRendering: () => boolean, + hookName: string, + keys?: ReadonlyArray, +): FileContextSelectors { + const guardedKeys = + keys ?? (Object.keys(selectors) as Array); + const guarded: Record = { ...selectors }; + for (const key of guardedKeys) { + const original = selectors[key] as unknown as ( + ...args: unknown[] + ) => unknown; + guarded[key] = (...args: unknown[]) => { + if (isRendering()) { + console.error( + `[${hookName}] ${key}() was called during render. This read doesn't ` + + "subscribe to the state it depends on, so the UI can go stale — use " + + "useFileSelector / useFileSelection / useAllFiles for render-time data.", + ); + } + return original(...args); + }; + } + return guarded as unknown as FileContextSelectors; +} + +/** + * Wrap a hook's exposed selectors in the render-phase misuse guard (no-op in + * production). `keys` names the selectors the calling hook doesn't subscribe to; + * omit it to guard every selector (for hooks that subscribe to nothing). + */ +function useGuardedSelectors( + selectors: FileContextSelectors, + hookName: string, + keys?: ReadonlyArray, +): FileContextSelectors { + // True exactly while this consumer is rendering: set on every render, cleared + // by the layout effect once that render commits. + const renderPhase = useRef(false); + renderPhase.current = GUARD_MISUSE; + useLayoutEffect(() => { + renderPhase.current = false; + }); + return useMemo( + () => + GUARD_MISUSE + ? guardSelectors(selectors, () => renderPhase.current, hookName, keys) + : selectors, + [selectors, hookName, keys], + ); +} + +/** + * Stable selector API with NO state subscription — never re-renders. For + * event-time reads (callbacks/effects), which see live state when invoked. + * Render-time reads need a reactive hook (useAllFiles/useFileSelector) or + * they go stale — calling one during render logs an error outside production. + */ +export function useFileSelectors(): FileContextSelectors { + const { selectors } = useFileStore(); + return useGuardedSelectors(selectors, "useFileSelectors"); +} + +/** + * Position of `fileId` in the resolved file list — the SAME array useAllFiles() + * returns, which drops ids whose bytes haven't hydrated into memory yet, so the + * index lines up with what consumers actually index into. 0 when unset/absent. + * + * Selects a NUMBER, so the consumer re-renders only when the index actually + * moves. useAllFiles() would do the job too, but it re-renders on every + * unrelated stub update (thumbnail hydration, labels, …) — too costly for a + * high-level provider whose context value isn't memoized. + */ +export function useFileIndex(fileId: string | null | undefined): number { + // Raw (unguarded) selectors: the read below runs inside the subscription + // selector, so it IS reactive and the render-phase guard doesn't apply. + const { selectors } = useFileStore(); + return useFileSelector((s) => { + if (!fileId) return 0; + const index = selectors + .getFiles(s.files.ids) + .findIndex((file) => file.fileId === fileId); + return index >= 0 ? index : 0; + }); +} + /** * Hook for accessing file state (will re-render on any state change) * Use individual selector hooks below for better performance */ export function useFileState(): FileContextStateValue { - const context = useContext(FileStateContext); - if (!context) { - throw new Error("useFileState must be used within a FileContextProvider"); - } - return context; + const store = useFileStore(); + const state = useFileSelector((s) => s); + // Selectors are exposed unguarded on purpose: this hook subscribes to the + // WHOLE state, so a render-time selector read can't go stale. + return useMemo( + () => ({ state, selectors: store.selectors }), + [state, store.selectors], + ); } /** @@ -39,21 +199,21 @@ export function useFileActions(): FileContextActionsValue { * Hook for current/primary file (first in list) */ export function useCurrentFile(): { file?: File; record?: StirlingFileStub } { - const { state, selectors } = useFileState(); - - const primaryFileId = state.files.ids[0]; - const primaryFileRecord = primaryFileId - ? state.files.byId[primaryFileId] - : undefined; + const { selectors } = useFileStore(); + const { primaryFileId, record } = useFileSelector( + (s) => ({ + primaryFileId: s.files.ids[0], + record: s.files.ids[0] ? s.files.byId[s.files.ids[0]] : undefined, + }), + shallowEqual, + ); return useMemo( () => ({ file: primaryFileId ? selectors.getFile(primaryFileId) : undefined, - record: primaryFileId - ? selectors.getStirlingFileStub(primaryFileId) - : undefined, + record, }), - [primaryFileId, primaryFileRecord, selectors], + [primaryFileId, record, selectors], ); } @@ -61,27 +221,35 @@ export function useCurrentFile(): { file?: File; record?: StirlingFileStub } { * Hook for file selection state and actions */ export function useFileSelection() { - const { state, selectors } = useFileState(); + const { selectors } = useFileStore(); const { actions } = useFileActions(); + const selectedFileIds = useFileSelector((s) => s.ui.selectedFileIds); + const selectedPageNumbers = useFileSelector((s) => s.ui.selectedPageNumbers); + // Only the SELECTED files' records — an unrelated file's update never + // re-renders selection consumers. + const selectedStubs = useFileSelector( + (s) => s.ui.selectedFileIds.map((id) => s.files.byId[id]), + shallowEqual, + ); // Memoize selected files to avoid recreating arrays const selectedFiles = useMemo(() => { return selectors.getSelectedFiles(); - }, [state.ui.selectedFileIds, state.files.byId, selectors]); + }, [selectedFileIds, selectedStubs, selectors]); return useMemo( () => ({ selectedFiles, - selectedFileIds: state.ui.selectedFileIds, - selectedPageNumbers: state.ui.selectedPageNumbers, + selectedFileIds, + selectedPageNumbers, setSelectedFiles: actions.setSelectedFiles, setSelectedPages: actions.setSelectedPages, clearSelections: actions.clearSelections, }), [ selectedFiles, - state.ui.selectedFileIds, - state.ui.selectedPageNumbers, + selectedFileIds, + selectedPageNumbers, actions.setSelectedFiles, actions.setSelectedPages, actions.clearSelections, @@ -111,57 +279,64 @@ export function useFileManagement() { * Hook for UI state */ export function useFileUI() { - const { state } = useFileState(); const { actions } = useFileActions(); + const ui = useFileSelector( + (s) => ({ + isProcessing: s.ui.isProcessing, + processingProgress: s.ui.processingProgress, + hasUnsavedChanges: s.ui.hasUnsavedChanges, + }), + shallowEqual, + ); return useMemo( () => ({ - isProcessing: state.ui.isProcessing, - processingProgress: state.ui.processingProgress, - hasUnsavedChanges: state.ui.hasUnsavedChanges, + ...ui, setProcessing: actions.setProcessing, setUnsavedChanges: actions.setHasUnsavedChanges, }), - [state.ui, actions], + [ui, actions], ); } /** - * Hook for specific file by ID (optimized for individual file access) + * Hook for specific file by ID (optimized for individual file access): + * re-renders only when THAT file's record changes. */ export function useStirlingFileStub(fileId: FileId): { file?: File; record?: StirlingFileStub; } { - const { state, selectors } = useFileState(); - const fileRecord = state.files.byId[fileId]; + const { selectors } = useFileStore(); + const record = useFileSelector((s) => s.files.byId[fileId]); return useMemo( () => ({ file: selectors.getFile(fileId), - record: selectors.getStirlingFileStub(fileId), + record, }), - [fileId, fileRecord, selectors], + [fileId, record, selectors], ); } /** - * Hook for all files (use sparingly - causes re-renders on file list changes) + * Hook for all files: re-renders on file-list changes only (not selection/UI). */ export function useAllFiles(): { files: StirlingFile[]; fileStubs: StirlingFileStub[]; fileIds: FileId[]; } { - const { state, selectors } = useFileState(); + const { selectors } = useFileStore(); + const files = useFileSelector((s) => s.files); return useMemo( () => ({ - files: selectors.getFiles(), - fileStubs: selectors.getStirlingFileStubs(), - fileIds: state.files.ids, + files: selectors.getFiles(files.ids), + fileStubs: selectors.getStirlingFileStubs(files.ids), + fileIds: files.ids, }), - [state.files.ids, state.files.byId, selectors], + [files, selectors], ); } @@ -173,30 +348,47 @@ export function useSelectedFiles(): { selectedFileStubs: StirlingFileStub[]; selectedFileIds: FileId[]; } { - const { state, selectors } = useFileState(); + const { selectors } = useFileStore(); + const selectedFileIds = useFileSelector((s) => s.ui.selectedFileIds); + // Only the SELECTED files' records — see useFileSelection. + const selectedStubs = useFileSelector( + (s) => s.ui.selectedFileIds.map((id) => s.files.byId[id]), + shallowEqual, + ); return useMemo( () => ({ selectedFiles: selectors.getSelectedFiles(), selectedFileStubs: selectors.getSelectedStirlingFileStubs(), - selectedFileIds: state.ui.selectedFileIds, + selectedFileIds, }), - [state.ui.selectedFileIds, state.files.byId, selectors], + [selectedFileIds, selectedStubs, selectors], ); } -// Navigation management removed - moved to NavigationContext - /** - * Primary API hook for file context operations - * Used by tools for core file context functionality + * Primary API hook for file context operations. Used by tools for core file + * context functionality. Re-renders only when the slices it exposes reactively + * (files, pinned files) change — not on selection/UI changes. */ export function useFileContext() { - const { state, selectors } = useFileState(); + const store = useFileStore(); const { actions } = useFileActions(); + const { files, pinnedFiles } = useFileSelector( + (s) => ({ files: s.files, pinnedFiles: s.pinnedFiles }), + shallowEqual, + ); + // This hook subscribes to files + pinnedFiles, so those selectors are safe to + // read during render; the SELECTION ones aren't (no subscription to + // ui.selectedFileIds), so they carry the misuse guard. + const selectors = useGuardedSelectors( + store.selectors, + "useFileContext", + SELECTION_SELECTORS, + ); - return useMemo( - () => ({ + return useMemo(() => { + return { // Lifecycle management trackBlobUrl: actions.trackBlobUrl, scheduleCleanup: actions.scheduleCleanup, @@ -213,10 +405,11 @@ export function useFileContext() { _operationId: string, _error: string, ) => {}, // Operation tracking not implemented - // File ID lookup + // File ID lookup (reads live state at call time) findFileId: (file: File) => { - return state.files.ids.find((id) => { - const record = state.files.byId[id]; + const { files: liveFiles } = store.getState(); + return liveFiles.ids.find((id) => { + const record = liveFiles.byId[id]; return ( record && record.name === file.name && @@ -227,19 +420,18 @@ export function useFileContext() { }, // Pinned files - pinnedFiles: state.pinnedFiles, + pinnedFiles, pinFile: actions.pinFile, unpinFile: actions.unpinFile, isFilePinned: selectors.isFilePinned, // Active files - activeFiles: selectors.getFiles(), + activeFiles: selectors.getFiles(files.ids), openEncryptedUnlockPrompt: actions.openEncryptedUnlockPrompt, // Direct access to actions and selectors (for advanced use cases) actions, selectors, - }), - [state, selectors, actions], - ); + }; + }, [files, pinnedFiles, actions, store, selectors]); } diff --git a/frontend/editor/src/core/contexts/file/reducerIdentityGuard.test.ts b/frontend/editor/src/core/contexts/file/reducerIdentityGuard.test.ts new file mode 100644 index 0000000000..c250c3e4dc --- /dev/null +++ b/frontend/editor/src/core/contexts/file/reducerIdentityGuard.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { withReducerIdentityGuard } from "@app/contexts/file/FileReducer"; +import type { + FileContextState, + FileContextAction, + StirlingFileStub, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const stub = (id: string): StirlingFileStub => + ({ id: id as FileId, name: `${id}.pdf` }) as StirlingFileStub; + +function baseState(): FileContextState { + return { + files: { ids: ["a" as FileId], byId: { ["a" as FileId]: stub("a") } }, + pinnedFiles: new Set(), + ui: { + selectedFileIds: [], + selectedPageNumbers: [], + isProcessing: false, + processingProgress: 0, + hasUnsavedChanges: false, + errorFileIds: [], + }, + }; +} + +const guardErrors = (spy: ReturnType) => + spy.mock.calls.filter((a) => String(a[0]).includes("[FileReducer]")); + +afterEach(() => vi.restoreAllMocks()); + +describe("withReducerIdentityGuard", () => { + it("warns when a slice is reallocated but unchanged", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + // Bad reducer: rebuilds `files` (new ref) with identical contents. + const guarded = withReducerIdentityGuard((s) => ({ + ...s, + files: { ids: [...s.files.ids], byId: { ...s.files.byId } }, + })); + guarded(baseState(), { type: "NOOP" } as unknown as FileContextAction); + expect(guardErrors(spy)).toHaveLength(1); + expect(String(guardErrors(spy)[0][0])).toContain("state.files"); + }); + + it("stays quiet when a slice genuinely changes", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const guarded = withReducerIdentityGuard((s) => ({ + ...s, + files: { + ids: [...s.files.ids, "b" as FileId], + byId: { ...s.files.byId, ["b" as FileId]: stub("b") }, + }, + })); + guarded(baseState(), { type: "NOOP" } as unknown as FileContextAction); + expect(guardErrors(spy)).toHaveLength(0); + }); + + it("stays quiet when the reducer returns the same state reference", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const guarded = withReducerIdentityGuard((s) => s); + const state = baseState(); + expect( + guarded(state, { type: "NOOP" } as unknown as FileContextAction), + ).toBe(state); + expect(guardErrors(spy)).toHaveLength(0); + }); + + it("flags a needless ui reallocation", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const guarded = withReducerIdentityGuard((s) => ({ + ...s, + ui: { ...s.ui }, + })); + guarded(baseState(), { type: "NOOP" } as unknown as FileContextAction); + expect(String(guardErrors(spy)[0][0])).toContain("state.ui"); + }); +}); diff --git a/frontend/editor/src/core/contexts/file/useFileIndex.test.tsx b/frontend/editor/src/core/contexts/file/useFileIndex.test.tsx new file mode 100644 index 0000000000..e10bfed517 --- /dev/null +++ b/frontend/editor/src/core/contexts/file/useFileIndex.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { render, act } from "@testing-library/react"; +import { + FileStoreContext, + type FileStateStore, +} from "@app/contexts/file/contexts"; +import { useFileIndex } from "@app/contexts/file/fileHooks"; +import type { + FileContextSelectors, + FileContextState, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +/** + * useFileIndex replaced a render-time `selectors.getFiles()` read in + * ViewerContext, which never re-subscribed and so survived a file-list change + * that moved the active file. These tests drive a hand-built store (the real one + * needs IndexedDB to populate its File map) and lock the two properties the fix + * depends on: the index tracks the RESOLVED file list, and the consumer + * re-renders only when the index actually moves. + */ + +function makeStore(ids: string[], resolved: string[]) { + let state: FileContextState = { + files: { ids: ids as FileId[], byId: {} }, + } as FileContextState; + let resolvedIds = new Set(resolved); + const listeners = new Set<() => void>(); + + // Mirrors createFileSelectors.getFiles: maps ids through the in-memory File + // map and DROPS the ones whose bytes haven't landed yet. + const selectors = { + getFiles: (requested?: FileId[]) => + (requested ?? state.files.ids) + .filter((id) => resolvedIds.has(id as string)) + .map((id) => ({ fileId: id })), + } as unknown as FileContextSelectors; + + const store: FileStateStore = { + getState: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + selectors, + }; + + const update = (nextIds: string[], nextResolved: string[] = nextIds) => { + act(() => { + state = { + files: { ids: nextIds as FileId[], byId: {} }, + } as FileContextState; + resolvedIds = new Set(nextResolved); + listeners.forEach((listener) => listener()); + }); + }; + + return { store, update }; +} + +function setup(ids: string[], resolved: string[], fileId: string | null) { + const { store, update } = makeStore(ids, resolved); + let renders = 0; + let index = -1; + + function Probe() { + index = useFileIndex(fileId); + renders++; + return null; + } + + render( + + + , + ); + + return { update, get: () => index, renderCount: () => renders }; +} + +describe("useFileIndex", () => { + it("reports the active file's position in the resolved list", () => { + const { get } = setup(["a", "b", "c"], ["a", "b", "c"], "c"); + expect(get()).toBe(2); + }); + + it("skips ids whose bytes haven't hydrated, matching what consumers index into", () => { + // "a" has no File yet, so getFiles() yields [b, c] — "c" sits at 1, not 2. + const { get } = setup(["a", "b", "c"], ["b", "c"], "c"); + expect(get()).toBe(1); + }); + + it("updates when the list reorders under a stable active file", () => { + // The regression this fixes: activeFileId never changed, so the old + // useMemo kept returning the pre-reorder index. + const { get, update } = setup(["a", "b", "c"], ["a", "b", "c"], "c"); + expect(get()).toBe(2); + update(["c", "a", "b"]); + expect(get()).toBe(0); + }); + + it("updates when a file ahead of the active one is removed", () => { + const { get, update } = setup(["a", "b", "c"], ["a", "b", "c"], "c"); + update(["b", "c"]); + expect(get()).toBe(1); + }); + + it("falls back to 0 when the active file leaves the list", () => { + const { get, update } = setup(["a", "b"], ["a", "b"], "b"); + update(["a"]); + expect(get()).toBe(0); + }); + + it("returns 0 with no active file", () => { + const { get } = setup(["a", "b"], ["a", "b"], null); + expect(get()).toBe(0); + }); + + it("does not re-render when a store change leaves the index alone", () => { + // Selecting a NUMBER is the point: appending after the active file, or any + // unrelated stub churn, must not re-render the consumer. + const { get, update, renderCount } = setup(["a", "b"], ["a", "b"], "a"); + const before = renderCount(); + update(["a", "b", "c"]); + expect(get()).toBe(0); + expect(renderCount()).toBe(before); + }); +}); diff --git a/frontend/editor/src/core/tools/Convert.tsx b/frontend/editor/src/core/tools/Convert.tsx index de29e4ae94..b1d214f1a6 100644 --- a/frontend/editor/src/core/tools/Convert.tsx +++ b/frontend/editor/src/core/tools/Convert.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; @@ -14,8 +14,7 @@ import { BaseToolProps, ToolComponent } from "@app/types/tool"; const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { selectors } = useFileState(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const selectedFiles = useViewScopedFiles(); const scrollContainerRef = useRef(null); diff --git a/frontend/editor/src/desktop/hooks/useExitWarning.ts b/frontend/editor/src/desktop/hooks/useExitWarning.ts index 1e6b4423fc..4a5b16fb8f 100644 --- a/frontend/editor/src/desktop/hooks/useExitWarning.ts +++ b/frontend/editor/src/desktop/hooks/useExitWarning.ts @@ -1,14 +1,14 @@ import { useEffect, useRef } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { message } from "@tauri-apps/plugin-dialog"; -import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { useFileSelectors, useFileActions } from "@app/contexts/FileContext"; import { downloadFile } from "@app/services/downloadService"; import type { StirlingFileStub } from "@app/types/fileContext"; import { useTranslation } from "react-i18next"; export function useExitWarning() { const { t } = useTranslation(); - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const { actions: fileActions } = useFileActions(); const selectorsRef = useRef(selectors); const isClosingRef = useRef(false); diff --git a/frontend/editor/src/desktop/hooks/useSaveShortcut.ts b/frontend/editor/src/desktop/hooks/useSaveShortcut.ts index 927d5f06cf..b3faa64bb7 100644 --- a/frontend/editor/src/desktop/hooks/useSaveShortcut.ts +++ b/frontend/editor/src/desktop/hooks/useSaveShortcut.ts @@ -1,5 +1,9 @@ import { useEffect } from "react"; -import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { + useFileSelector, + useFileSelectors, + useFileActions, +} from "@app/contexts/FileContext"; // Save through the export gateway so a "run on export" policy enforces before // the file is written out (no-op when no such policy is active). import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; @@ -10,7 +14,8 @@ import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWith * Matches WorkbenchBar button behavior: saves selected files if any, otherwise all files */ export function useSaveShortcut() { - const { selectors, state } = useFileState(); + const selectors = useFileSelectors(); + const currentSelectedFileIds = useFileSelector((s) => s.ui.selectedFileIds); const { actions: fileActions } = useFileActions(); useEffect(() => { @@ -20,7 +25,7 @@ export function useSaveShortcut() { event.preventDefault(); // Get selected files or all files if nothing selected - const selectedFileIds = state.ui.selectedFileIds; + const selectedFileIds = currentSelectedFileIds; const filesToSave = selectedFileIds.length > 0 ? selectors.getFiles(selectedFileIds) @@ -63,5 +68,5 @@ export function useSaveShortcut() { document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [selectors, state.ui.selectedFileIds, fileActions]); + }, [selectors, currentSelectedFileIds, fileActions]); } diff --git a/frontend/editor/src/proprietary/components/policies/classificationLabelTargets.test.ts b/frontend/editor/src/proprietary/components/policies/classificationLabelTargets.test.ts new file mode 100644 index 0000000000..74baf59006 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/classificationLabelTargets.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { classificationLabelTargetStubs } from "@app/components/policies/usePolicyAutoRun"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +// Loosely-typed builder: FileId is a branded string, so accept plain string ids +// in tests and cast — classificationLabelTargetStubs only reads id/parent/sources. +const stub = (s: { + id: string; + parentFileId?: string; + sourceFileIds?: string[]; +}): StirlingFileStub => s as unknown as StirlingFileStub; + +const ids = (stubs: StirlingFileStub[]) => stubs.map((s) => s.id as string); + +describe("classificationLabelTargetStubs", () => { + it("targets the run's own file when it's still the leaf", () => { + const stubs = [stub({ id: "a" }), stub({ id: "b" })]; + expect(ids(classificationLabelTargetStubs("a", stubs))).toEqual(["a"]); + }); + + it("targets a descendant leaf when the file was edited during the run", () => { + // "a" was consumed into leaf "a2" (edit forked a new version mid-run). + const stubs = [stub({ id: "a2", sourceFileIds: ["a"] })]; + expect(ids(classificationLabelTargetStubs("a", stubs))).toEqual(["a2"]); + }); + + it("targets a direct child via parentFileId", () => { + const stubs = [stub({ id: "a2", parentFileId: "a" })]; + expect(ids(classificationLabelTargetStubs("a", stubs))).toEqual(["a2"]); + }); + + it("returns the stubs themselves, so the caller can see what's already tagged", () => { + const target = stub({ id: "a" }); + expect(classificationLabelTargetStubs("a", [target])[0]).toBe(target); + }); + + it("is empty when the document has left the workspace (file closed)", () => { + // No fallback to the run's own id: stamping a consumed id would no-op + // anyway, and an empty result lets the caller settle without downloading. + expect(classificationLabelTargetStubs("a", [stub({ id: "z" })])).toEqual( + [], + ); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.test.ts b/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.test.ts new file mode 100644 index 0000000000..355d8b5526 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + acquireDispatchSlot, + releaseDispatchSlot, + resetDispatchSemaphoreForTests, +} from "@app/components/policies/dispatchSemaphore"; + +// Drain the microtask queue so an acquire's await-resume AND the caller's .then +// have both run. +const flush = () => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => resetDispatchSemaphoreForTests()); + +describe("dispatchSemaphore", () => { + it("lets up to 4 acquire without waiting, then blocks the 5th", async () => { + for (let i = 0; i < 4; i++) await acquireDispatchSlot(); + let fifthAcquired = false; + void acquireDispatchSlot().then(() => { + fifthAcquired = true; + }); + await flush(); + expect(fifthAcquired).toBe(false); + releaseDispatchSlot(); + await flush(); + expect(fifthAcquired).toBe(true); + }); + + it("serves a priority (chained) waiter before earlier normal waiters", async () => { + for (let i = 0; i < 4; i++) await acquireDispatchSlot(); // pool full + const order: string[] = []; + // Two normal (new-file) dispatches queue first… + void acquireDispatchSlot(false).then(() => order.push("normal-1")); + void acquireDispatchSlot(false).then(() => order.push("normal-2")); + // …then a chained dispatch arrives — it must jump ahead. + void acquireDispatchSlot(true).then(() => order.push("chained")); + await flush(); + + releaseDispatchSlot(); + await flush(); + releaseDispatchSlot(); + await flush(); + releaseDispatchSlot(); + await flush(); + + expect(order).toEqual(["chained", "normal-1", "normal-2"]); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.ts b/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.ts new file mode 100644 index 0000000000..d5119e5923 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.ts @@ -0,0 +1,42 @@ +/** + * Bounded concurrency for policy run-dispatch uploads. + * + * Each dispatch POSTs a file's bytes; firing a whole drop at once saturates the + * browser's per-origin connection pool, so status polls and output downloads of + * already-running files queue behind the pending uploads and nothing visibly + * progresses. A small window keeps connections free. + * + * `priority` (a chained/downstream dispatch) jumps to the FRONT of the queue, so + * a file already mid-chain finishes its whole policy flow before a brand-new + * file's first policy starts. Without it a chained dispatch would sit behind the + * entire first-policy wave (FIFO) — e.g. classification wouldn't start on any + * file until security had finished on all of them. + */ +const MAX_CONCURRENT_DISPATCHES = 4; + +let slotsInUse = 0; +const waiters: Array<() => void> = []; + +export async function acquireDispatchSlot(priority = false): Promise { + if (slotsInUse < MAX_CONCURRENT_DISPATCHES) { + slotsInUse++; + return; + } + await new Promise((resolve) => { + if (priority) waiters.unshift(resolve); + else waiters.push(resolve); + }); +} + +export function releaseDispatchSlot(): void { + const next = waiters.shift(); + // Hand the slot straight to the next waiter, else free it. + if (next) next(); + else slotsInUse--; +} + +/** Test-only: reset module state between cases. */ +export function resetDispatchSemaphoreForTests(): void { + slotsInUse = 0; + waiters.length = 0; +} diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx index 5837bfb285..bc4fa54dff 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx @@ -2,21 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; /** - * Batch integration test for the policy auto-run orchestration, at the scale the - * user hit the bug: 61 files uploaded at once, two active upload policies - * (Classification → Security) chained. Drives the REAL policyRunStore + the REAL - * hook effects (dispatch → poll → import → chain), mocking only the IO boundaries - * (network, storage, thumbnail/stub creation). - * - * Proves the invariants the user asked for: - * - 61 files ⇒ exactly 122 runs (61 classification, then 61 security). - * - Delivery is SILENT + in place (consumeFiles called with { silent: true }), - * never adding a second copy — the workspace never grows past 61. - * - No runaway: if the loop guard regressed, the run count would blow past 122 - * (or the test would time out), so an exact 122 is a hard regression gate. - * - Closing all files mid-run does NOT re-open them: with the workspace emptied, - * outputs are delivered to storage (persistVersionedOutputs), never re-added - * to the workspace via consumeFiles. + * Batch integration test (61 files, two chained upload policies) driving the real + * store + hook effects, IO mocked. Classification is forced last (see the sort). */ const FILE_COUNT = 61; @@ -25,13 +12,15 @@ const FILE_COUNT = 61; // the workbench, mirrored into useAllFiles. consumeFiles mutates it in place // (input id → output id) exactly as the real silent reducer would. const mocks = vi.hoisted(() => ({ - workspace: [] as Array<{ id: string }>, + workspace: [] as Array<{ id: string; classificationLabels?: string[] }>, consumeSilentCalls: 0, consumeNonSilentCalls: 0, persistCalls: 0, addFilesCalls: 0, stubCounter: 0, backendOutCounter: 0, + dispatchInFlight: 0, + maxDispatchInFlight: 0, bumpRevision: vi.fn(), runStoredPolicy: vi.fn(), getPolicyRun: vi.fn(), @@ -66,7 +55,8 @@ vi.mock("@app/contexts/IndexedDBContext", () => ({ vi.mock("@app/hooks/usePolicies", () => ({ usePolicies: () => ({ policies: { - // Classification runs first (order 0), Security second (order 1). + // Classification is configured first (order 0) but is FORCED to run last + // by the orchestrator; Security (order 1) therefore runs first. classification: { configured: true, status: "active", @@ -107,7 +97,9 @@ vi.mock("@app/services/fileStubHelpers", () => ({ createStirlingFilesAndStubs: mocks.createStirlingFilesAndStubs, })); vi.mock("@app/services/fileClassification", () => ({ - readClassificationLabelsFromFile: vi.fn().mockResolvedValue(null), + // Classification always resolves labels here, so the metadata-only import path + // stamps them onto the stub. + readClassificationLabelsFromFile: vi.fn().mockResolvedValue(["Invoice"]), })); import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; @@ -141,6 +133,8 @@ beforeEach(() => { mocks.addFilesCalls = 0; mocks.stubCounter = 0; mocks.backendOutCounter = 0; + mocks.dispatchInFlight = 0; + mocks.maxDispatchInFlight = 0; mocks.workspace = Array.from({ length: FILE_COUNT }, (_, i) => ({ id: `file-${i}`, @@ -155,15 +149,31 @@ beforeEach(() => { mocks.persistVersionedOutputs.mockImplementation(async () => { mocks.persistCalls += 1; }); - mocks.updateFileMetadata.mockResolvedValue(false); + mocks.updateFileMetadata.mockResolvedValue(true); mocks.downloadPolicyOutput.mockResolvedValue( new Blob(["x"], { type: "application/pdf" }), ); + // Apply stub updates to the shared workspace, as the real reducer does — the + // label stamp's second pass reads them back to stay idempotent. + mocks.updateStirlingFileStub.mockImplementation( + (id: string, updates: Record) => { + const stub = mocks.workspace.find((s) => s.id === id); + if (stub) Object.assign(stub, updates); + }, + ); // Each dispatch gets a unique run id; the run's single backend output likewise. - mocks.runStoredPolicy.mockImplementation( - async () => `run-${mocks.stubCounter++}`, - ); + // Takes real time so overlapping dispatches are measurable (the upload window). + mocks.runStoredPolicy.mockImplementation(async () => { + mocks.dispatchInFlight++; + mocks.maxDispatchInFlight = Math.max( + mocks.maxDispatchInFlight, + mocks.dispatchInFlight, + ); + await new Promise((resolve) => setTimeout(resolve, 2)); + mocks.dispatchInFlight--; + return `run-${mocks.stubCounter++}`; + }); mocks.getPolicyRun.mockImplementation(async (runId: string) => ({ runId, policyId: null, @@ -225,8 +235,8 @@ async function runUntilSettled(expectedRuns: number) { }); } -describe("policy auto-run — 61-file batch through a Classification → Security chain", () => { - it("produces exactly 122 runs (61 classification, then 61 security)", async () => { +describe("policy auto-run — 61-file batch through a Security → Classification chain", () => { + it("produces exactly 122 runs (61 security, then 61 classification)", async () => { await runUntilSettled(FILE_COUNT * 2); const classification = latestRuns.filter( @@ -239,15 +249,26 @@ describe("policy auto-run — 61-file batch through a Classification → Securit expect(latestRuns).toHaveLength(FILE_COUNT * 2); }); - it("delivers every output SILENTLY in place — workspace never grows past 61", async () => { + it("bounds concurrent dispatch uploads so polls/downloads keep connections", async () => { + await runUntilSettled(FILE_COUNT * 2); + expect(mocks.maxDispatchInFlight).toBeGreaterThan(1); // still parallel… + expect(mocks.maxDispatchInFlight).toBeLessThanOrEqual(4); // …but windowed + }); + + it("versions on Security in place, tags on Classification — workspace never grows past 61", async () => { await runUntilSettled(FILE_COUNT * 2); - // 122 deliveries, all silent (background), none via the disruptive path. - expect(mocks.consumeSilentCalls).toBe(FILE_COUNT * 2); + // Only the 61 Security runs fork a version, and every one silently in place. + expect(mocks.consumeSilentCalls).toBe(FILE_COUNT); expect(mocks.consumeNonSilentCalls).toBe(0); + // Classification never forks a version — it only stamps labels onto the stub. + expect(mocks.updateStirlingFileStub).toHaveBeenCalledTimes(FILE_COUNT); + for (const call of mocks.updateStirlingFileStub.mock.calls) { + expect(call[1]).toEqual({ classificationLabels: ["Invoice"] }); + } // Never added as brand-new files either. expect(mocks.addFilesCalls).toBe(0); - // In-place versioning: each file replaced twice, count unchanged. + // In-place versioning + metadata-only tagging: count unchanged. expect(mocks.workspace).toHaveLength(FILE_COUNT); }); @@ -273,8 +294,8 @@ describe("policy auto-run — 61-file batch through a Classification → Securit ); }); - // Still fully processed (chain intact), but delivered to STORAGE, never - // re-added to the workbench — the workspace stays empty. + // Still fully processed (chain intact), but Security's versions went to + // STORAGE, never re-added to the workbench — the workspace stays empty. expect(latestRuns).toHaveLength(FILE_COUNT * 2); expect(mocks.workspace).toHaveLength(0); expect(mocks.consumeSilentCalls).toBe(0); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx new file mode 100644 index 0000000000..b67648e7fc --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx @@ -0,0 +1,293 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +/** + * Mid-run race: classification is in flight (its labelled output is still + * downloading) when the user manually runs a tool on the same file — e.g. + * quickly redacting it — which consumes the input and forks a new leaf. + * + * The label targets must be resolved AT WRITE TIME (after the download/parse + * window), not snapshotted at run completion: a stale snapshot points at the + * consumed id, no-ops, and silently loses the labels — the file then shows the + * classification badge (provenance-resolved) but never gets its labels. + */ + +const mocks = vi.hoisted(() => ({ + workspace: [] as Array<{ + id: string; + sourceFileIds?: string[]; + derivedFromTool?: boolean; + classificationLabels?: string[]; + }>, + runStoredPolicy: vi.fn(), + getPolicyRun: vi.fn(), + listPolicyRuns: vi.fn(), + downloadPolicyOutput: vi.fn(), + getStirlingFile: vi.fn(), + getStirlingFileStub: vi.fn(), + persistVersionedOutputs: vi.fn(), + updateFileMetadata: vi.fn(), + createStirlingFilesAndStubs: vi.fn(), + addFiles: vi.fn(), + updateStirlingFileStub: vi.fn(), + consumeFiles: vi.fn(), + bumpRevision: vi.fn(), +})); + +// Classification chains server-side only when the AI engine is on (else it runs +// client-side); this race is in the server import path, so force the engine on. +vi.mock("@app/hooks/useAiEngineEnabled", () => ({ + useAiEngineEnabled: () => true, +})); +vi.mock("@app/contexts/FileContext", () => ({ + useAllFiles: () => ({ fileStubs: mocks.workspace }), + useFileManagement: () => ({ + addFiles: mocks.addFiles, + updateStirlingFileStub: mocks.updateStirlingFileStub, + }), + useFileContext: () => ({ consumeFiles: mocks.consumeFiles }), +})); +vi.mock("@app/contexts/IndexedDBContext", () => ({ + useIndexedDB: () => ({ bumpRevision: mocks.bumpRevision }), +})); +vi.mock("@app/hooks/usePolicies", () => ({ + usePolicies: () => ({ + policies: { + classification: { + configured: true, + status: "active", + backendId: "backend-classification", + runOn: "upload", + order: 0, + outputMode: "new_version", + outputName: "", + }, + }, + }), +})); +vi.mock("@app/services/policyApi", () => ({ + runStoredPolicy: mocks.runStoredPolicy, + getPolicyRun: mocks.getPolicyRun, + listPolicyRuns: mocks.listPolicyRuns, + downloadPolicyOutput: mocks.downloadPolicyOutput, + resolvePolicyRunTarget: () => "saas", +})); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getStirlingFile: mocks.getStirlingFile, + getStirlingFileStub: mocks.getStirlingFileStub, + persistVersionedOutputs: mocks.persistVersionedOutputs, + updateFileMetadata: mocks.updateFileMetadata, + }, +})); +vi.mock("@app/services/fileStubHelpers", () => ({ + createStirlingFilesAndStubs: mocks.createStirlingFilesAndStubs, +})); +vi.mock("@app/services/fileClassification", () => ({ + readClassificationLabelsFromFile: vi.fn().mockResolvedValue(["Invoice"]), +})); + +import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; +import { + usePolicyRuns, + resetPolicyRuns, +} from "@app/components/policies/policyRunStore"; +import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; + +let latestRuns: PolicyRunRecord[] = []; +function Harness() { + usePolicyAutoRun(); + latestRuns = usePolicyRuns(); + return null; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +beforeEach(() => { + localStorage.clear(); + resetPolicyRuns(); + vi.clearAllMocks(); + + mocks.workspace = [{ id: "file-0" }]; + + mocks.listPolicyRuns.mockResolvedValue([]); + mocks.getStirlingFile.mockResolvedValue( + new File(["x"], "doc.pdf", { type: "application/pdf" }), + ); + mocks.getStirlingFileStub.mockResolvedValue(null); + mocks.updateFileMetadata.mockResolvedValue(true); + // Apply stub updates to the shared workspace, as the real reducer does — the + // label stamp's second pass reads them back to stay idempotent. + mocks.updateStirlingFileStub.mockImplementation( + (id: string, updates: Record) => { + const stub = mocks.workspace.find((s) => s.id === id); + if (stub) Object.assign(stub, updates); + }, + ); + mocks.runStoredPolicy.mockResolvedValue("run-0"); + mocks.getPolicyRun.mockResolvedValue({ + runId: "run-0", + policyId: null, + status: "COMPLETED", + currentStep: 1, + stepCount: 1, + error: null, + outputs: [{ fileId: "backend-out-0", fileName: "doc.pdf" }], + }); +}); + +async function settleImport(timeout = 8000) { + await act(async () => { + await vi.waitFor( + () => { + expect(latestRuns.filter((r) => r.imported)).toHaveLength(1); + }, + { timeout, interval: 20 }, + ); + }); +} + +describe("classification vs a mid-run manual tool edit", () => { + it("labels land on the forked leaf when a tool consumes the file during the label download", async () => { + // The classified output's download hangs until we release it — this is the + // async window the user's edit slips into. + const download = deferred(); + mocks.downloadPolicyOutput.mockReturnValue(download.promise); + + const { rerender } = renderHook(() => Harness()); + + // Run dispatched, completed, import started — now hanging in the window. + await act(async () => { + await vi.waitFor( + () => expect(mocks.downloadPolicyOutput).toHaveBeenCalled(), + { timeout: 8000, interval: 20 }, + ); + }); + + // User quickly redacts: the tool consumes file-0 and forks a new leaf. + // (derivedFromTool + sourceFileIds are what CONSUME_FILES stamps.) + act(() => { + mocks.workspace = [ + { + id: "file-0~redacted", + sourceFileIds: ["file-0"], + derivedFromTool: true, + }, + ]; + rerender(); + }); + + // The download finally lands. + download.resolve(new Blob(["x"], { type: "application/pdf" })); + await settleImport(); + + // Labels stamped onto the LIVE leaf, not no-oped on the consumed id. + const stampedIds = mocks.updateStirlingFileStub.mock.calls.map((c) => c[0]); + expect(stampedIds).toEqual(["file-0~redacted"]); + expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith( + "file-0~redacted", + { classificationLabels: ["Invoice"] }, + ); + // Badge persists on the leaf: the run's outputFileIds are the tagged files. + expect(latestRuns[0].outputFileIds).toEqual(["file-0~redacted"]); + }); + + it("control: with no mid-run edit, labels land on the original file", async () => { + mocks.downloadPolicyOutput.mockResolvedValue( + new Blob(["x"], { type: "application/pdf" }), + ); + + renderHook(() => Harness()); + await settleImport(); + + expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("file-0", { + classificationLabels: ["Invoice"], + }); + expect(latestRuns[0].outputFileIds).toEqual(["file-0"]); + }); + + it("stamps the forked leaf when the consume lands in the same frame as the first stamp", async () => { + // Tighter than the case above: the consume is dispatched but hasn't rendered + // when the labels are stamped, so the workspace snapshot still shows file-0 + // and that stamp no-ops against the real reducer. The post-commit second pass + // is what saves the labels. + mocks.downloadPolicyOutput.mockResolvedValue( + new Blob(["x"], { type: "application/pdf" }), + ); + mocks.updateStirlingFileStub.mockImplementation((id: string) => { + // file-0 is already consumed, so its stamp is lost (no Object.assign) and + // the forked leaf only becomes visible afterwards. Mutate the workspace in + // place: the hook holds it by ref, which is what the second pass re-reads. + if (id === "file-0") { + mocks.workspace.splice(0, mocks.workspace.length, { + id: "file-0~redacted", + sourceFileIds: ["file-0"], + }); + return; + } + const stub = mocks.workspace.find((s) => s.id === id); + if (stub) Object.assign(stub, { classificationLabels: ["Invoice"] }); + }); + + renderHook(() => Harness()); + await settleImport(); + + const stampedIds = mocks.updateStirlingFileStub.mock.calls.map((c) => c[0]); + expect(stampedIds).toEqual(["file-0", "file-0~redacted"]); + // The leaf becoming visible also queues its own classification run, so pick + // the settled one rather than assuming an index. + const imported = latestRuns.find((r) => r.imported); + expect(imported?.outputFileIds).toContain("file-0~redacted"); + }); +}); + +// The label read backs off between attempts (2s, then 4s), so these run on fake +// timers — sleeping for real would hold a worker long enough to starve the suite. +describe("classification label-read failures", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + async function settleOnFakeTime(maxMs = 30_000) { + for (let elapsed = 0; elapsed < maxMs; elapsed += 250) { + if (latestRuns.some((r) => r.imported)) return; + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + } + throw new Error("classification run never settled"); + } + + it("retries a transient failure instead of leaving the run unsettled", async () => { + // The import effect only re-runs when the run store changes, so bailing out + // on a transient failure would leave this run "running" forever. + mocks.downloadPolicyOutput + .mockRejectedValueOnce(new Error("network blip")) + .mockResolvedValue(new Blob(["x"], { type: "application/pdf" })); + + renderHook(() => Harness()); + await settleOnFakeTime(); + + expect(mocks.downloadPolicyOutput).toHaveBeenCalledTimes(2); + expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("file-0", { + classificationLabels: ["Invoice"], + }); + }); + + it("settles a run whose labels never become readable", async () => { + // Permanent failure: give up after the retry budget and settle unlabelled, + // rather than spinning the file's "running" pill indefinitely. + mocks.downloadPolicyOutput.mockRejectedValue(new Error("network down")); + + renderHook(() => Harness()); + await settleOnFakeTime(); + + expect(mocks.updateStirlingFileStub).not.toHaveBeenCalled(); + expect(latestRuns.find((r) => r.imported)?.outputFileIds).toEqual([]); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx index c25f80ce65..3c54e5cc42 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx @@ -69,8 +69,17 @@ afterEach(() => vi.useRealTimers()); describe("auto-run queue-rejection retry", () => { it("relabels a queue-rejected run as retrying, then re-dispatches it in place", async () => { - // The polled run comes back queue-rejected; the retry resolves the file + fires a fresh run. - getRunApi.mockResolvedValue(queueFullView); + // The polled run comes back queue-rejected once; the retry resolves the file + // and fires a fresh run, whose own polls then see it genuinely running. + getRunApi.mockResolvedValueOnce(queueFullView).mockResolvedValue({ + runId: "run-2", + status: "RUNNING", + currentStep: 1, + stepCount: 2, + error: null, + errorCode: null, + outputs: [], + } as never); getFile.mockResolvedValue({ size: 1234 } as never); runStored.mockResolvedValue("run-2"); @@ -92,19 +101,20 @@ describe("auto-run queue-rejection retry", () => { return usePolicyRuns(); }); - // First poll (2s cadence) sees the rejection → relabel as a soft "retrying" row. + // First poll sees the rejection → relabel as a soft "retrying" row. await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(getRun("run-1")?.retrying).toBe(true); expect(runStored).not.toHaveBeenCalled(); - // After the first backoff window (BASE 4s) the rejected record is dropped and a fresh run fires. + // After the first backoff window (BASE 4s) the rejected record is dropped and + // a fresh run fires; its own first poll shows it genuinely running. await act(async () => { - await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(6000); }); expect(runStored).toHaveBeenCalledWith("backend-1", [{ size: 1234 }]); expect(getRun("run-1")).toBeUndefined(); - expect(getRun("run-2")?.status).toBe("PENDING"); + expect(getRun("run-2")?.status).toBe("RUNNING"); }); }); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index ca096b6792..e475d784ea 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -39,6 +39,11 @@ import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge"; import type { FileId } from "@app/types/file"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { readClassificationLabelsFromFile } from "@app/services/fileClassification"; +import { isClassificationCategory } from "@app/data/policyCategories"; +import { + acquireDispatchSlot, + releaseDispatchSlot, +} from "@app/components/policies/dispatchSemaphore"; import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; import type { PoliciesByCategory } from "@app/types/policies"; import { usePolicies } from "@app/hooks/usePolicies"; @@ -59,6 +64,10 @@ import { /** Status poll cadence. */ const POLL_MS = 2000; +/** First poll fires early so a fresh run shows real progress quickly instead of + * sitting on an indeterminate spinner for a full poll interval. */ +const FIRST_POLL_MS = 500; + /** The server aborts any single tool step that runs longer than its internal-API * read timeout, then fails the run — so a run can legitimately stay in flight * for up to this long per step. The client must keep polling at least that long, @@ -175,7 +184,14 @@ export function usePolicyAutoRun(): void { // Classification policy out of the server chain when the AI engine is off. !(id === "classification" && !aiEnabled), ) - .sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0)) + // Classification runs last: it's non-blocking, so an enforcement policy + // running after it would fork a new version and drop the user's edits. + .sort(([idA, a], [idB, b]) => { + const ca = isClassificationCategory(idA) ? 1 : 0; + const cb = isClassificationCategory(idB) ? 1 : 0; + if (ca !== cb) return ca - cb; + return (a.order ?? 0) - (b.order ?? 0); + }) .map(([id]) => id), [policies, aiEnabled], ); @@ -310,6 +326,7 @@ export function usePolicyAutoRun(): void { backendId, outputId as FileId, run.fileName, + true, // chained → jump the dispatch queue ahead of new files ).catch(() => {}); } } @@ -330,15 +347,33 @@ export function usePolicyAutoRun(): void { // so the enforced file appears in the app rather than only on the backend. useEffect(() => { for (const run of runs) { + const classification = isClassificationCategory(run.categoryId); if ( run.status !== "COMPLETED" || run.imported || - !run.outputs?.length || - importing.current.has(run.runId) + importing.current.has(run.runId) || + // Classification settles even with no outputs (nothing to tag); other + // policies need an output to import. + (!run.outputs?.length && !classification) ) { continue; } importing.current.add(run.runId); + // Classification is metadata-only: stamp labels onto the current leaf of + // the file it ran on (no version fork). See importClassificationLabels. + if (classification) { + // Targets are resolved by importClassificationLabels AT WRITE TIME (not + // snapshotted here): its download/parse is an async window during which + // a manual tool run can consume the input and fork a new leaf, and a + // stale snapshot would no-op on the dead id and lose the labels. + void importClassificationLabels( + run, + () => + classificationLabelTargetStubs(run.fileId, fileStubsRef.current), + { updateStirlingFileStub, bumpRevision }, + ).finally(() => importing.current.delete(run.runId)); + continue; + } // Honour the policy's output mode: a new file, or a new version of the // input file it ran on (needs that input's stub, still in the workspace). const outputMode = policies[run.categoryId]?.outputMode ?? "new_version"; @@ -501,6 +536,142 @@ function categoryForPolicy( )?.[0]; } +interface ClassificationImportContext { + updateStirlingFileStub: ( + fileId: FileId, + updates: Partial, + ) => void; + bumpRevision: () => void; +} + +/** Workspace stubs to tag with a classification run's labels: the file it ran + * on plus any live descendants, so an edit made during the async run (which + * forks a new leaf) still shows the tags. Empty once the document has left the + * workspace (closed, or a reconciled run with no local input link). */ +export function classificationLabelTargetStubs( + runFileId: string, + stubs: ReadonlyArray, +): StirlingFileStub[] { + return stubs.filter( + (s) => + (s.id as string) === runFileId || + s.parentFileId === runFileId || + s.sourceFileIds?.includes(runFileId as FileId), + ); +} + +/** Attempts to read a completed run's labels before giving up, and the backoff + * between them (delay × attempt). The import effect only re-runs when the run + * store changes, so a transient read failure has to be retried HERE: bailing + * out would leave the run unsettled and the file's "running" pill spinning + * until unrelated policy activity happened to nudge the effect. */ +const LABEL_READ_ATTEMPTS = 3; +const LABEL_READ_RETRY_MS = 2000; + +/** + * Read classification labels out of a completed run's output PDF. A 404 means + * that output aged out, so it's skipped; any other failure is transient and + * retried with backoff. Returns null when there are genuinely no labels to + * apply (including a run with no outputs), so the caller can settle the run. + */ +async function readRunLabels(run: PolicyRunRecord): Promise { + for (let attempt = 0; attempt < LABEL_READ_ATTEMPTS; attempt++) { + if (attempt > 0) await delay(LABEL_READ_RETRY_MS * attempt); + let transientFailure = false; + for (const out of run.outputs) { + try { + const blob = await downloadPolicyOutput(out.fileId, run.target); + const file = new File([blob], out.fileName ?? run.fileName, { + type: blob.type || "application/pdf", + }); + const labels = await readClassificationLabelsFromFile(file); + if (labels && labels.length > 0) return labels; + } catch (err) { + if (!isNotFoundError(err)) transientFailure = true; + } + } + // Every output was read (or had aged out): there are no labels to apply. + if (!transientFailure) return null; + } + // Out of attempts. Settle the run unlabelled rather than spin forever; the + // file keeps its classification badge, just without tags. + return null; +} + +/** + * Stamp `labels` onto the run's live descendants in place (workspace + storage) + * — no versioned child, no history entry, only tags. Returns the tagged ids. + * + * Runs twice, because `resolveTargets` reads a rendered snapshot of the + * workspace: a CONSUME_FILES that was dispatched but not yet rendered when the + * first pass ran leaves its target already gone by the time UPDATE_FILE_RECORD + * is processed, so that stamp no-ops and the labels would be silently lost. The + * second pass sees the forked leaf and tags it. Each id is stamped at most once + * across both passes, so the pass costs nothing when no consume raced. + */ +async function stampClassificationLabels( + labels: string[], + resolveTargets: () => StirlingFileStub[], + ctx: ClassificationImportContext, +): Promise { + const updates = { classificationLabels: labels }; + const tagged = new Set(); + + for (let pass = 0; pass < 2; pass++) { + // Resolve and stamp the store in one synchronous block — no await between + // them, so a target can't be consumed in between. A consume AFTER the stamp + // is safe too: the CONSUME_FILES reducer carries classificationLabels onto + // the new leaf. + const fresh = resolveTargets().filter((s) => !tagged.has(s.id)); + for (const stub of fresh) { + tagged.add(stub.id); + ctx.updateStirlingFileStub(stub.id, updates); + } + + let mutated = false; + for (const stub of fresh) { + if (await fileStorage.updateFileMetadata(stub.id, updates)) + mutated = true; + } + if (mutated) ctx.bumpRevision(); + + // Yield a macrotask so React processes this pass's stamps (and any consume + // that raced them) before the next pass re-resolves. + if (pass === 0) await new Promise((resolve) => setTimeout(resolve)); + } + return Array.from(tagged); +} + +/** + * Deliver a classification run: read its labels and tag the live document with + * them. Metadata-only — nothing is versioned. + */ +async function importClassificationLabels( + run: PolicyRunRecord, + resolveTargets: () => StirlingFileStub[], + ctx: ClassificationImportContext, +): Promise { + if (resolveTargets().length === 0) { + // The document left the workspace (closed, or a server-reconciled run with + // no local input link) — nothing to tag. + updateRun(run.runId, { imported: true }); + return; + } + const labels = await readRunLabels(run); + const targetIds = + labels && labels.length > 0 + ? await stampClassificationLabels(labels, resolveTargets, ctx) + : []; + // Settle either way so it stops re-importing. outputFileIds are the TAGGED + // workspace files (no forked version), so their policy badge persists. Safe + // to chain-key on: classification is always last, so nothing chains off it. + updateRun(run.runId, { + imported: true, + importedFileIds: run.outputs.map((o) => o.fileId), + outputFileIds: targetIds, + }); +} + /** * Fetch a completed run's not-yet-imported output files and deliver them to the * workspace. Per-output, via allSettled: each output is tracked once delivered, @@ -737,6 +908,9 @@ async function runPolicyOnFile( backendId: string, fileId: FileId, fileName: string, + // Chained (downstream) dispatch — jumps the dispatch queue so a file mid-chain + // finishes its flow before new files start (see acquireDispatchSlot). + priority = false, ): Promise { // A freshly-uploaded file's bytes are written to IndexedDB asynchronously, so // its stub can appear in the file list a beat before getStirlingFile resolves @@ -762,6 +936,9 @@ async function runPolicyOnFile( markDispatched(categoryId, fileId); return; } + // Bounded upload window — see MAX_CONCURRENT_DISPATCHES. Only the POST is + // gated; the IDB wait above never holds a slot. + await acquireDispatchSlot(priority); try { const target = resolvePolicyRunTarget(); const runId = await runStoredPolicy(backendId, [file]); @@ -783,6 +960,8 @@ async function runPolicyOnFile( // the absent run simply won't appear in the activity feed. If the backend did // start a run we never recorded, reconcileServerRuns rediscovers it. markDispatched(categoryId, fileId); + } finally { + releaseDispatchSlot(); } } @@ -803,8 +982,10 @@ export async function poll( // would quit while a long step is still legitimately running. let budgetMs = DEFAULT_STEP_COUNT * STEP_TIMEOUT_MS + POLL_GRACE_MS; const startedAt = Date.now(); + let nextDelayMs = FIRST_POLL_MS; while (Date.now() - startedAt < budgetMs) { - await delay(POLL_MS); + await delay(nextDelayMs); + nextDelayMs = POLL_MS; let view; try { view = await getPolicyRun(runId); diff --git a/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx b/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx index 45dea92070..048bca15ac 100644 --- a/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx +++ b/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx @@ -11,6 +11,7 @@ import { import { ActionIcon } from "@app/ui/ActionIcon"; import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import CloseIcon from "@mui/icons-material/Close"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; import { useTranslation } from "react-i18next"; interface PolicyEnforcingOverlayProps { @@ -23,6 +24,9 @@ interface PolicyEnforcingOverlayProps { /** CSS colour var of the enforcing policy's accent (e.g. `var(--color-orange)`), * so the icon/spinner match that policy's badge instead of a fixed blue. */ accentVar?: string; + /** Category of the enforcing policy — picks its shared icon (shield for + * security, label for classification, …); generic shield when unknown. */ + categoryId?: string; } /** @@ -35,6 +39,7 @@ export function PolicyEnforcingOverlay({ zIndex = 200, onDismiss, accentVar, + categoryId, }: PolicyEnforcingOverlayProps) { const { t } = useTranslation(); if (!enforcing) return null; @@ -87,7 +92,11 @@ export function PolicyEnforcingOverlay({ : undefined } > - + {categoryId ? ( + policyCategoryIcon(categoryId, { fontSize: 26 }) + ) : ( + + )} {t("policy.enforcingTitle", "Enforcing policy…")} diff --git a/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx b/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx index 75fa9cb952..71d5294f2d 100644 --- a/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx +++ b/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx @@ -71,6 +71,7 @@ export function PolicyEnforcementOverlay({ runs }: Props) { progress={progress} onDismiss={() => setDismissed(true)} accentVar={policyAccentVar(inFlight.categoryId)} + categoryId={inFlight.categoryId} /> ); } diff --git a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx index e1d0cd96ab..1f04ea22ca 100644 --- a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx +++ b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx @@ -8,6 +8,7 @@ import { usePolicyRuns, type PolicyRunRecord, } from "@app/components/policies/policyRunStore"; +import { isClassificationCategory } from "@app/data/policyCategories"; import { PolicyEnforcementOverlay } from "@app/components/viewer/PolicyEnforcementOverlay"; type SignatureOverlayPassThrough = Pick< @@ -29,6 +30,8 @@ const Viewer = (props: ViewerProps & SignatureOverlayPassThrough) => { ? allRuns.filter( (r: PolicyRunRecord) => r.fileId === activeFileId && + // Classification runs async and must never block the viewer. + !isClassificationCategory(r.categoryId) && (POLICY_IN_FLIGHT_STATUSES.includes(r.status) || r.retrying === true), ) : []; diff --git a/frontend/editor/src/proprietary/data/policyCategories.test.ts b/frontend/editor/src/proprietary/data/policyCategories.test.ts new file mode 100644 index 0000000000..4b4fca2b7e --- /dev/null +++ b/frontend/editor/src/proprietary/data/policyCategories.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { + isClassificationCategory, + pinClassificationLast, +} from "@app/data/policyCategories"; + +describe("isClassificationCategory", () => { + it("recognises the classification category and nothing else", () => { + expect(isClassificationCategory("classification")).toBe(true); + expect(isClassificationCategory("security")).toBe(false); + expect(isClassificationCategory("")).toBe(false); + }); +}); + +describe("pinClassificationLast", () => { + it("moves classification to the end, preserving other order", () => { + expect( + pinClassificationLast(["classification", "security", "compliance"]), + ).toEqual(["security", "compliance", "classification"]); + }); + + it("leaves an order without classification untouched", () => { + expect(pinClassificationLast(["security", "compliance"])).toEqual([ + "security", + "compliance", + ]); + }); + + it("is a no-op when classification is already last", () => { + expect(pinClassificationLast(["security", "classification"])).toEqual([ + "security", + "classification", + ]); + }); + + it("handles classification as the only policy", () => { + expect(pinClassificationLast(["classification"])).toEqual([ + "classification", + ]); + }); +}); diff --git a/frontend/editor/src/proprietary/data/policyCategories.ts b/frontend/editor/src/proprietary/data/policyCategories.ts new file mode 100644 index 0000000000..820a93366b --- /dev/null +++ b/frontend/editor/src/proprietary/data/policyCategories.ts @@ -0,0 +1,21 @@ +/** The classification policy's catalog category id. */ +export const CLASSIFICATION_CATEGORY_ID = "classification"; + +/** + * Classification is metadata-only: it runs async (never blocks), never forks a + * version, and always runs last. This predicate gates that special handling. + */ +export function isClassificationCategory(categoryId: string): boolean { + return categoryId === CLASSIFICATION_CATEGORY_ID; +} + +/** + * Move classification to the end of an execution order (others keep their order), + * so a persisted/displayed order can't place it anywhere but last. + */ +export function pinClassificationLast(orderedCategoryIds: string[]): string[] { + return [ + ...orderedCategoryIds.filter((id) => !isClassificationCategory(id)), + ...orderedCategoryIds.filter((id) => isClassificationCategory(id)), + ]; +} diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index 8043e6b66a..389f98cd65 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -35,6 +35,7 @@ import { removePolicy, } from "@app/services/policyBackend"; import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi"; +import { pinClassificationLast } from "@app/data/policyCategories"; import type { PolicyToStore } from "@app/services/policyPipeline"; import type { PoliciesByCategory, @@ -326,9 +327,12 @@ export function usePolicies() { * first for an instant re-render; the next reconcile re-reads the server order. */ const reorderPolicies = useCallback((orderedCategoryIds: string[]) => { - persistPolicyOrder(orderedCategoryIds); + // Pin classification last so the persisted/server order matches execution + // (it always runs last — see usePolicyAutoRun). + const ordered = pinClassificationLast(orderedCategoryIds); + persistPolicyOrder(ordered); const current = loadPolicies(); - const backendIds = orderedCategoryIds + const backendIds = ordered .map((categoryId) => current[categoryId]?.backendId) .filter((id): id is string => !!id); if (backendIds.length > 0) { diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts index c5d8b9952b..2364321213 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect } from "vitest"; -import { buildPolicyBadgeMap } from "@app/hooks/usePolicyFileBadges"; +import { + buildPolicyBadgeMap, + reusePolicyBadgeArrays, +} from "@app/hooks/usePolicyFileBadges"; import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; -const NOW = 1_000_000; const labels = new Map([ ["security", "Security"], ["watermark", "Watermark"], + ["classification", "Classification"], ]); function run(overrides: Partial): PolicyRunRecord { @@ -20,29 +23,24 @@ function run(overrides: Partial): PolicyRunRecord { outputs: [], outputFileIds: ["out"], error: null, - startedAt: NOW - 1_000, // recent by default + startedAt: 0, ...overrides, }; } describe("buildPolicyBadgeMap — badge follows the document onto derived files", () => { - it("badges a policy's direct output, and marks it recent within the window", () => { - const map = buildPolicyBadgeMap([run({})], [{ id: "out" }], labels, NOW); - const badges = map.get("out") ?? []; - expect(badges.map((b) => b.id)).toEqual(["security"]); - expect(badges[0].recent).toBe(true); + it("badges a policy's direct output", () => { + const map = buildPolicyBadgeMap([run({})], [{ id: "out" }], labels); + expect((map.get("out") ?? []).map((b) => b.id)).toEqual(["security"]); }); - it("a versioned edit inherits the badge via parentFileId (never glows)", () => { + it("a versioned edit inherits the badge via parentFileId", () => { const map = buildPolicyBadgeMap( [run({})], [{ id: "out" }, { id: "edit", parentFileId: "out" }], labels, - NOW, ); - const edit = map.get("edit") ?? []; - expect(edit.map((b) => b.id)).toEqual(["security"]); - expect(edit[0].recent).toBe(false); + expect((map.get("edit") ?? []).map((b) => b.id)).toEqual(["security"]); }); it("SPLIT parts inherit the badge via sourceFileIds, though they have no parent", () => { @@ -56,11 +54,9 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" { id: "part2", sourceFileIds: ["out"] }, ], labels, - NOW, ); expect((map.get("part1") ?? []).map((b) => b.id)).toEqual(["security"]); expect((map.get("part2") ?? []).map((b) => b.id)).toEqual(["security"]); - expect((map.get("part1") ?? [])[0].recent).toBe(false); }); it("resolves transitively when an intermediate edit was consumed/removed", () => { @@ -70,7 +66,6 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" [run({})], [{ id: "part", sourceFileIds: ["editGone", "out"] }], labels, - NOW, ); expect((map.get("part") ?? []).map((b) => b.id)).toEqual(["security"]); }); @@ -83,7 +78,6 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" ], [{ id: "merged", sourceFileIds: ["a", "b"] }], labels, - NOW, ); expect((map.get("merged") ?? []).map((b) => b.id).sort()).toEqual([ "security", @@ -96,24 +90,33 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" [run({})], [{ id: "out" }, { id: "unrelated", sourceFileIds: ["someUpload"] }], labels, - NOW, ); expect(map.has("unrelated")).toBe(false); }); - it("inherited badges never glow even when the source run is recent", () => { + it("a completed classification run badges the files it tagged", () => { + // Classification is metadata-only: its outputFileIds are the tagged + // workspace files (no forked version), so the label badge persists there. const map = buildPolicyBadgeMap( - [run({ startedAt: NOW })], // maximally recent - [{ id: "out" }, { id: "part", sourceFileIds: ["out"] }], + [ + run({ + categoryId: "classification", + fileId: "in", + outputFileIds: ["in"], + imported: true, + }), + ], + [{ id: "in" }], labels, - NOW, ); - expect((map.get("out") ?? [])[0].recent).toBe(true); - expect((map.get("part") ?? [])[0].recent).toBe(false); + const badges = map.get("in") ?? []; + expect(badges.map((b) => b.id)).toEqual(["classification"]); + expect(badges[0].enforcing).toBeUndefined(); + expect(badges[0].background).toBeUndefined(); }); }); -describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", () => { +describe("buildPolicyBadgeMap — in-flight indicators", () => { const enforcingOn = ( map: Map, id: string, @@ -124,7 +127,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "RUNNING", outputFileIds: [] })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(map, "in")).toBe(true); }); @@ -136,7 +138,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "COMPLETED" })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(before, "in")).toBe(true); @@ -144,7 +145,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "COMPLETED", imported: true })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(after, "in")).toBe(false); }); @@ -155,7 +155,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status, outputFileIds: [] })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(map, "in")).toBe(false); } @@ -166,7 +165,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "FAILED", retrying: true, outputFileIds: [] })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(map, "in")).toBe(true); }); @@ -176,8 +174,97 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "RUNNING", fileId: "", outputFileIds: [] })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(map, "in")).toBe(false); }); + + it("an in-flight classification run is background, never enforcing", () => { + // Non-blocking: shows a spinner but must never trip the enforcing flag + // that gates actions and overlays. + const map = buildPolicyBadgeMap( + [ + run({ + categoryId: "classification", + status: "RUNNING", + outputFileIds: [], + }), + ], + [{ id: "in" }], + labels, + ); + const badges = map.get("in") ?? []; + expect(badges.map((b) => b.id)).toEqual(["classification"]); + expect(badges[0].background).toBe(true); + expect(enforcingOn(map, "in")).toBe(false); + }); +}); + +describe("reusePolicyBadgeArrays — per-file identity across rebuilds", () => { + // buildPolicyBadgeMap allocates fresh arrays every call and the run store hands + // back a new `runs` array on every status poll, so without this the memoized + // sidebar rows get a new `policies` prop for EVERY badged file on each tick. + const build = (runs: PolicyRunRecord[], stubs: { id: string }[]) => + buildPolicyBadgeMap(runs, stubs, labels); + + const twoFiles = [{ id: "a" }, { id: "b" }]; + // Settled + imported, so the badge is a plain one (a COMPLETED run keeps + // `enforcing` until its outputs land — see the in-flight tests above). + const settled = (id: string) => + run({ + runId: `r${id}`, + fileId: id, + outputFileIds: [id], + status: "COMPLETED", + imported: true, + }); + const bothSettled = () => [settled("a"), settled("b")]; + + it("returns the same map when nothing changed", () => { + const first = build(bothSettled(), twoFiles); + const second = reusePolicyBadgeArrays( + first, + build(bothSettled(), twoFiles), + ); + expect(second).toBe(first); + }); + + it("keeps the untouched file's array identity when another file changes", () => { + const first = build(bothSettled(), twoFiles); + // "a" goes in-flight; "b" is unaffected and must keep its exact array. + const next = build( + [ + run({ + runId: "ra", + fileId: "a", + outputFileIds: ["a"], + status: "RUNNING", + }), + settled("b"), + ], + twoFiles, + ); + const second = reusePolicyBadgeArrays(first, next); + expect(second).not.toBe(first); + expect(second.get("b")).toBe(first.get("b")); + expect(second.get("a")).not.toBe(first.get("a")); + expect((second.get("a") ?? [])[0].enforcing).toBe(true); + expect((first.get("a") ?? [])[0].enforcing).toBeUndefined(); + }); + + it("a new badged file doesn't disturb the existing files' arrays", () => { + const first = build(bothSettled(), twoFiles); + const next = build( + [...bothSettled(), settled("c")], + [...twoFiles, { id: "c" }], + ); + const second = reusePolicyBadgeArrays(first, next); + expect(second.get("a")).toBe(first.get("a")); + expect(second.get("b")).toBe(first.get("b")); + expect((second.get("c") ?? []).map((b) => b.id)).toEqual(["security"]); + }); + + it("passes the fresh map straight through on the first build", () => { + const map = build(bothSettled(), twoFiles); + expect(reusePolicyBadgeArrays(null, map)).toBe(map); + }); }); diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts index 40fcd0399e..52779b9cf8 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -1,17 +1,12 @@ -import { useMemo } from "react"; +import { useMemo, useRef } from "react"; import { usePolicyRuns } from "@app/components/policies/policyRunStore"; import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; import { useAllFiles } from "@app/contexts/FileContext"; import { loadPolicyCatalog } from "@app/services/policyCatalog"; import { policyAccentVar } from "@app/components/policies/policyStatus"; +import { isClassificationCategory } from "@app/data/policyCategories"; import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; -/** How long after a run a badge counts as "recent" (drives the one-off glow). - * Measured from run start — must exceed the longest realistic policy wall-clock - * time so the glow still fires after a slow run completes and imports. Old or - * reloaded runs fall outside this window, suppressing the glow on page reload. */ -const RECENT_MS = 5 * 60 * 1000; - /** Minimal provenance shape needed to resolve a file's inherited badges. */ type LineageStub = { id: string; @@ -19,14 +14,10 @@ type LineageStub = { sourceFileIds?: string[]; }; -/** Merge a ref into a list, deduping by policy id. A direct (recent) hit wins - * the glow over an inherited one for the same policy. */ +/** Merge a ref into a list, deduping by policy id. */ function mergeRef(list: FileItemPolicyRef[], ref: FileItemPolicyRef): void { - const existing = list.find((p) => p.id === ref.id); - if (!existing) { + if (!list.some((p) => p.id === ref.id)) { list.push(ref); - } else if (ref.recent) { - existing.recent = true; } } @@ -42,21 +33,18 @@ function mergeRef(list: FileItemPolicyRef[], ref: FileItemPolicyRef): void { * from: its transitive `sourceFileIds` (recorded at the consume boundary, so it * covers split/merge/convert too) plus, defensively, its `parentFileId`. * Because `sourceFileIds` is transitive, a flat lookup suffices — no chain walk, - * and it survives a consumed intermediate. Inherited badges never glow - * (recent=false): only the original application does. + * and it survives a consumed intermediate. */ export function buildPolicyBadgeMap( runs: ReadonlyArray, stubs: ReadonlyArray, labelById: ReadonlyMap, - now: number, ): Map { // Direct badges: a file that IS a policy run's output. const directByFile = new Map(); for (const run of runs) { const name = labelById.get(run.categoryId); if (!name) continue; - const recent = now - run.startedAt < RECENT_MS; for (const fileId of run.outputFileIds ?? []) { const list = directByFile.get(fileId) ?? []; if (!list.some((p) => p.id === run.categoryId)) { @@ -64,7 +52,6 @@ export function buildPolicyBadgeMap( id: run.categoryId, name, accentColor: policyAccentVar(run.categoryId), - recent, }); directByFile.set(fileId, list); } @@ -86,7 +73,7 @@ export function buildPolicyBadgeMap( // from. `sourceFileIds` is the transitive provenance set (so a flat lookup // catches even ancestors whose intermediate edits were consumed), and // `parentFileId` is included defensively for any child not created via a - // consume. Inherited badges are marked recent=false (carried, not applied). + // consume. for (const stub of stubs) { const sources = new Set(stub.sourceFileIds ?? []); if (stub.parentFileId) sources.add(stub.parentFileId); @@ -94,14 +81,17 @@ export function buildPolicyBadgeMap( const srcBadges = directByFile.get(src); if (!srcBadges?.length) continue; const list = result.get(stub.id) ?? []; - for (const ref of srcBadges) mergeRef(list, { ...ref, recent: false }); + for (const ref of srcBadges) mergeRef(list, { ...ref }); result.set(stub.id, list); } } // In-flight pass: add (or upgrade) a badge on the input file for any run that // is currently being processed, so the sidebar shows a spinning indicator - // while the policy is actively enforcing — not just after it completes. + // while the policy is actively running — not just after it completes. + // Blocking policies set `enforcing` (which gates actions/overlays); + // classification is non-blocking, so it sets `background` instead — same + // spinner, but nothing is ever gated on it. // Keep the spinner until `imported` is true: the status reaches COMPLETED // before the output files are imported into the workspace, so gating on // status alone would drop the badge during that async gap. @@ -112,17 +102,19 @@ export function buildPolicyBadgeMap( if (settled && !run.retrying) continue; const name = labelById.get(run.categoryId); if (!name) continue; + const inFlightFlag = isClassificationCategory(run.categoryId) + ? ("background" as const) + : ("enforcing" as const); const list = result.get(run.fileId) ?? []; const existing = list.find((p) => p.id === run.categoryId); if (existing) { - existing.enforcing = true; + existing[inFlightFlag] = true; } else { list.push({ id: run.categoryId, name, accentColor: policyAccentVar(run.categoryId), - recent: false, - enforcing: true, + [inFlightFlag]: true, }); result.set(run.fileId, list); } @@ -131,20 +123,70 @@ export function buildPolicyBadgeMap( return result; } +/** Field-wise equality for a badge ref — the whole shape `PolicyBadges` renders. */ +function sameRef(a: FileItemPolicyRef, b: FileItemPolicyRef): boolean { + return ( + a.id === b.id && + a.name === b.name && + a.accentColor === b.accentColor && + !!a.enforcing === !!b.enforcing && + !!a.background === !!b.background + ); +} + +function sameRefs(a: FileItemPolicyRef[], b: FileItemPolicyRef[]): boolean { + return a.length === b.length && a.every((ref, i) => sameRef(ref, b[i])); +} + +/** + * Carry the previous map's array references over to files whose badges didn't + * change, and return the previous MAP itself when none did. + * + * {@link buildPolicyBadgeMap} allocates a fresh array per badged file on every + * call, and the run store hands back a new `runs` array on every status poll — + * so without this, one file's poll tick gives EVERY badged file a new `policies` + * identity, and the memoized sidebar rows can never bail out (the case the + * memoization exists for). `NO_POLICIES` in FileSidebar only covers the rows + * with no badges at all. + */ +export function reusePolicyBadgeArrays( + previous: Map | null, + next: Map, +): Map { + if (!previous) return next; + let changed = previous.size !== next.size; + for (const [fileId, refs] of next) { + const before = previous.get(fileId); + if (before && sameRefs(before, refs)) next.set(fileId, before); + else changed = true; + } + return changed ? next : previous; +} + /** * Distinct policies that have produced each file, keyed by fileId, derived from * the reactive policy run store. Drives the file sidebar's shield badges. The * badge follows a document down its tool-edit chain — see * {@link buildPolicyBadgeMap}. Shadows the core stub via the {@code @app/*} * alias cascade. + * + * Per-file array identity is preserved across rebuilds so memoized consumers + * (the sidebar rows) only re-render for the file that actually changed — see + * {@link reusePolicyBadgeArrays}. */ export function usePolicyFileBadges(): Map { const runs = usePolicyRuns(); const { fileStubs } = useAllFiles(); + const previous = useRef | null>(null); return useMemo(() => { const labelById = new Map( loadPolicyCatalog().categories.map((c) => [c.id, c.label]), ); - return buildPolicyBadgeMap(runs, fileStubs, labelById, Date.now()); + const map = reusePolicyBadgeArrays( + previous.current, + buildPolicyBadgeMap(runs, fileStubs, labelById), + ); + previous.current = map; + return map; }, [runs, fileStubs]); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 970d6703cd..7db904ddf6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -84,6 +84,7 @@ "signature_pad": "^5.0.4", "smol-toml": "^1.4.2", "tailwindcss": "^4.1.13", + "use-sync-external-store": "^1.6.0", "web-vitals": "^5.1.0" }, "devDependencies": { @@ -109,6 +110,7 @@ "@types/node": "^24.5.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.1.9", + "@types/use-sync-external-store": "^0.0.6", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", "@typescript/native": "npm:typescript@^7.0.2", diff --git a/frontend/package.json b/frontend/package.json index 1763a2126a..dfcfa2842c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -81,6 +81,7 @@ "signature_pad": "^5.0.4", "smol-toml": "^1.4.2", "tailwindcss": "^4.1.13", + "use-sync-external-store": "^1.6.0", "web-vitals": "^5.1.0" }, "scripts": { @@ -131,6 +132,7 @@ "@types/node": "^24.5.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.1.9", + "@types/use-sync-external-store": "^0.0.6", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", "@typescript/native": "npm:typescript@^7.0.2", From a2dd0298dc7931c1e7f202343a75937a4edd889a Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:04 +0200 Subject: [PATCH 072/122] refactor(api): replace length checks with isEmpty (#7214) # Description of Changes Stylistic problem reported by static analyzer. Changes: * Replaced `sb.length() > 0` and `sb.length() == 0` with `!sb.isEmpty()` and `sb.isEmpty()` for `StringBuilder`, `String`, and collections throughout the codebase, improving readability and aligning with modern Java best practices. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../common/pdf/PdfMarkdownConverter.java | 2 +- .../software/common/util/GeneralUtils.java | 2 +- .../common/pdf/PdfMarkdownConverterTest.java | 2 +- .../SPDF/config/ExternalAppDepConfig.java | 2 +- .../SPDF/controller/api/UIDataController.java | 2 +- .../api/security/PasswordController.java | 8 ++++---- .../api/security/RedactExecuteService.java | 18 +++++++++--------- .../SPDF/controller/web/MetricsController.java | 6 +++--- .../SPDF/service/HardwareKeyStoreService.java | 4 ++-- .../controller/api/UserController.java | 2 +- .../security/service/UserService.java | 2 +- .../service/PortalInfraAuditService.java | 2 +- .../service/UserLicenseSettingsService.java | 4 ++-- 13 files changed, 28 insertions(+), 28 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java b/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java index c19468b5ed..73f2d7f5ad 100644 --- a/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java +++ b/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java @@ -983,7 +983,7 @@ public class PdfMarkdownConverter { ordered.sort(Comparator.comparingDouble((Line l) -> l.y).reversed()); StringBuilder sb = new StringBuilder(); for (Line l : ordered) { - if (sb.length() > 0) { + if (!sb.isEmpty()) { sb.append(' '); } sb.append(l.text); diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index 4a9ef0834b..52f79f733a 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -941,7 +941,7 @@ public class GeneralUtils { } // If no MAC address found, use hostname as fallback - if (sb.length() == 0) { + if (sb.isEmpty()) { String hostname = InetAddress.getLocalHost().getHostName(); sb.append(hostname != null ? hostname : "unknown-host"); log.warn("No MAC address found, using hostname for fingerprint generation"); diff --git a/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterTest.java b/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterTest.java index b3c104da85..7e1d3d2e35 100644 --- a/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterTest.java +++ b/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterTest.java @@ -154,7 +154,7 @@ class PdfMarkdownConverterTest { || isTableSeparatorRow(line)) { continue; } - if (sb.length() > 0) { + if (!sb.isEmpty()) { sb.append('\n'); } sb.append(line); diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index 8755dfe2ef..46b1976ca7 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -321,7 +321,7 @@ public class ExternalAppDepConfig { new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { String line; while ((line = br.readLine()) != null) { - if (sb.length() > 0) sb.append('\n'); + if (!sb.isEmpty()) sb.append('\n'); sb.append(line); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index de391c7c32..a3ed09fe5d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -130,7 +130,7 @@ public class UIDataController { objectMapper.readValue( config, new TypeReference>() {}); String name = (String) jsonContent.get("name"); - if (name == null || name.length() < 1) { + if (name == null || name.isEmpty()) { String filename = jsonFiles .get(pipelineConfigs.indexOf(config)) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java index 690c82ca8f..2ad494fc63 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java @@ -124,15 +124,15 @@ public class PasswordController { StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPassword, password, ap); - if ((ownerPassword != null && ownerPassword.length() > 0) - || (password != null && password.length() > 0)) { + if ((ownerPassword != null && !ownerPassword.isEmpty()) + || (password != null && !password.isEmpty())) { spp.setEncryptionKeyLength(keyLength); } spp.setPermissions(ap); document.protect(spp); - if ((ownerPassword == null || ownerPassword.length() == 0) - && (password == null || password.length() == 0)) + if ((ownerPassword == null || ownerPassword.isEmpty()) + && (password == null || password.isEmpty())) return WebResponseUtils.pdfDocToWebResponse( document, GeneralUtils.generateFilename( diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java index 4a53be97b6..c43abcf666 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java @@ -760,12 +760,12 @@ class RedactExecuteService { char ch = raw.charAt(i); if (Character.isLetterOrDigit(ch)) { current.append(ch); - } else if (current.length() > 0) { + } else if (!current.isEmpty()) { tokens.add(current.toString()); current.setLength(0); } } - if (current.length() > 0) tokens.add(current.toString()); + if (!current.isEmpty()) tokens.add(current.toString()); if (tokens.size() < 2) return null; StringBuilder out = new StringBuilder(); for (int i = 0; i < tokens.size(); i++) { @@ -788,25 +788,25 @@ class RedactExecuteService { StringBuilder current = new StringBuilder(); for (String token : tokens) { if (token.isEmpty()) { - if (current.length() > 0) { - if (result.length() > 0) result.append(' '); + if (!current.isEmpty()) { + if (!result.isEmpty()) result.append(' '); result.append(current); current.setLength(0); } } else if (token.length() == 1) { current.append(token); } else { - if (current.length() > 0) { - if (result.length() > 0) result.append(' '); + if (!current.isEmpty()) { + if (!result.isEmpty()) result.append(' '); result.append(current); current.setLength(0); } - if (result.length() > 0) result.append(' '); + if (!result.isEmpty()) result.append(' '); result.append(token); } } - if (current.length() > 0) { - if (result.length() > 0) result.append(' '); + if (!current.isEmpty()) { + if (!result.isEmpty()) result.append(' '); result.append(current); } return result.toString().trim(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java index de28d66ca9..4bbf3dfb82 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java @@ -251,7 +251,7 @@ public class MetricsController { // For GET requests, validate if we have a list of valid endpoints final boolean validateGetEndpoints = - endpointInspector.getValidGetEndpoints().size() != 0; + !endpointInspector.getValidGetEndpoints().isEmpty(); if ("GET".equals(method) && validateGetEndpoints && !endpointInspector.isValidGetEndpoint(uri)) { @@ -292,7 +292,7 @@ public class MetricsController { // For GET requests, validate if we have a list of valid endpoints final boolean validateGetEndpoints = - endpointInspector.getValidGetEndpoints().size() != 0; + !endpointInspector.getValidGetEndpoints().isEmpty(); if ("GET".equals(method) && validateGetEndpoints && !endpointInspector.isValidGetEndpoint(uri)) { @@ -332,7 +332,7 @@ public class MetricsController { // For GET requests, validate if we have a list of valid endpoints final boolean validateGetEndpoints = - endpointInspector.getValidGetEndpoints().size() != 0; + !endpointInspector.getValidGetEndpoints().isEmpty(); if ("GET".equals(method) && validateGetEndpoints && !endpointInspector.isValidGetEndpoint(uri)) { diff --git a/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java b/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java index 988b935f27..93ca36d08e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java @@ -237,12 +237,12 @@ public class HardwareKeyStoreService { combined.append(env); } if (prop != null && !prop.isBlank()) { - if (combined.length() > 0) { + if (!combined.isEmpty()) { combined.append(java.io.File.pathSeparator); } combined.append(prop); } - if (combined.length() == 0) { + if (combined.isEmpty()) { return List.of(); } return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]")) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index b19c052ff1..fdacda72b2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -199,7 +199,7 @@ public class UserController { return ResponseEntity.status(HttpStatus.CONFLICT) .body(Map.of("error", "usernameExists", "message", "Username already exists")); } - if (newUsername != null && newUsername.length() > 0) { + if (newUsername != null && !newUsername.isEmpty()) { try { userService.changeUsername(user, newUsername); } catch (IllegalArgumentException e) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 0cb4653ef1..e08dd52d07 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -205,7 +205,7 @@ public class UserService implements UserServiceInterface { User user = findByUsernameIgnoreCase(username) .orElseThrow(() -> new UsernameNotFoundException("User not found")); - if (user.getApiKey() == null || user.getApiKey().length() == 0) { + if (user.getApiKey() == null || user.getApiKey().isEmpty()) { user = addApiKeyToUser(username); } return user.getApiKey(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java index c47df3649f..86f944ca9d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java @@ -224,7 +224,7 @@ public class PortalInfraAuditService { if (word.isEmpty()) { continue; } - if (sb.length() > 0) { + if (!sb.isEmpty()) { sb.append(' '); } String lower = word.toLowerCase(Locale.ROOT); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java index 54660a1ccb..085a9ffcf1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -515,7 +515,7 @@ public class UserLicenseSettingsService { appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getUUID()); appendIfPresent(builder, applicationProperties.getPremium().getKey()); - if (builder.length() == 0) { + if (builder.isEmpty()) { builder.append(DEFAULT_INTEGRITY_SECRET); } @@ -524,7 +524,7 @@ public class UserLicenseSettingsService { private void appendIfPresent(StringBuilder builder, String value) { if (value != null && !value.isBlank()) { - if (builder.length() > 0) { + if (!builder.isEmpty()) { builder.append(SIGNATURE_SEPARATOR); } builder.append(value); From e5c6ceedc52a3aee263ffb20ca2ff4813ca9799c Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:25 +0200 Subject: [PATCH 073/122] fix(ui): resolve double scrollbar issue in Sidebar Categories modal (#7142) # Description of Changes Resolves double scrollbar design bug. ### New image ### Old image --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../proprietary/components/shared/FileSidebarGroupControls.css | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.css b/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.css index 16712e351a..0889dfe3d1 100644 --- a/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.css +++ b/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.css @@ -3,9 +3,6 @@ display: flex; flex-direction: column; gap: 12px; - max-height: 60vh; - overflow-y: auto; - padding-right: 4px; } .fsg-footer { From 732a6025038edfa6250b11b32ce100f9116d5273 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:43 +0200 Subject: [PATCH 074/122] style(scanner-effect): remove padding from ToolButton of Scanner-effect (#7205) # Description of Changes ### New image ### Old image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../editor/src/core/components/tools/toolPicker/ToolButton.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx index 78c573bb38..4792fd721a 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx @@ -286,7 +286,7 @@ const ToolButton: React.FC = ({ accent="neutral" onClick={() => handleClick(id)} size="sm" - p="sm" + p="none" fullWidth justify="start" className="tool-button" @@ -297,6 +297,7 @@ const ToolButton: React.FC = ({ borderRadius: 0, cursor: visuallyUnavailable ? "not-allowed" : undefined, overflow: "visible", + ...selectedBg, }} > {buttonContent} From cc1c6bc9e37ecc4904b74a489fa6f0d4a6f98d4e Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:01:46 +0200 Subject: [PATCH 075/122] style(people): Fix convert dropdown single-category formatting and admin table header styling (#7139) # Description of Changes The dropdown table with the people looked out-place mainly due to the blue, i think... this simplifies and make more consistent with the rest of the "new" UI and not so aggresive with the colour schema. ### New: image ### Old: image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../config/configSections/PeopleSection.tsx | 24 ++++--------------- .../configSections/TeamDetailsSection.tsx | 24 ++++--------------- .../config/configSections/TeamsSection.tsx | 9 +------ 3 files changed, 9 insertions(+), 48 deletions(-) diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index 10e9f39fb0..d955cb341b 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -588,38 +588,22 @@ export default function PeopleSection() { {/* Members Table */} - +
    - - + + {t("workspace.people.user")} {t("workspace.people.role")} - + {t("workspace.people.team")} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx index cabdfb642c..d3702e7f80 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx @@ -408,29 +408,13 @@ export default function TeamDetailsSection({ {/* Members Table */} -
    +
    - - + + {t("workspace.people.user")} - + {t("workspace.people.role")} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx index 4559296afe..d4da200d59 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx @@ -298,19 +298,13 @@ export default function TeamsSection() { verticalSpacing="sm" withRowBorders highlightOnHover - style={ - { - "--table-border-color": "var(--mantine-color-gray-3)", - } as React.CSSProperties - } > - + {t("workspace.teams.teamName")} @@ -319,7 +313,6 @@ export default function TeamsSection() { style={{ fontWeight: 600, fontSize: "0.875rem", - color: "var(--mantine-color-gray-7)", }} > {t("workspace.teams.totalMembers")} From 9aaf4173036f4ebca74c8ea8379e4362a17bd969 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:11:26 +0200 Subject: [PATCH 076/122] perf(ui): lazy-load MobileScannerPage and optimize bundle splitting (#7122) # Description of Changes This pull request improves the frontend's performance and code organization by optimizing how certain pages are loaded and by updating the application's bundle splitting strategy. Changes: * Updated the `manualChunks` configuration in `vite.config.ts` to more granularly split vendor dependencies into separate chunks based on their library or usage, which can improve caching and load performance. * Updated MobileScanner code to be lazy loaded --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- frontend/editor/src/core/App.tsx | 5 ++-- frontend/editor/src/proprietary/App.tsx | 5 ++-- frontend/editor/src/saas/App.tsx | 5 ++-- frontend/editor/vite.config.ts | 33 ++++++++++++++++++++++--- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index 566e861520..6fd23e5510 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -1,4 +1,4 @@ -import { Suspense } from "react"; +import { Suspense, lazy } from "react"; import { Routes, Route } from "react-router-dom"; import { AppProviders } from "@app/components/AppProviders"; import { AppLayout } from "@app/components/AppLayout"; @@ -6,9 +6,10 @@ import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import { ThemeProvider } from "@app/components/shared/ThemeProvider"; import { PreferencesProvider } from "@app/contexts/PreferencesContext"; import HomePage from "@app/pages/HomePage"; -import MobileScannerPage from "@app/pages/MobileScannerPage"; import Onboarding from "@app/components/onboarding/Onboarding"; +const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); + // Import global styles import "@app/styles/tailwind.css"; import "@app/styles/cookieconsent.css"; diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index e98064ebc8..a2103bd60a 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -1,4 +1,4 @@ -import { Suspense } from "react"; +import { Suspense, lazy } from "react"; import { Routes, Route, useParams } from "react-router-dom"; import { AppProviders } from "@app/components/AppProviders"; import { AppLayout } from "@app/components/AppLayout"; @@ -12,9 +12,10 @@ import AuthCallback from "@app/routes/AuthCallback"; import InviteAccept from "@app/routes/InviteAccept"; import ShareLinkPage from "@app/routes/ShareLinkPage"; import ParticipantView from "@app/components/workflow/ParticipantView"; -import MobileScannerPage from "@app/pages/MobileScannerPage"; import Onboarding from "@app/components/onboarding/Onboarding"; import WatchedFoldersRegistration from "@app/components/watchedFolders/WatchedFoldersRegistration"; + +const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 32af9a6782..9509f630b0 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -1,4 +1,4 @@ -import { Suspense, type ReactNode } from "react"; +import { Suspense, lazy, type ReactNode } from "react"; import { Routes, Route, useLocation } from "react-router-dom"; import { isAuthRoute } from "@app/utils/pathUtils"; import { AppProviders } from "@app/components/AppProviders"; @@ -16,13 +16,14 @@ import AuthCallback from "@app/routes/AuthCallback"; import ResetPassword from "@app/routes/ResetPassword"; import OAuthConsent from "@app/routes/OAuthConsent"; import ShareLinkPage from "@app/routes/ShareLinkPage"; -import MobileScannerPage from "@app/pages/MobileScannerPage"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; +const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); + // Import global styles import "@app/styles/tailwind.css"; import "@app/auth/ui/auth-theme.css"; diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 0d367d2465..2e72d1efec 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -358,9 +358,36 @@ export default defineConfig(async ({ mode, command }) => { target: "esnext", rollupOptions: { output: { - manualChunks: { - "vendor-react": ["react", "react-dom"], - "pdf-engine": ["@embedpdf/engines", "@embedpdf/pdfium"], + manualChunks(id) { + if (id.includes("material-symbols-icons.json")) + return "vendor-iconset"; + if (id.includes("node_modules")) { + if (id.includes("pdfjs-dist")) return "vendor-pdfjs"; + if (id.includes("@embedpdf")) return "vendor-embedpdf"; + if ( + id.includes("react") || + id.includes("@mantine") || + id.includes("@emotion") || + id.includes("@mui") || + id.includes("@iconify") + ) { + return "vendor-ui"; + } + if (id.includes("@supabase")) return "vendor-supabase"; + if (id.includes("posthog-js") || id.includes("@posthog")) + return "vendor-posthog"; + if (id.includes("@cantoo/pdf-lib") || id.includes("pdf-lib")) + return "vendor-pdflib"; + if ( + id.includes("recharts") || + id.includes("d3") || + id.includes("decimal.js") + ) + return "vendor-charts"; + if (id.includes("jszip") || id.includes("pako")) + return "vendor-zip"; + if (id.includes("i18next")) return "vendor-i18n"; + } }, }, }, From 934ad180cb9cf87aa2709c1b40f4e5ca4ee2f223 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:16:15 +0200 Subject: [PATCH 077/122] fix(ui): handle 404 policy error on upload without toast popup (#7254) # Description of Changes Fixes policy error pop-ups that sometimes happen upon upload. Changes: - Improved the error handling in `runPolicyOnFile` to log detailed debug information when policy dispatch fails, making it easier to trace issues such as missing policies or backend errors. - Updated the `runStoredPolicy` function to pass `{ suppressErrorToast: true }` to the API client, preventing error toasts from appearing in the UI when the policy run fails. --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../proprietary/components/policies/usePolicyAutoRun.ts | 8 ++++++-- frontend/editor/src/proprietary/services/policyApi.ts | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index e475d784ea..597136a32f 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -955,10 +955,14 @@ async function runPolicyOnFile( error: null, startedAt: Date.now(), }); - } catch { - // Dispatch failed (offline / backend error). Mark dispatched so we don't hammer; + } catch (err) { + // Dispatch failed (e.g. policy deleted/404 or backend offline). Mark dispatched so we don't hammer; // the absent run simply won't appear in the activity feed. If the backend did // start a run we never recorded, reconcileServerRuns rediscovers it. + console.debug( + `[PolicyAutoRun] Failed to dispatch policy ${categoryId} (${backendId}):`, + err, + ); markDispatched(categoryId, fileId); } finally { releaseDispatchSlot(); diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts index a10c7a8382..b0d78f36f7 100644 --- a/frontend/editor/src/proprietary/services/policyApi.ts +++ b/frontend/editor/src/proprietary/services/policyApi.ts @@ -73,6 +73,7 @@ export async function runStoredPolicy( const res = await apiClient.post( `/api/v1/policies/${encodeURIComponent(id)}/run`, form, + { suppressErrorToast: true }, ); return res.data.jobId; } From 7cccee4c346c5bf10058bb7e570c23f7374bb9f5 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:17:27 +0200 Subject: [PATCH 078/122] refactor(get-info): remove redundant PDF validation logic (#7213) # Description of Changes Could not get past validation, since very few endpoint have such validation, i think redundant. Changes: * Removed the `validatePdfFile` method, which previously checked for file presence, size limits, and content type, from `GetInfoOnPDF.java`. * Deleted the invocation of `validatePdfFile` and its associated error handling from the `getPdfInfo` method, so uploaded files are no longer validated at this layer. --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../controller/api/security/GetInfoOnPDF.java | 28 ------- .../api/security/GetInfoOnPDFMoreTest.java | 15 ---- .../api/security/GetInfoOnPDFTest.java | 76 ------------------- 3 files changed, 119 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java index d29480fd3c..d2775e910b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java @@ -61,7 +61,6 @@ import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; -import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.RegexPatternUtils; import stirling.software.common.util.WebResponseUtils; @@ -270,25 +269,6 @@ public class GetInfoOnPDF { } } - private static void validatePdfFile(MultipartFile file) { - if (file == null || file.isEmpty()) { - throw new IllegalArgumentException("PDF file is required"); - } - - if (file.getSize() > MAX_FILE_SIZE) { - throw ExceptionUtils.createIllegalArgumentException( - "error.fileSizeLimit", - "File size ({0} bytes) exceeds maximum allowed size ({1} bytes)", - file.getSize(), - MAX_FILE_SIZE); - } - - String contentType = file.getContentType(); - if (contentType != null && !"application/pdf".equals(contentType)) { - log.warn("File content type is {}, expected application/pdf", contentType); - } - } - private static ResponseEntity createErrorResponse(String errorMessage) { try { ObjectNode errorNode = objectMapper.createObjectNode(); @@ -1104,14 +1084,6 @@ public class GetInfoOnPDF { public ResponseEntity getPdfInfo(@ModelAttribute PDFFile request) throws IOException { MultipartFile inputFile = request.getFileInput(); - // Validate input - try { - validatePdfFile(inputFile); - } catch (IllegalArgumentException e) { - log.error("Invalid PDF file: {}", e.getMessage()); - return createErrorResponse("Invalid PDF file: " + e.getMessage()); - } - List verificationResults = null; try { verificationResults = veraPDFService.validatePDF(inputFile.getInputStream()); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java index 7e2aa388fa..1df66f739e 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java @@ -264,21 +264,6 @@ class GetInfoOnPDFMoreTest { @DisplayName("error handling") class Errors { - @Test - @DisplayName("empty file input yields an error response") - void emptyFile() throws Exception { - MockMultipartFile mf = - new MockMultipartFile("fileInput", "x.pdf", "application/pdf", new byte[0]); - PDFFile request = new PDFFile(); - request.setFileInput(mf); - ResponseEntity resp = getInfoOnPDF.getPdfInfo(request); - // createErrorResponse returns HTTP 200 with a JSON body carrying an "error" field. - assertThat(resp.getBody()).isNotNull(); - JsonNode body = om.readTree(resp.getBody()); - assertThat(body.has("error")).isTrue(); - assertThat(body.get("error").asText("")).contains("Invalid"); - } - @Test @DisplayName("veraPDF failure is swallowed and a report is still produced") void veraPdfFailureSwallowed() throws Exception { diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java index efc09cf190..8d17d729ea 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java @@ -556,24 +556,6 @@ class GetInfoOnPDFTest { @DisplayName("Validation and Error Handling Tests") class ValidationErrorTests { - @Test - @DisplayName("Should reject null file") - void testValidation_NullFile() throws IOException { - PDFFile request = new PDFFile(); - request.setFileInput(null); - - ResponseEntity response = getInfoOnPDF.getPdfInfo(request); - - Assertions.assertEquals( - HttpStatus.OK, response.getStatusCode()); // Returns error JSON with 200 - String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8); - JsonNode jsonNode = objectMapper.readTree(jsonResponse); - - Assertions.assertTrue(jsonNode.has("error")); - Assertions.assertTrue( - jsonNode.get("error").asText("").contains("PDF file is required")); - } - @Test @DisplayName("Should reject empty file") void testValidation_EmptyFile() throws IOException { @@ -591,64 +573,6 @@ class GetInfoOnPDFTest { Assertions.assertTrue(jsonNode.has("error")); } - - @Test - @DisplayName("Should reject file that exceeds max size") - void testValidation_TooLargeFile() throws IOException { - MultipartFile largeFile = - new MultipartFile() { - @Override - public String getName() { - return "file"; - } - - @Override - public String getOriginalFilename() { - return "large.pdf"; - } - - @Override - public String getContentType() { - return MediaType.APPLICATION_PDF_VALUE; - } - - @Override - public boolean isEmpty() { - return false; - } - - @Override - public long getSize() { - // Report 101 MB without allocating memory - return 101L * 1024L * 1024L; - } - - @Override - public byte[] getBytes() { - return new byte[0]; - } - - @Override - public java.io.InputStream getInputStream() { - return java.io.InputStream.nullInputStream(); - } - - @Override - public void transferTo(java.io.File dest) throws IllegalStateException {} - }; - - PDFFile request = new PDFFile(); - request.setFileInput(largeFile); - - ResponseEntity response = getInfoOnPDF.getPdfInfo(request); - - String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8); - JsonNode jsonNode = objectMapper.readTree(jsonResponse); - - Assertions.assertTrue(jsonNode.has("error")); - Assertions.assertTrue( - jsonNode.get("error").asText("").contains("exceeds maximum allowed size")); - } } @Nested From e560ee4cc45e1bde1e71012f888e05f83add8b86 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:25:22 +0200 Subject: [PATCH 079/122] chore(deps): update junrar dependency to version 8.0.0 (#7210) # Description of Changes Since this was a major version update i tested manually, afterwards figured i'll submit as PR. This version of junrar adds long-awaited (by me) RAR 5 support to the library. RAR 5 is newest version of the RAR file format and was not available in previous Junrar version, but is somewhat common for CBR files to be RAR 5. For junrar release notes see: https://github.com/junrar/junrar/releases/tag/v8.0.0 Changes: - Bumped junrar dep to version 8.0.0 --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- app/common/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/common/build.gradle b/app/common/build.gradle index 73be441940..942dddc5fd 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -16,7 +16,7 @@ dependencies { api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion" api "org.apache.pdfbox:xmpbox:$pdfboxVersion" api "org.apache.pdfbox:preflight:$pdfboxVersion" - api 'com.github.junrar:junrar:7.6.0' // RAR archive support for CBR files + api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files api 'jakarta.servlet:jakarta.servlet-api:6.1.0' api 'org.snakeyaml:snakeyaml-engine:3.0.1' api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" From 866e56728d11a8e9e6c5313cdb860e5e814abac1 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 13:06:16 +0200 Subject: [PATCH 080/122] fix(storage): delete share access records before expired share links (#7161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - Updated expired share-link cleanup to delete related `FileShareAccess` records before deleting their parent `FileShare` records. - Wrapped the cleanup operation in a transaction to ensure the deletion order is enforced atomically. - Prevents foreign-key constraint violations and scheduled-task failures during cleanup. - The full backend check was limited by a Gradle distribution download/network error. ```cmd [backend:dev:proprietary] 16:25:43.362 [scheduled-vt-2] WARN org.hibernate.orm.jdbc.error - HHH000247: ErrorCode: 23503, SQLState: 23503 [backend:dev:proprietary] 16:25:43.362 [scheduled-vt-2] WARN org.hibernate.orm.jdbc.error - Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))" [backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement: [backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240] [backend:dev:proprietary] 16:25:43.380 [scheduled-vt-2] ERROR o.s.s.s.TaskUtils$LoggingErrorHandler - Unexpected error occurred in scheduled task [backend:dev:proprietary] org.springframework.dao.DataIntegrityViolationException: could not execute statement [Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))" [backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement: [backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240]] [delete from file_shares where file_share_id=?]; SQL [delete from file_shares where file_share_id=?]; constraint [FKQ6V4QH5LFCAWII0ABRVSJO5SG] [backend:dev:proprietary] at org.springframework.orm.jpa.hibernate.HibernateExceptionTranslator.convertHibernateAccessException(HibernateExceptionTranslator.java:169) [backend:dev:proprietary] at org.springframework.orm.jpa.hibernate.HibernateExceptionTranslator.convertHibernateAccessException(HibernateExceptionTranslator.java:131) [backend:dev:proprietary] at org.springframework.orm.jpa.hibernate.HibernateExceptionTranslator.translateExceptionIfPossible(HibernateExceptionTranslator.java:105) [backend:dev:proprietary] at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:223) [backend:dev:proprietary] at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:557) [backend:dev:proprietary] at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:794) [backend:dev:proprietary] at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:757) [backend:dev:proprietary] at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:687) [backend:dev:proprietary] at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:408) [backend:dev:proprietary] at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:130) [backend:dev:proprietary] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) [backend:dev:proprietary] at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:135) [backend:dev:proprietary] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) [backend:dev:proprietary] at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:166) [backend:dev:proprietary] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) [backend:dev:proprietary] at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:222) [backend:dev:proprietary] at jdk.proxy4/jdk.proxy4.$Proxy246.deleteAll(Unknown Source) [backend:dev:proprietary] at stirling.software.proprietary.storage.service.StorageCleanupService.cleanupExpiredShareLinks(StorageCleanupService.java:71) [backend:dev:proprietary] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) [backend:dev:proprietary] at java.base/java.lang.reflect.Method.invoke(Method.java:565) [backend:dev:proprietary] at org.springframework.scheduling.support.ScheduledMethodRunnable.runInternal(ScheduledMethodRunnable.java:128) [backend:dev:proprietary] at org.springframework.scheduling.support.ScheduledMethodRunnable.lambda$run$1(ScheduledMethodRunnable.java:122) [backend:dev:proprietary] at io.micrometer.observation.Observation.observe(Observation.java:569) [backend:dev:proprietary] at org.springframework.scheduling.support.ScheduledMethodRunnable.run(ScheduledMethodRunnable.java:122) [backend:dev:proprietary] at org.springframework.scheduling.config.Task$OutcomeTrackingRunnable.run(Task.java:88) [backend:dev:proprietary] at org.springframework.scheduling.support.DelegatingErrorHandlingRunnable.run(DelegatingErrorHandlingRunnable.java:54) [backend:dev:proprietary] at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:545) [backend:dev:proprietary] at java.base/java.util.concurrent.FutureTask.runAndReset(FutureTask.java:369) [backend:dev:proprietary] at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:310) [backend:dev:proprietary] at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090) [backend:dev:proprietary] at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614) [backend:dev:proprietary] at java.base/java.lang.VirtualThread.run(VirtualThread.java:460) [backend:dev:proprietary] Caused by: org.hibernate.exception.ConstraintViolationException: could not execute statement [Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))" [backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement: [backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240]] [delete from file_shares where file_share_id=?] [backend:dev:proprietary] at org.hibernate.dialect.H2Dialect.lambda$buildSQLExceptionConversionDelegate$0(H2Dialect.java:840) [backend:dev:proprietary] at org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:34) [backend:dev:proprietary] at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:115) [backend:dev:proprietary] at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:184) [backend:dev:proprietary] at org.hibernate.engine.jdbc.mutation.internal.AbstractMutationExecutor.performNonBatchedMutation(AbstractMutationExecutor.java:145) [backend:dev:proprietary] at org.hibernate.engine.jdbc.mutation.internal.MutationExecutorSingleNonBatched.performNonBatchedOperations(MutationExecutorSingleNonBatched.java:53) [backend:dev:proprietary] at org.hibernate.engine.jdbc.mutation.internal.AbstractMutationExecutor.execute(AbstractMutationExecutor.java:66) [backend:dev:proprietary] at org.hibernate.persister.entity.mutation.AbstractDeleteCoordinator.doStaticDelete(AbstractDeleteCoordinator.java:268) [backend:dev:proprietary] at org.hibernate.persister.entity.mutation.AbstractDeleteCoordinator.delete(AbstractDeleteCoordinator.java:79) [backend:dev:proprietary] at org.hibernate.action.internal.EntityDeleteAction.execute(EntityDeleteAction.java:119) [backend:dev:proprietary] at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:634) [backend:dev:proprietary] at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:505) [backend:dev:proprietary] at org.hibernate.event.internal.AbstractFlushingEventListener.performExecutions(AbstractFlushingEventListener.java:381) [backend:dev:proprietary] at org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:40) [backend:dev:proprietary] at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:138) [backend:dev:proprietary] at org.hibernate.internal.SessionImpl.fireFlush(SessionImpl.java:1484) [backend:dev:proprietary] at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:481) [backend:dev:proprietary] at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:2111) [backend:dev:proprietary] at org.hibernate.internal.SessionImpl.beforeTransactionCompletion(SessionImpl.java:2033) [backend:dev:proprietary] at org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl.beforeTransactionCompletion(JdbcCoordinatorImpl.java:410) [backend:dev:proprietary] at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.beforeCompletionCallback(JdbcResourceLocalTransactionCoordinatorImpl.java:166) [backend:dev:proprietary] at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commitNoRollbackOnly(JdbcResourceLocalTransactionCoordinatorImpl.java:248) [backend:dev:proprietary] at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commit(JdbcResourceLocalTransactionCoordinatorImpl.java:242) [backend:dev:proprietary] at org.hibernate.engine.transaction.internal.TransactionImpl.commit(TransactionImpl.java:89) [backend:dev:proprietary] at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:553) [backend:dev:proprietary] ... 27 common frames omitted [backend:dev:proprietary] Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))" [backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement: [backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240] [backend:dev:proprietary] at org.h2.message.DbException.getJdbcSQLException(DbException.java:520) [backend:dev:proprietary] at org.h2.message.DbException.getJdbcSQLException(DbException.java:489) [backend:dev:proprietary] at org.h2.message.DbException.get(DbException.java:223) [backend:dev:proprietary] at org.h2.message.DbException.get(DbException.java:199) [backend:dev:proprietary] at org.h2.constraint.ConstraintReferential.checkRow(ConstraintReferential.java:363) [backend:dev:proprietary] at org.h2.constraint.ConstraintReferential.checkRowRefTable(ConstraintReferential.java:380) [backend:dev:proprietary] at org.h2.constraint.ConstraintReferential.checkRow(ConstraintReferential.java:254) [backend:dev:proprietary] at org.h2.table.Table.fireConstraints(Table.java:1208) [backend:dev:proprietary] at org.h2.table.Table.fireAfterRow(Table.java:1226) [backend:dev:proprietary] at org.h2.command.dml.Delete.update(Delete.java:81) [backend:dev:proprietary] at org.h2.command.dml.DataChangeStatement.update(DataChangeStatement.java:77) [backend:dev:proprietary] at org.h2.command.CommandContainer.update(CommandContainer.java:139) [backend:dev:proprietary] at org.h2.command.Command.executeUpdate(Command.java:306) [backend:dev:proprietary] at org.h2.command.Command.executeUpdate(Command.java:250) [backend:dev:proprietary] at org.h2.jdbc.JdbcPreparedStatement.executeUpdateInternal(JdbcPreparedStatement.java:213) [backend:dev:proprietary] at org.h2.jdbc.JdbcPreparedStatement.executeUpdate(JdbcPreparedStatement.java:172) [backend:dev:proprietary] at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61) [backend:dev:proprietary] at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeUpdate(HikariProxyPreparedStatement.java) [backend:dev:proprietary] at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:181) [backend:dev:proprietary] ... 48 common frames omitted ``` --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../proprietary/storage/service/StorageCleanupService.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java index 5ea5f28def..32c54c6298 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java @@ -7,12 +7,14 @@ import java.util.concurrent.TimeUnit; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.storage.model.StorageCleanupEntry; import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.repository.FileShareAccessRepository; import stirling.software.proprietary.storage.repository.FileShareRepository; import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository; @@ -25,6 +27,7 @@ public class StorageCleanupService { private final StorageProvider storageProvider; private final StorageCleanupEntryRepository cleanupEntryRepository; + private final FileShareAccessRepository fileShareAccessRepository; private final FileShareRepository fileShareRepository; @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) @@ -62,12 +65,14 @@ public class StorageCleanupService { } @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + @Transactional public void cleanupExpiredShareLinks() { List expired = fileShareRepository.findByExpiresAtBeforeAndShareTokenNotNull(LocalDateTime.now()); if (expired.isEmpty()) { return; } + expired.forEach(fileShareAccessRepository::deleteByFileShare); fileShareRepository.deleteAll(expired); } } From b10fc1b2de79ced7e7b26d89191c27868619dd80 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 13:06:28 +0200 Subject: [PATCH 081/122] fix(java): prevent executor, task, regex, and stream resource leaks (#7284) # Description of Changes - Added graceful shutdown handling for service-owned executors in `JobExecutorService`, `PolicyEngine`, and `AsyncConfig`. - Added expiration and cleanup for abandoned pending jobs in `TaskManager`. - Replaced the unbounded regex pattern cache with a bounded cache limited to 512 entries. - Ensured `Files.walk()` is closed correctly in `MobileScannerService`. - These changes prevent unbounded heap growth, lingering virtual-thread executors, and file-descriptor leaks. - Added configurable pending-job expiration through `stirling.job.pendingExpiryMinutes`, defaulting to 24 hours. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../common/service/JobExecutorService.java | 16 ++++++++ .../common/service/MobileScannerService.java | 28 +++++++------ .../software/common/service/TaskManager.java | 27 ++++++++++--- .../common/util/RegexPatternUtils.java | 40 +++++++++++++++---- .../proprietary/config/AsyncConfig.java | 30 ++++++++++++-- .../policy/engine/PolicyEngine.java | 17 ++++++++ 6 files changed, 128 insertions(+), 30 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 23a23e868b..f283f65763 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -19,6 +19,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import jakarta.annotation.PreDestroy; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; @@ -63,6 +64,21 @@ public class JobExecutorService { "Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs); } + /** Stop the service-owned executor when the application context is closed or restarted. */ + @PreDestroy + public void shutdown() { + log.debug("Shutting down job executor"); + executor.shutdown(); + try { + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + executor.shutdownNow(); + } + } + public ResponseEntity runJobGeneric(boolean async, Supplier work) { return runJobGeneric(async, work, -1); } diff --git a/app/common/src/main/java/stirling/software/common/service/MobileScannerService.java b/app/common/src/main/java/stirling/software/common/service/MobileScannerService.java index 7c544b6242..18958841b2 100644 --- a/app/common/src/main/java/stirling/software/common/service/MobileScannerService.java +++ b/app/common/src/main/java/stirling/software/common/service/MobileScannerService.java @@ -225,19 +225,21 @@ public class MobileScannerService { Path sessionDir = getSafeSessionDirectory(sessionId); if (Files.exists(sessionDir)) { // Delete all files in session directory - Files.walk(sessionDir) - .sorted( - (a, b) -> - -a.compareTo(b)) // Reverse order to delete files before - // directory - .forEach( - path -> { - try { - Files.deleteIfExists(path); - } catch (IOException e) { - log.warn("Failed to delete file: {}", path, e); - } - }); + try (var paths = Files.walk(sessionDir)) { + paths.sorted( + (a, b) -> + -a.compareTo( + b)) // Reverse order to delete files before + // directory + .forEach( + path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.warn("Failed to delete file: {}", path, e); + } + }); + } } log.info("Deleted session: {}", sessionId); } catch (IllegalArgumentException e) { diff --git a/app/common/src/main/java/stirling/software/common/service/TaskManager.java b/app/common/src/main/java/stirling/software/common/service/TaskManager.java index 3fd9ac3fe4..f504b39395 100644 --- a/app/common/src/main/java/stirling/software/common/service/TaskManager.java +++ b/app/common/src/main/java/stirling/software/common/service/TaskManager.java @@ -48,6 +48,10 @@ public class TaskManager { @Value("${stirling.jobResultExpiryMinutes:30}") private int jobResultExpiryMinutes = 30; + /** Maximum age of a task that never reached a terminal state. */ + @Value("${stirling.job.pendingExpiryMinutes:1440}") + private int pendingJobExpiryMinutes = 1440; + private final FileStorage fileStorage; private final JobStore jobStore; private final ClusterBackplane clusterBackplane; @@ -332,19 +336,32 @@ public class TaskManager { } LocalDateTime expiryThreshold = LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES); + LocalDateTime pendingExpiryThreshold = + LocalDateTime.now().minus(pendingJobExpiryMinutes, ChronoUnit.MINUTES); int removedCount = 0; try { for (Map.Entry entry : jobResults.entrySet()) { JobResult result = entry.getValue(); - // Remove completed jobs that are older than the expiry threshold - if (result.isComplete() - && result.getCompletedAt() != null - && result.getCompletedAt().isBefore(expiryThreshold)) { + boolean expiredCompletedJob = + result.isComplete() + && result.getCompletedAt() != null + && result.getCompletedAt().isBefore(expiryThreshold); + boolean abandonedPendingJob = + !result.isComplete() + && result.getCreatedAt() != null + && result.getCreatedAt().isBefore(pendingExpiryThreshold); + + // Remove old terminal results and abandoned pending jobs. Without the second + // branch, a client that starts a task and never completes it keeps its result in + // memory forever. + if (expiredCompletedJob || abandonedPendingJob) { // Clean up file results - cleanupJobFiles(result, entry.getKey()); + if (expiredCompletedJob) { + cleanupJobFiles(result, entry.getKey()); + } // Remove the job result jobResults.remove(entry.getKey()); diff --git a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java index b4821edd9c..9d2d1b74db 100644 --- a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java @@ -1,17 +1,22 @@ package stirling.software.common.util; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.util.concurrent.UncheckedExecutionException; + import lombok.extern.slf4j.Slf4j; @Slf4j public final class RegexPatternUtils { private static final RegexPatternUtils INSTANCE = new RegexPatternUtils(); - private final ConcurrentHashMap patternCache = new ConcurrentHashMap<>(); + private static final long MAX_CACHED_PATTERNS = 512; + private final Cache patternCache = + CacheBuilder.newBuilder().maximumSize(MAX_CACHED_PATTERNS).build(); private static final String WHITESPACE_REGEX = "\\s++"; private static final String EXTENSION_REGEX = "\\.(?:[^.]*+)?$"; @@ -51,7 +56,7 @@ public final class RegexPatternUtils { throw new IllegalArgumentException("Regex pattern cannot be null"); } - return patternCache.computeIfAbsent(new PatternKey(regex, 0), this::compilePattern); + return getOrCompile(new PatternKey(regex, 0)); } /** @@ -77,7 +82,7 @@ public final class RegexPatternUtils { throw new IllegalArgumentException("Regex pattern cannot be null"); } - return patternCache.computeIfAbsent(new PatternKey(regex, flags), this::compilePattern); + return getOrCompile(new PatternKey(regex, flags)); } /** @@ -98,7 +103,7 @@ public final class RegexPatternUtils { * @return true if pattern is cached, false otherwise */ public boolean isCached(String regex, int flags) { - return regex != null && patternCache.containsKey(new PatternKey(regex, flags)); + return regex != null && patternCache.getIfPresent(new PatternKey(regex, flags)) != null; } /** @@ -107,7 +112,7 @@ public final class RegexPatternUtils { * @return number of patterns currently cached */ public int getCacheSize() { - return patternCache.size(); + return (int) patternCache.size(); } /** @@ -115,7 +120,7 @@ public final class RegexPatternUtils { * useful for testing or memory cleanup in long-running applications. */ public void clearCache() { - patternCache.clear(); + patternCache.invalidateAll(); log.debug("Regex pattern cache cleared"); } @@ -141,13 +146,32 @@ public final class RegexPatternUtils { return false; } PatternKey key = new PatternKey(regex, flags); - boolean removed = patternCache.remove(key) != null; + boolean removed = patternCache.getIfPresent(key) != null; + patternCache.invalidate(key); if (removed) { log.debug("Removed regex pattern from cache: {} (flags: {})", regex, flags); } return removed; } + private Pattern getOrCompile(PatternKey key) { + try { + return patternCache.get(key, () -> compilePattern(key)); + } catch (UncheckedExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof PatternSyntaxException patternSyntaxException) { + throw patternSyntaxException; + } + throw e; + } catch (java.util.concurrent.ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof PatternSyntaxException patternSyntaxException) { + throw patternSyntaxException; + } + throw new IllegalStateException("Failed to compile regex pattern", cause); + } + } + /** * Internal method to compile a pattern and handle errors consistently. * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java index ea096a8d23..3ba4adcbee 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.config; import java.util.Map; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.slf4j.MDC; @@ -12,10 +13,15 @@ import org.springframework.core.task.support.TaskExecutorAdapter; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.security.concurrent.DelegatingSecurityContextExecutor; +import jakarta.annotation.PreDestroy; + @Configuration @EnableAsync public class AsyncConfig { + private ExecutorService auditExecutorService; + private ExecutorService aiStreamExecutorService; + /** * MDC context-propagating task decorator. Copies MDC context from the caller thread to the * virtual thread executing the task. @@ -44,8 +50,8 @@ public class AsyncConfig { @Bean(name = "auditExecutor") public Executor auditExecutor() { - TaskExecutorAdapter adapter = - new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); + auditExecutorService = Executors.newVirtualThreadPerTaskExecutor(); + TaskExecutorAdapter adapter = new TaskExecutorAdapter(auditExecutorService); adapter.setTaskDecorator(new MDCContextTaskDecorator()); return adapter; } @@ -53,9 +59,25 @@ public class AsyncConfig { /** Propagates the request's SecurityContext onto background AI-orchestration threads. */ @Bean(name = "aiStreamExecutor") public Executor aiStreamExecutor() { - TaskExecutorAdapter adapter = - new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); + aiStreamExecutorService = Executors.newVirtualThreadPerTaskExecutor(); + TaskExecutorAdapter adapter = new TaskExecutorAdapter(aiStreamExecutorService); adapter.setTaskDecorator(new MDCContextTaskDecorator()); return new DelegatingSecurityContextExecutor(adapter); } + + /** + * Close the underlying executors because the exposed Spring adapters do not own their + * lifecycle. + */ + @PreDestroy + void shutdown() { + shutdownExecutor(auditExecutorService); + shutdownExecutor(aiStreamExecutorService); + } + + private void shutdownExecutor(ExecutorService executor) { + if (executor != null) { + executor.shutdownNow(); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index e6dce0ee7b..1f31d153a6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -16,6 +16,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClientResponseException; +import jakarta.annotation.PreDestroy; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -79,6 +81,21 @@ public class PolicyEngine { private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor(); + /** Stop the service-owned executor when the application context is closed or restarted. */ + @PreDestroy + void shutdown() { + log.debug("Shutting down policy engine executor"); + asyncExecutor.shutdown(); + try { + if (!asyncExecutor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) { + asyncExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + asyncExecutor.shutdownNow(); + } + } + /** * Submit a pipeline to run asynchronously. The handle's run id scopes a {@link TaskManager} job * (status/notes/results observable via the job endpoints); its future resolves when the run From 2265e48b3215f77a3b52cf0c6ce47f94f1dcc3c2 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 13:43:53 +0200 Subject: [PATCH 082/122] chore(ci): optimize GitHub Actions Gradle caching across workflows (#7299) # Description of Changes This PR refactors Gradle caching across the GitHub Actions workflows to improve cache reuse, reduce dependency resolution overhead, and shorten CI execution times. ### What was changed - Replaced multiple `gradle/actions/setup-gradle` steps with a unified `actions/cache`-based Gradle User Home cache strategy. - Standardized cache paths across workflows to include: - `~/.gradle/caches` - `~/.gradle/wrapper` - Introduced consistent cache keys using: - Runner OS - Runner architecture - JDK version - Hashes of Gradle wrapper, version catalog, Gradle build files, and project build scripts. - Added restore keys to maximize cache hit rates across similar environments. - Added a new **`gradle-cache-prime`** job in the main build workflow that: - Restores or creates the shared Gradle cache. - Resolves backend dependencies before downstream jobs execute. - Makes the populated cache available to subsequent jobs. - Updated workflow dependencies so Gradle-based jobs wait for the cache priming job before execution. - Simplified and unified Gradle cache handling across numerous CI workflows, including backend builds, OpenAPI generation, database migration tests, Docker tests, Tauri builds, Swagger generation, enterprise builds, release workflows, and license generation. - Updated workflow comments to reflect the new caching strategy and shared cache behavior. ### Why the change was made The previous workflows used a mixture of Gradle setup actions and partial dependency caches, leading to duplicated dependency downloads, inconsistent cache behavior, and longer CI runtimes. Consolidating all workflows onto a shared Gradle User Home cache with a dedicated cache priming job improves cache reuse, reduces unnecessary dependency resolution, and makes CI execution more consistent. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../workflows/PR-Demo-Comment-with-react.yml | 12 +++-- .github/workflows/backend-build.yml | 16 +++--- .github/workflows/build-enterprise.yml | 10 ++++ .github/workflows/build.yml | 53 +++++++++++++++---- .github/workflows/check-generated-models.yml | 12 +++-- .github/workflows/check-licence.yml | 16 +++--- .github/workflows/check-openapi.yml | 16 +++--- .github/workflows/coverage-aggregate.yml | 22 ++++---- .github/workflows/db-migration-test.yml | 20 +++---- .github/workflows/docker-compose-tests.yml | 16 +++--- .github/workflows/e2e-live.yml | 17 +++--- .../frontend-backend-licenses-update.yml | 12 +++-- .github/workflows/multiOSReleases.yml | 36 ++++++++----- .github/workflows/push-docker.yml | 12 ++--- .github/workflows/swagger.yml | 12 +++-- .github/workflows/tauri-build.yml | 12 +++-- .github/workflows/test-build-docker.yml | 16 +++--- .github/workflows/testdriver.yml | 12 +++-- 18 files changed, 187 insertions(+), 135 deletions(-) diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index e48a6170da..3826897a39 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -211,10 +211,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 4d133593d3..47b5591e19 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -40,20 +40,16 @@ jobs: java-version: ${{ matrix.jdk-version }} distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index 0fb02ad90f..398cca3144 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -60,6 +60,16 @@ jobs: with: java-version: "25" distribution: "temurin" + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d4aee192d..4ad4b046e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,8 +60,43 @@ jobs: with: filters: .github/config/.files.yaml - build: + gradle-cache-prime: + name: Prime shared Gradle cache needs: [files-changed] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: "25" + distribution: "temurin" + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- + - name: Resolve backend dependencies + run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon + env: + STIRLING_FLAVOR: saas + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + + build: + needs: [files-changed, gradle-cache-prime] permissions: actions: read contents: read @@ -76,7 +111,7 @@ jobs: # works after Hibernate's ddl-auto=update migrates the schema. Gated on # the `project` filter so doc-only PRs skip this ~5-minute job. if: needs.files-changed.outputs.project == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/db-migration-test.yml @@ -84,7 +119,7 @@ jobs: check-generateOpenApiDocs: if: needs.files-changed.outputs.openapi == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/check-openapi.yml @@ -120,7 +155,7 @@ jobs: playwright-e2e-live: if: needs.files-changed.outputs.frontend == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/e2e-live.yml @@ -128,7 +163,7 @@ jobs: playwright-e2e-enterprise: if: needs.files-changed.outputs.proprietary == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/build-enterprise.yml @@ -136,7 +171,7 @@ jobs: check-licence: if: needs.files-changed.outputs.build == 'true' - needs: [files-changed, build] + needs: [files-changed, build, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/check-licence.yml @@ -144,7 +179,7 @@ jobs: docker-compose-tests: if: needs.files-changed.outputs.project == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: actions: write contents: read @@ -156,7 +191,7 @@ jobs: test-build-docker-images: if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' - needs: [files-changed, build, check-generateOpenApiDocs, check-licence] + needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime] permissions: contents: read packages: read @@ -199,7 +234,7 @@ jobs: # frontend filter, so a CSS-only PR does not pay for a backend build. generated-models: if: needs.files-changed.outputs.generated-models == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read pull-requests: write diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index a758c4268e..a8559a7d78 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -42,10 +42,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.0 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index c9f64a0b93..61d21c0501 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -26,20 +26,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index eb89d32629..c188e8ff23 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -27,20 +27,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index e3a871b08e..1ece083252 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -46,20 +46,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -81,7 +77,7 @@ jobs: # Each lands as a sibling dir under coverage-execs/, with the .exec # files preserving their original relative paths. - name: Download all .exec artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: jacoco-exec-* path: coverage-execs/ @@ -206,7 +202,7 @@ jobs: # absence on backend-only runs by skipping the download entirely # when the producer job was not part of this workflow run. if: inputs.frontend-validation-result == 'success' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: frontend-coverage path: matrix-inputs/vitest/ @@ -216,7 +212,7 @@ jobs: # e2e-live uploads the artifact with a stable name. Skip the # download entirely when the producer job did not run. if: inputs.playwright-e2e-live-result == 'success' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: playwright-frontend-coverage path: matrix-inputs/playwright/ diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 8bd28fc060..edb2d0547c 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -30,23 +30,19 @@ jobs: java-version: 25 distribution: temurin - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true - - # No `-PnoSpotless` here yet because the upstream cache layer matches the - # backend build's; reuse keeps cold-cache cost identical. + # Keep the normal formatting path here so this smoke test exercises the + # same Gradle configuration as the backend build. - name: Build Stirling-PDF JAR env: MAVEN_USER: ${{ secrets.MAVEN_USER }} diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 9b81f774ef..049db8adc0 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -38,20 +38,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- # When the PR changes the base image, test.sh builds it locally # (stirling-pdf-base:local) into the daemon image store. A buildx diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 302663cbf9..0e64b89099 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -25,21 +25,16 @@ jobs: with: java-version: "25" distribution: "temurin" - # Same cache layer as backend-build.yml. Without it every run resolved the - # whole classpath cold and eventually got HTTP 429 from Maven Central. - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- # Gradle does not retry 429s, and a cold cache resolving the buildscript # classpath is exactly where Maven Central rate-limits us. Retry it here, # where a failure is cheap, instead of inside the backgrounded bootRun. diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 8c852ae08f..1f901a7f05 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -350,10 +350,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 8303cf102a..f5a99a0ef2 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -57,20 +57,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependencies + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} restore-keys: | - gradle-${{ runner.os }}- - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -151,10 +147,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Setup Node.js if: matrix.variant.build_frontend == true @@ -255,10 +257,16 @@ jobs: java-version: "25" distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 12fcd23f6c..f4416a726d 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -65,20 +65,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependencies + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} restore-keys: | - gradle-${{ runner.os }}- - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Set up Docker Buildx id: buildx diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index ffbeafdd1e..76b28c4566 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -39,10 +39,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Generate Swagger documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 415e8be4e5..09422a8093 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -179,10 +179,16 @@ jobs: java-version: "25" distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Setup Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index d935cac53d..7f8c72d41e 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -84,20 +84,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index a7f74df4a7..a8d777744a 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -38,10 +38,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Build with Gradle run: ./gradlew build From 8094765babe12b96d46e5d61f1d258fa29b1eb81 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 13:44:12 +0200 Subject: [PATCH 083/122] build(licenses): Module-specific license. Add dependency overrides. (#7049) # Description of Changes This change adds a version-scoped override mechanism for dependencies whose published metadata does not expose a detectable license. - Added `app/license-overrides.json` with verified Apache License 2.0 metadata for: - `com.hubspot.immutables:immutables-exceptions:1.9` - `com.hubspot:algebra:1.5` - Added `ModuleLicenseOverrideFilter` as custom `buildSrc` logic for the Gradle dependency license report plugin. - Applied overrides only when the exact `group:artifact:version` matches and no usable license metadata was detected. - Added automatic maintenance of the override file: - Removes overrides when the dependency is no longer resolved. - Removes overrides when the dependency starts publishing valid license metadata. - Migrates stale overrides to newer unresolved versions and clears their metadata for re-verification. - Adds null-valued placeholders for newly detected dependencies without license metadata. - Preserves populated overrides for newer versions when already present. - Added Gradle version-aware dependency ordering for override migration. - Registered `app/license-overrides.json` as an input for license-report and license-check preparation tasks. - Centralized the dependency license report plugin version in `buildSrc`. - Added unit tests covering override application, cleanup, migration, exact-version matching, concurrent versions, placeholder generation, and numeric version ordering. - Added documentation describing the override lifecycle, verification requirements, maintenance workflow, and validation commands. - Replaced broad null-license allowances for the two HubSpot modules with explicit Apache License 2.0 metadata. - Added accepted GNU Lesser General Public License name variants encountered in dependency metadata. The change was made because some dependencies have known upstream licenses but do not publish license metadata in a form detected by the Gradle license report plugin. Previously, these dependencies were permitted through module-specific null-license exceptions, leaving incomplete information in the generated report. The new mechanism supplies verified metadata without overriding valid metadata published by dependencies. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- app/allowed-licenses.json | 16 +- app/license-overrides.json | 12 + build.gradle | 13 +- buildSrc/README.md | 168 +++++++++++ buildSrc/build.gradle | 22 ++ .../gradle/ModuleLicenseOverrideFilter.groovy | 173 ++++++++++++ .../ModuleLicenseOverrideFilterTest.groovy | 265 ++++++++++++++++++ docker/backend/Dockerfile | 2 + docker/embedded/Dockerfile | 2 + docker/embedded/Dockerfile.fat | 2 + docker/embedded/Dockerfile.ultra-lite | 2 + 11 files changed, 668 insertions(+), 9 deletions(-) create mode 100644 app/license-overrides.json create mode 100644 buildSrc/README.md create mode 100644 buildSrc/build.gradle create mode 100644 buildSrc/src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy create mode 100644 buildSrc/src/test/groovy/stirling/software/gradle/ModuleLicenseOverrideFilterTest.groovy diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json index 9b5ef66556..033661629f 100644 --- a/app/allowed-licenses.json +++ b/app/allowed-licenses.json @@ -156,6 +156,14 @@ "moduleName": ".*", "moduleLicense": "GNU GENERAL PUBLIC LICENSE, Version 2 + Classpath Exception" }, + { + "moduleName": ".*", + "moduleLicense": "GNU Lesser Public License" + }, + { + "moduleName": ".*", + "moduleLicense": "The GNU Lesser General Public License" + }, { "moduleName": "com.martiansoftware:jsap", "moduleLicense": "LGPL" @@ -224,14 +232,6 @@ "moduleName": "com.google.re2j:re2j", "moduleLicense": "Go License" }, - { - "moduleName": "com.hubspot:algebra", - "moduleLicense": null - }, - { - "moduleName": "com.hubspot.immutables:immutables-exceptions", - "moduleLicense": null - }, { "moduleName": ".*", "moduleLicense": "UnRar License" diff --git a/app/license-overrides.json b/app/license-overrides.json new file mode 100644 index 0000000000..0ea43fcaf9 --- /dev/null +++ b/app/license-overrides.json @@ -0,0 +1,12 @@ +{ + "com.hubspot.immutables:immutables-exceptions:1.9": { + "name": "The Apache License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", + "projectUrl": "https://github.com/HubSpot/hubspot-immutables/tree/58628096ac99b286fe4f8bfe12aa3cff0f0589d3" + }, + "com.hubspot:algebra:1.5": { + "name": "The Apache License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", + "projectUrl": "https://github.com/HubSpot/algebra/tree/5d42983fd3a26539df9ba2cbeac32a1bddce0494" + } +} diff --git a/build.gradle b/build.gradle index 2af5522a73..31fa6b1bdb 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { id "org.springdoc.openapi-gradle-plugin" version "1.9.0" id "io.swagger.swaggerhub" version "1.3.2" id "com.diffplug.spotless" version "8.8.0" - id "com.github.jk1.dependency-license-report" version "3.1.2" + id "com.github.jk1.dependency-license-report" //id "nebula.lint" version "19.0.3" id "org.sonarqube" version "7.2.3.7755" } @@ -18,6 +18,7 @@ import groovy.xml.XmlSlurper import org.gradle.api.JavaVersion import org.gradle.api.tasks.testing.Test import org.gradle.jvm.toolchain.JavaLanguageVersion +import stirling.software.gradle.ModuleLicenseOverrideFilter ext { springBootVersion = "4.0.6" @@ -550,6 +551,7 @@ gradle.taskGraph.whenReady { graph -> } def allProjects = ((subprojects as Set) + project) as Set +def moduleLicenseOverridesFile = project.layout.projectDirectory.file("app/license-overrides.json").asFile licenseReport { projects = allProjects @@ -557,6 +559,15 @@ licenseReport { allowedLicensesFile = project.layout.projectDirectory.file("app/allowed-licenses.json").asFile outputDir = project.layout.buildDirectory.dir("reports/dependency-license").get().asFile.path configurations = [ "productionRuntimeClasspath", "runtimeClasspath" ] + filters = [new ModuleLicenseOverrideFilter(moduleLicenseOverridesFile)] +} + +tasks.named('generateLicenseReport') { + inputs.file(moduleLicenseOverridesFile) +} + +tasks.named('checkLicensePreparation') { + inputs.file(moduleLicenseOverridesFile) } // Configure the forked spring boot run task to properly delegate to the stirling-pdf module diff --git a/buildSrc/README.md b/buildSrc/README.md new file mode 100644 index 0000000000..bbcfb0de6c --- /dev/null +++ b/buildSrc/README.md @@ -0,0 +1,168 @@ +# Dependency license overrides + +The backend dependency license report is generated by the +[`com.github.jk1.dependency-license-report`](https://github.com/jk1/Gradle-License-Report) +Gradle plugin. Most license information is read from dependency POM files, manifests, or packaged +license files. Some artifacts do not publish license metadata in a form the plugin can detect, even +though the artifact has a known license. + +This directory contains the build logic used to provide narrowly scoped fallback license metadata +for those artifacts. + +## Files + +- `build.gradle` makes version 3.1.4 of the license report plugin available to the custom build + logic. The root build applies that plugin without a second version declaration so both use the + same classpath. +- `src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy` implements the + plugin's `DependencyFilter` interface. +- `../app/license-overrides.json` contains the actual module-specific fallback values. +- `../app/allowed-licenses.json` defines which detected or supplied licenses are accepted by + `checkLicense`. + +## How it works + +The root `build.gradle` passes `app/license-overrides.json` to +`ModuleLicenseOverrideFilter`: + +```groovy +filters = [new ModuleLicenseOverrideFilter(moduleLicenseOverridesFile)] +``` + +For every dependency discovered by the license plugin, the filter builds an identifier in this +format: + +```text +group:artifact:version +``` + +The filter applies a populated override only when both conditions are true: + +1. The complete identifier, including the version, exists in `app/license-overrides.json`. +2. The plugin did not discover a non-empty license name for that dependency. + +When both conditions match, the filter adds the configured license as fallback manifest metadata. +The normal report renderer and `checkLicense` then consume that metadata in the same way as +metadata discovered from the dependency itself. + +An override never replaces a license that the plugin already detected. Updating a dependency also +does not silently reuse the override because a different version produces a different identifier. + +Overrides are temporary fallbacks, not a permanent license catalog. If the plugin starts detecting +the original license for an overridden module, the filter automatically removes that exact entry +from `app/license-overrides.json` and logs the cleanup. When the overridden version is no longer +resolved, a newer resolved version takes its place: if it declares a license, the stale entry is +removed; otherwise the entry moves to the new exact version and its values are cleared for +re-verification. An already populated entry for the new version is preserved. If no higher version +is resolved, the unused override is removed instead. + +Because the report aggregates several projects and configurations, multiple versions of the same +`group:artifact` can be present at once. An override is retained whenever its exact version is still +resolved. Only when that exact version is absent may the filter treat a higher version as an update; +version ordering then follows Gradle's own dependency version comparator. Overrides for dependency +versions that are no longer resolved and have no higher replacement are deleted automatically. + +The filter also records every resolved dependency without detected license metadata that has no +override yet. It writes a placeholder with `null` values for `name`, `url`, and `projectUrl`. +Placeholders deliberately do not affect the generated report until `name` is filled in. This makes +new missing metadata visible in the source-controlled override file instead of only in a generated +report. Review and fill or remove every new placeholder before committing the resulting JSON. + +## Adding an override + +First verify the license from an authoritative source such as the upstream repository, the +published artifact metadata, or the license file shipped inside the artifact. Do not infer a +license from the organization name or from a related artifact. + +Add an entry to `app/license-overrides.json`: + +```json +{ + "com.example:example-library:1.2.3": { + "name": "Apache License, Version 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0", + "projectUrl": "https://github.com/example/example-library/tree/0123456789abcdef0123456789abcdef01234567" + } +} +``` + +The key must contain the exact resolved version. `name` must be non-empty for the override to be +applied. `url` should point to the canonical license text. `projectUrl` must point to the immutable +Git tree for the exact module version, using the commit hash at which that version was introduced: + +```text +https://github.com///tree/ +``` + +Do not use the repository's default branch or another moving URL. See the existing entries in +`app/license-overrides.json` for concrete examples. + +If the license name is not already accepted, add a suitably narrow rule to +`app/allowed-licenses.json`. Adding an override and allowing a license are separate operations: + +- `license-overrides.json` supplies missing metadata for a specific artifact version. +- `allowed-licenses.json` defines the policy enforced by `checkLicense`. + +## Updating a dependency + +When an overridden dependency changes version: + +1. Verify the license for the new version again. +2. Run the license report so the filter can move the old key or add a placeholder for the new full + `group:artifact:version` key. +3. Re-verify and fill the license values and the version's immutable Git-tree `projectUrl`; moved + values are intentionally cleared because a license conclusion for one release is not assumed + for another. +4. Regenerate and inspect the report. + +If the new artifact publishes usable license metadata, no override is necessary. The next license +report or license check removes the old entry from `app/license-overrides.json` automatically. The +file must contain only overrides that are still needed. + +## Verification + +Run the filter unit tests: + +```powershell +.\gradlew.bat -p buildSrc test +``` + +The tests use `com.example:example-library` versions 1.4 and 1.7 to cover the missing +metadata fallback, placeholder creation, version migration, preservation of a populated newer +override, automatic cleanup after license metadata appears, exact-version matching, and concurrent +resolved versions. They also verify removal when a dependency version disappears. A separate `1.9` +to `1.11.0` case verifies numeric Gradle version ordering. + +Run the normal backend license workflow from the repository root: + +```powershell +task backend:licenses:generate +``` + +Then inspect: + +- `build/reports/dependency-license/index.json` for the rendered module, version, license name, and + URL. +- `build/reports/dependency-license/dependencies-without-allowed-license.json` when `checkLicense` + reports a policy failure. + +Also run the backend quality gate after changing the filter or its build wiring: + +```powershell +task backend:check +``` + +The override JSON is registered as an input of `generateLicenseReport` and +`checkLicensePreparation`, so changing the file invalidates the corresponding Gradle task outputs. + +## What not to do + +- Do not use an unversioned key. It cannot match the filter and would make the intended scope + ambiguous. +- Do not use an override to replace valid license metadata published by a dependency. +- Do not add an empty license to `allowed-licenses.json` merely to silence `checkLicense`; that + would still leave the generated report without useful license information. +- Do not exclude a dependency from the report solely because it is transitive. Runtime transitive + dependencies are still distributed components and their licenses remain relevant. +- Do not edit generated files under `build/reports/dependency-license` or the copied static license + report by hand. diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle new file mode 100644 index 0000000000..25eee5c0f5 --- /dev/null +++ b/buildSrc/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'groovy' +} + +repositories { + gradlePluginPortal() +} + +dependencies { + implementation localGroovy() + implementation gradleApi() + implementation 'com.github.jk1:gradle-license-report:3.1.4' + testImplementation platform('org.junit:junit-bom:6.1.2') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() + jvmArgs '--add-opens=java.base/java.lang=ALL-UNNAMED' + testLogging.showStandardStreams = true +} diff --git a/buildSrc/src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy b/buildSrc/src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy new file mode 100644 index 0000000000..df0d76158c --- /dev/null +++ b/buildSrc/src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy @@ -0,0 +1,173 @@ +package stirling.software.gradle + +import com.github.jk1.license.License +import com.github.jk1.license.ManifestData +import com.github.jk1.license.ModuleData +import com.github.jk1.license.ProjectData +import com.github.jk1.license.filter.DependencyFilter +import com.github.jk1.license.render.LicenseDataCollector +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.DefaultVersionComparator +import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.Version +import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.VersionParser + +class ModuleLicenseOverrideFilter implements DependencyFilter { + private static final VersionParser VERSION_PARSER = new VersionParser() + private static final Comparator VERSION_COMPARATOR = + new DefaultVersionComparator().asVersionComparator() + + private final File overridesFile + + ModuleLicenseOverrideFilter(File overridesFile) { + this.overridesFile = overridesFile + } + + @Override + ProjectData filter(ProjectData projectData) { + Map> overrides = loadOverrides() + List modules = projectData.configurations + .collectMany { configuration -> configuration.dependencies } + Map> modulesByCoordinate = modules + .groupBy { module -> moduleCoordinate(module) } + + boolean overridesChanged = false + overrides.keySet().toList().each { overrideId -> + ModuleCoordinates overrideModule = parseModuleId(overrideId) + List coordinateModules = modulesByCoordinate[overrideModule.coordinate] + ModuleData currentModule = coordinateModules + ?.find { module -> module.version == overrideModule.version } + if (currentModule == null) { + currentModule = newestModule(coordinateModules, overrideModule.version) + } + if (currentModule == null) { + overrides.remove(overrideId) + overridesChanged = true + projectData.project.logger.lifecycle( + "Removed unused license override for ${overrideId}: " + + 'dependency version is no longer resolved') + return + } + + if (hasDeclaredLicense(currentModule)) { + overrides.remove(overrideId) + overridesChanged = true + projectData.project.logger.lifecycle( + "Removed stale license override for ${overrideId}: " + + "${moduleId(currentModule)} now declares a license") + return + } + + if (compareVersions(currentModule.version, overrideModule.version) > 0) { + String currentModuleId = moduleId(currentModule) + overrides.remove(overrideId) + if (!overrides.containsKey(currentModuleId)) { + overrides[currentModuleId] = [name: null, url: null, projectUrl: null] + } + overridesChanged = true + projectData.project.logger.lifecycle( + "Updated license override from ${overrideId} to ${currentModuleId}: " + + 'newer dependency still declares no license') + } + } + + modules.groupBy { module -> moduleId(module) }.each { currentModuleId, matchingModules -> + ModuleData module = matchingModules.first() + if (!overrides.containsKey(currentModuleId) && !hasDeclaredLicense(module)) { + overrides[currentModuleId] = [name: null, url: null, projectUrl: null] + overridesChanged = true + projectData.project.logger.lifecycle( + "Added missing license override for ${currentModuleId}. " + + "Set 'name' and 'url' in ${overridesFile}.") + } + } + if (overridesChanged) { + saveOverrides(overrides) + } + + projectData.configurations.each { configuration -> + configuration.dependencies.each { module -> applyOverride(module, overrides) } + } + return projectData + } + + private void applyOverride( + ModuleData module, Map> overrides) { + String moduleId = moduleId(module) + Map override = overrides[moduleId] + if (override == null) { + return + } + + String licenseName = override.name + String licenseUrl = override.url + String projectUrl = override.projectUrl + if (licenseName == null || licenseName.isBlank()) { + return + } + + Set licenses = [new License(licenseName, licenseUrl)] as LinkedHashSet + ManifestData manifest = + new ManifestData(module.name, module.version, null, null, projectUrl, licenses, false) + Set manifests = new LinkedHashSet<>(module.manifests ?: []) + manifests.add(manifest) + module.manifests = manifests + } + + private Map> loadOverrides() { + Object parsed = new JsonSlurper().parse(overridesFile) + if (!(parsed instanceof Map)) { + throw new IllegalArgumentException( + "License overrides file ${overridesFile} must contain a JSON object") + } + return parsed as Map> + } + + private void saveOverrides(Map> overrides) { + String json = JsonOutput.prettyPrint(JsonOutput.toJson(overrides)) + System.lineSeparator() + overridesFile.setText(json, 'UTF-8') + } + + private static String moduleId(ModuleData module) { + return "${module.group}:${module.name}:${module.version}" + } + + private static String moduleCoordinate(ModuleData module) { + return "${module.group}:${module.name}" + } + + private static ModuleCoordinates parseModuleId(String moduleId) { + List parts = moduleId.split(':', 3) as List + if (parts.size() != 3 || parts.any { part -> part.isBlank() }) { + throw new IllegalArgumentException( + "License override key ${moduleId} must use group:module:version") + } + return new ModuleCoordinates("${parts[0]}:${parts[1]}", parts[2]) + } + + private static ModuleData newestModule(List modules, String minimumVersion) { + return modules + ?.findAll { module -> compareVersions(module.version, minimumVersion) > 0 } + ?.max { left, right -> compareVersions(left.version, right.version) } + } + + private static int compareVersions(String left, String right) { + return VERSION_COMPARATOR.compare( + VERSION_PARSER.transform(left), VERSION_PARSER.transform(right)) + } + + private static boolean hasDeclaredLicense(ModuleData module) { + Set licenses = LicenseDataCollector.multiModuleLicenseInfo(module).licenses + return licenses.any { license -> license.name != null && !license.name.isBlank() } + } + + private static class ModuleCoordinates { + final String coordinate + final String version + + ModuleCoordinates(String coordinate, String version) { + this.coordinate = coordinate + this.version = version + } + } +} diff --git a/buildSrc/src/test/groovy/stirling/software/gradle/ModuleLicenseOverrideFilterTest.groovy b/buildSrc/src/test/groovy/stirling/software/gradle/ModuleLicenseOverrideFilterTest.groovy new file mode 100644 index 0000000000..7536d08af0 --- /dev/null +++ b/buildSrc/src/test/groovy/stirling/software/gradle/ModuleLicenseOverrideFilterTest.groovy @@ -0,0 +1,265 @@ +package stirling.software.gradle + +import com.github.jk1.license.ConfigurationData +import com.github.jk1.license.License +import com.github.jk1.license.ManifestData +import com.github.jk1.license.ModuleData +import com.github.jk1.license.ProjectData +import com.github.jk1.license.render.LicenseDataCollector +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import java.nio.file.Path +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertFalse +import static org.junit.jupiter.api.Assertions.assertTrue + +class ModuleLicenseOverrideFilterTest { + private static final String GROUP = 'com.example' + private static final String MODULE = 'example-library' + private static final String VERSION_WITHOUT_LICENSE = '1.4' + private static final String VERSION_WITH_LICENSE = '1.7' + private static final String APACHE_NAME = 'Apache License, Version 2.0' + private static final String APACHE_URL = 'https://www.apache.org/licenses/LICENSE-2.0' + private static final String PROJECT_URL = 'https://github.com/HubSpot/hubspot-immutables' + + @TempDir + Path temporaryDirectory + + @Test + void keepsOverrideForVersionWithoutLicenseMetadata() { + ModuleData module = createModule(VERSION_WITHOUT_LICENSE, null) + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertTrue(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + @Test + void removesOverrideWhenLaterVersionDeclaresLicense() { + License publishedLicense = new License(APACHE_NAME, APACHE_URL) + ModuleData module = createModule(VERSION_WITH_LICENSE, publishedLicense) + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + @Test + void removesOverrideWhenOnlyOlderVersionIsResolved() { + License publishedLicense = new License(APACHE_NAME, APACHE_URL) + ModuleData module = createModule(VERSION_WITHOUT_LICENSE, publishedLicense) + File overridesFile = createOverridesFile(moduleId(VERSION_WITH_LICENSE)) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(VERSION_WITH_LICENSE))) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + @Test + void removesOverrideWhenModuleIsNoLongerResolved() { + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData()) + + assertTrue(readOverrides(overridesFile).isEmpty()) + } + + @Test + void keepsOverrideWhenExactAndNewerVersionsAreBothResolved() { + ModuleData olderModule = createModule(VERSION_WITHOUT_LICENSE, null) + License publishedLicense = new License(APACHE_NAME, APACHE_URL) + ModuleData newerModule = createModule(VERSION_WITH_LICENSE, publishedLicense) + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + new ModuleLicenseOverrideFilter(overridesFile) + .filter(createProjectData(olderModule, newerModule)) + + Map overrides = readOverrides(overridesFile) + assertTrue(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals([APACHE_NAME], licenseNames(olderModule)) + assertEquals([APACHE_NAME], licenseNames(newerModule)) + } + + @Test + void movesOverrideUsingGradleNumericVersionOrdering() { + String oldVersion = '1.9' + String newVersion = '1.11.0' + ModuleData module = createModule(newVersion, null) + File overridesFile = createOverridesFile(moduleId(oldVersion)) + + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(oldVersion))) + assertEquals( + [name: null, url: null, projectUrl: null], overrides[moduleId(newVersion)]) + } + + @Test + void movesOverrideToLaterVersionWithoutLicenseAndClearsLicenseData() { + ModuleData module = createModule(VERSION_WITH_LICENSE, null) + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals( + [name: null, url: null, projectUrl: null], + overrides[moduleId(VERSION_WITH_LICENSE)]) + assertTrue(licenseNames(module).isEmpty()) + } + + @Test + void preservesExistingOverrideWhenRemovingOlderVersion() { + ModuleData module = createModule(VERSION_WITH_LICENSE, null) + File overridesFile = createOverridesFile( + [ + (moduleId(VERSION_WITHOUT_LICENSE)): [ + name: APACHE_NAME, url: APACHE_URL + ], + (moduleId(VERSION_WITH_LICENSE)): [ + name: APACHE_NAME, url: APACHE_URL, projectUrl: PROJECT_URL + ] + ]) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals( + [name: APACHE_NAME, url: APACHE_URL, projectUrl: PROJECT_URL], + overrides[moduleId(VERSION_WITH_LICENSE)]) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + @Test + void addsMissingOverrideForModuleWithoutLicense() { + ModuleData module = createModule(VERSION_WITHOUT_LICENSE, null) + File overridesFile = createEmptyOverridesFile() + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertEquals( + [name: null, url: null, projectUrl: null], + overrides[moduleId(VERSION_WITHOUT_LICENSE)]) + assertTrue(licenseNames(module).isEmpty()) + } + + @Test + void doesNotAddOverrideForModuleWithLicense() { + License publishedLicense = new License(APACHE_NAME, APACHE_URL) + ModuleData module = createModule(VERSION_WITH_LICENSE, publishedLicense) + File overridesFile = createEmptyOverridesFile() + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + assertTrue(readOverrides(overridesFile).isEmpty()) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + private File createOverridesFile(String moduleId) { + Map> overrides = [ + (moduleId): [name: APACHE_NAME, url: APACHE_URL] + ] + return createOverridesFile(overrides) + } + + private File createOverridesFile(Map> overrides) { + File overridesFile = temporaryDirectory.resolve('license-overrides.json').toFile() + overridesFile.setText(JsonOutput.prettyPrint(JsonOutput.toJson(overrides)), 'UTF-8') + return overridesFile + } + + private File createEmptyOverridesFile() { + File overridesFile = temporaryDirectory.resolve('license-overrides.json').toFile() + overridesFile.setText('{}', 'UTF-8') + return overridesFile + } + + private static Map readOverrides(File overridesFile) { + return new JsonSlurper().parse(overridesFile) as Map + } + + private static void debugState(String stage, ModuleData module, File overridesFile) { + String resolvedModuleId = "${module.group}:${module.name}:${module.version}" + Map overrides = readOverrides(overridesFile) + System.out.println( + "[license-override-test] ${stage}: module=${resolvedModuleId}, " + + "licenses=${licenseNames(module)}, " + + "matchingOverride=${overrides.containsKey(resolvedModuleId)}, " + + "overrideKeys=${overrides.keySet().sort()}") + } + + private static ProjectData createProjectData(ModuleData module) { + return createProjectData(module as ModuleData[]) + } + + private static ProjectData createProjectData(ModuleData... modules) { + ConfigurationData configuration = + new ConfigurationData( + 'runtimeClasspath', modules as LinkedHashSet) + return new ProjectData( + ProjectBuilder.builder().build(), + [configuration] as LinkedHashSet) + } + + private static ModuleData createModule(String version, License license) { + Set manifests = new LinkedHashSet<>() + if (license != null) { + manifests.add( + new ManifestData( + MODULE, + version, + null, + null, + null, + [license] as LinkedHashSet, + false)) + } + return new ModuleData( + GROUP, + MODULE, + version, + true, + manifests, + new LinkedHashSet<>(), + new LinkedHashSet<>()) + } + + private static String moduleId(String version) { + return "${GROUP}:${MODULE}:${version}" + } + + private static List licenseNames(ModuleData module) { + Set licenses = LicenseDataCollector.multiModuleLicenseInfo(module).licenses + return licenses.collect { license -> license.name }.sort() + } +} diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 5c6680661f..ab79caf9f0 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -17,6 +17,8 @@ WORKDIR /app COPY build.gradle settings.gradle gradlew ./ COPY gradle/ gradle/ +COPY buildSrc/build.gradle buildSrc/ +COPY buildSrc/src/main/ buildSrc/src/main/ COPY app/core/build.gradle app/core/ COPY app/common/build.gradle app/common/ COPY app/proprietary/build.gradle app/proprietary/ diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 54458667be..4ae0ee81e7 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -30,6 +30,8 @@ WORKDIR /app COPY build.gradle settings.gradle gradlew ./ COPY gradle/ gradle/ +COPY buildSrc/build.gradle buildSrc/ +COPY buildSrc/src/main/ buildSrc/src/main/ COPY app/core/build.gradle app/core/ COPY app/common/build.gradle app/common/ COPY app/proprietary/build.gradle app/proprietary/ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 9b2655987f..6e679b97c8 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -31,6 +31,8 @@ WORKDIR /app COPY build.gradle settings.gradle gradlew ./ COPY gradle/ gradle/ +COPY buildSrc/build.gradle buildSrc/ +COPY buildSrc/src/main/ buildSrc/src/main/ COPY app/core/build.gradle app/core/ COPY app/common/build.gradle app/common/ COPY app/proprietary/build.gradle app/proprietary/ diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index bcf189e9ee..14f9e934d0 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -23,6 +23,8 @@ WORKDIR /app # Copy gradle files for dependency resolution COPY build.gradle settings.gradle gradlew ./ COPY gradle/ gradle/ +COPY buildSrc/build.gradle buildSrc/ +COPY buildSrc/src/main/ buildSrc/src/main/ COPY app/core/build.gradle app/core/ COPY app/common/build.gradle app/common/ COPY app/proprietary/build.gradle app/proprietary/ From ddc0baa41daa863c8952f071a5a275cf33f51f1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:50 +0000 Subject: [PATCH 084/122] build(deps-dev): bump ip-address from 10.2.0 to 10.4.0 in /frontend (#7278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0.
    Release notes

    Sourced from ip-address's releases.

    v10.4.0

    What's Changed

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.3.1...v10.4.0

    v10.3.1

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.3.0...v10.3.1

    v10.3.0

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.2.2...v10.3.0

    v10.2.2

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.2.1...v10.2.2

    v10.2.1

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.2.1

    Commits
    • fbb8db2 10.4.0
    • 45a2b11 Validate the byte arrays Address6 is given (#217)
    • bac8810 Keep the package loadable on node 12, and enforce it (#216)
    • 9b3d848 Add a security policy and a README section on security posture
    • e84a7b3 Order the README API reference Address4, Address6, AddressError
    • 015160b Collapse each class in the README API reference
    • 34061a8 Pin checkout and setup-node to commits in the release job
    • c5fae5d Pin action-gh-release to a commit and move it to 3.0.2
    • e0ef048 Replace CircleCI with GitHub Actions
    • 5e3ceb7 Add GitHub Actions CI across Node 20, 22, 24 and 25 (#213)
    • Additional commits viewable in compare view
    Maintainer changes

    This version was pushed to npm by GitHub Actions, a new releaser for ip-address since your current version.

    Install script changes

    This version adds prepare script that runs during installation. Review the package contents before updating.


    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ip-address&package-manager=npm_and_yarn&previous-version=10.2.0&new-version=10.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7db904ddf6..a5abc659e3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10774,9 +10774,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "dev": true, "license": "MIT", "engines": { From 7aeec93032a952c78cf10e77804e71052df1dc38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:01:20 +0100 Subject: [PATCH 085/122] build(deps): bump softprops/action-gh-release from 3.0.0 to 3.0.2 (#7273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.0 to 3.0.2.
    Release notes

    Sourced from softprops/action-gh-release's releases.

    v3.0.2

    3.0.2 is a patch release focused on release reliability and compatibility. It reuses existing draft releases when publishing prereleases, supports replacing release assets on Gitea, hardens streamed asset uploads, and provides clearer release-creation diagnostics. It also includes TypeScript, coverage, and tooling maintenance merged since 3.0.1.

    This release fixes #795, #438, and #803. The upload transport hardening covers the historical failure reported in #790, although current hosted Node 24 runners did not reproduce it naturally. The diagnostics work is related to #786 and does not claim a reproducible release-creation fix.

    What's Changed

    Exciting New Features 🎉

    Bug fixes 🐛

    Other Changes 🔄

    v3.0.1

    3.0.1

    • maintenance release with updated dependencies
    Changelog

    Sourced from softprops/action-gh-release's changelog.

    3.0.2

    3.0.2 is a patch release focused on release reliability and compatibility. It reuses existing draft releases when publishing prereleases, supports replacing release assets on Gitea, hardens streamed asset uploads, and provides clearer release-creation diagnostics. It also includes TypeScript, coverage, and tooling maintenance merged since 3.0.1.

    This release fixes #795, #438, and #803. The upload transport hardening covers the historical failure reported in #790, although current hosted Node 24 runners did not reproduce it naturally. The diagnostics work is related to #786 and does not claim a reproducible release-creation fix.

    What's Changed

    Exciting New Features 🎉

    Bug fixes 🐛

    Other Changes 🔄

    3.0.1

    • maintenance release with updated dependencies

    3.0.0

    3.0.0 is a major release that moves the action runtime from Node 20 to Node 24. Use v3 on GitHub-hosted runners and self-hosted fleets that already support the Node 24 Actions runtime. v2.6.2 was the final Node 20-compatible release and is no longer maintained or supported.

    What's Changed

    Other Changes 🔄

    • Move the action runtime and bundle target to Node 24
    • Update @types/node to the Node 24 line and allow future Dependabot updates
    • Keep the floating major tag on v3; freeze v2 at the final v2.6.2 release

    ... (truncated)

    Commits
    • 3d0d988 release 3.0.2 (#818)
    • 7e13ed4 fix: clarify release creation 404 errors (#817)
    • e6c70a5 fix: replace existing release assets on Gitea (#816)
    • f345337 fix: publish existing draft releases as prereleases (#801)
    • d8a89a2 fix: upload small checksum assets reliably (#815)
    • 45ece40 chore(deps): remove unused TypeScript tooling (#814)
    • f6b913c feat: improve release error reporting and test coverage (#813)
    • 15f193d chore(deps): upgrade TypeScript to 7 (#812)
    • cc8268d chore(deps): bump actions/checkout in the github-actions group (#810)
    • fd0ed1e chore(deps): bump the npm group with 3 updates (#811)
    • Additional commits viewable in compare view

    Most Recent Ignore Conditions Applied to This Pull Request | Dependency Name | Ignore Conditions | | --- | --- | | softprops/action-gh-release | [>= 2.2.a, < 2.3] |
    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=softprops/action-gh-release&package-manager=github_actions&previous-version=3.0.0&new-version=3.0.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/multiOSReleases.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index f5a99a0ef2..ebf2585bb1 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -902,7 +902,7 @@ jobs: # instead of silently shipping a broken auto-update. - name: Upload binaries to Release if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master' - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: tag_name: v${{ needs.determine-matrix.outputs.version }} # Don't regenerate/append notes on re-runs, and don't force this into the From 5c319f13cbbc72c48dcad93eda7a36e6cf44a4aa Mon Sep 17 00:00:00 2001 From: "posthog-eu[bot]" <226701856+posthog-eu[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:13:30 +0000 Subject: [PATCH 086/122] Fix pt-BR download label mislabeled as "Baixar (JSON)" (#7043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - **What:** Corrected the pt-BR (Brazilian Portuguese) `download` translation from `"Baixar (JSON)"` to `"Baixar"` in `frontend/editor/public/locales/pt-BR/translation.toml`, in both the root table (line 32) and the `[fileManager]` section (line 3577). - **Why:** The generic `download` key flows through `useFileActionTerminology` (`download: t("download", "Download")`) into the shared download button rendered on tool-result screens (e.g. `ReviewToolStep`). Because the string was `"Baixar (JSON)"`, every tool's Download button showed "Baixar (JSON)" for pt-BR users — implying a JSON export regardless of the actual output format. This mislabeling was locale-wide (all pt-BR users, all tool downloads). Session autocapture confirmed the confusion: a pt-BR user on `/convert` repeatedly clicked a button whose text was exactly "Baixar (JSON)", then abandoned the flow. Nothing crashed — it's a confusing label, not a functional break. - **Scope / verification:** en-US uses plain `"Download"` for this key and pt-PT already uses `"Transferir"`; no other locale carried the `"(JSON)"` suffix on the download key, so the defect was isolated to pt-BR. Only translation values changed — no keys added/removed, so translation counts are unaffected. Note: I scoped this to the mislabel — the exact symptom users observed. The report also mentions the download being a silent anchor-click with no success toast; that's a separate, broader UX enhancement in `ReviewToolStep`/`WorkbenchBar`/`downloadService`, so it's intentionally left out of this focused translation fix. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Translations (if applicable) - [x] Only a value correction in `pt-BR`; no translation tags added or removed. --- *Created with [PostHog Code](https://posthog.com/code?ref=pr) from [an inbox report](posthog-code://inbox/019f655d-bd60-78c2-ba59-98c23243ed57).* Co-authored-by: posthog-eu[bot] <226701856+posthog-eu[bot]@users.noreply.github.com> --- frontend/editor/public/locales/pt-BR/translation.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/editor/public/locales/pt-BR/translation.toml b/frontend/editor/public/locales/pt-BR/translation.toml index 6fbc440d20..689d72b58c 100644 --- a/frontend/editor/public/locales/pt-BR/translation.toml +++ b/frontend/editor/public/locales/pt-BR/translation.toml @@ -29,7 +29,7 @@ customTextTooltip = "Formato personalizado opcional para os números de página. delete = "Apagar" details = "Detalhes" discardChanges = "Descartar alterações" -download = "Baixar (JSON)" +download = "Baixar" downloadPdf = "Baixar PDF" downloadUnavailable = "Download indisponível para este item" edit = "Editar" @@ -3574,7 +3574,7 @@ deleteAll = "Excluir tudo" deleteSelected = "Apagar Selecionados" deselectAll = "Desselecionar Tudo" details = "Detalhes do arquivo" -download = "Baixar (JSON)" +download = "Baixar" downloadSelected = "Baixar selecionados" dropFilesHere = "Solte os arquivos aqui" fileFormat = "Formato" From 94fbc74271a44f4817672e71580df45f5f8c156f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:20:17 +0100 Subject: [PATCH 087/122] build(deps): bump the uv group across 1 directory with 3 updates (#7287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the uv group with 3 updates in the /engine directory: [cryptography](https://github.com/pyca/cryptography), [aiohttp](https://github.com/aio-libs/aiohttp) and [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator). Updates `cryptography` from 49.0.0 to 50.0.0
    Changelog

    Sourced from cryptography's changelog.

    50.0.0 - 2026-07-31

    
    * **SECURITY ISSUE**:
    
    :func:`~cryptography.hazmat.primitives.serialization.pkcs7.pkcs7_decrypt_der`
    and its PEM and S/MIME variants no longer expose distinguishable errors
    or
    timing when unwrapping a ``RecipientInfo``'s ``encryptedKey``, which
    could
    act as a Bleichenbacher oracle for callers that decrypt untrusted
    messages.
    A random key is now substituted on failure, as described in :rfc:`3218`.
      Credit to **@X1AOxiang** for reporting the issue. **CVE-2026-69247**
    * Deprecated Diffie-Hellman key exchange over finite fields (FFDH).
      Everything FFDH is deprecated, including the types in
    ``cryptography.hazmat.primitives.asymmetric.dh`` and loading FFDH keys
    or
      parameters with the key loading APIs. Users should migrate to a more
      modern key exchange algorithm.
    * Added ``xof()`` class methods to
      :class:`~cryptography.hazmat.primitives.hashes.SHAKE128` and
    :class:`~cryptography.hazmat.primitives.hashes.SHAKE256` for
    constructing
      algorithm instances configured for use with
      :class:`~cryptography.hazmat.primitives.hashes.XOFHash`.
    * The :mod:`X.509 verification <cryptography.x509.verification>`
    APIs are now
      considered stable and are subject to our API stability policy.
    * Added the :doc:`/cobblestone` recipe, an implementation of the
      Cobblestone-128 and Cobblestone-256 instantiations of the `C2SP
      chunked-encryption specification
    <https://c2sp.org/chunked-encryption>`_ for streaming
    authenticated
      encryption of large messages.
    * Parsing a Signed Certificate Timestamp list now rejects encodings that
    carry trailing bytes after the list or after an individual SCT, instead
    of
      silently ignoring them.
    * Added support for using :class:`~cryptography.x509.Name` as a field
    type in
      the :doc:`/hazmat/asn1/index` module.
    * Loading a public key or an EC private key now rejects DER where the
    ``subjectPublicKey`` (or EC ``publicKey``) ``BIT STRING`` declares a
    non-zero
      number of unused bits, instead of silently ignoring it.
    * Parsing a CRL entry's ``InvalidityDate`` extension now rejects a
    ``GeneralizedTime`` that carries fractional seconds or another non-DER
    form,
    matching the strict encoding already required for every other X.509 time
      field.
    * :func:`~cryptography.x509.ocsp.load_der_ocsp_request` and
    :func:`~cryptography.x509.ocsp.load_der_ocsp_response` now reject a
    request
    or response whose ``version`` field is not ``v1``, the only version
    defined
    by RFC 6960, matching the version validation already performed when
    loading
      certificates, CSRs and CRLs.
    * :class:`~cryptography.hazmat.primitives.hashes.XOFHash` is now
    supported
      when building against AWS-LC.
    * HMAC (and therefore PBKDF2-HMAC) with SHA-3 hashes is now supported
    when
      building against AWS-LC.
    * Diffie-Hellman (:doc:`/hazmat/primitives/asymmetric/dh`) is now
    supported
      when building against AWS-LC.
    </tr></table>
    

    ... (truncated)

    Commits

    Updates `aiohttp` from 3.14.1 to 3.14.3
    Changelog

    Sourced from aiohttp's changelog.

    3.14.3 (2026-07-22)

    Bug fixes

    • Fixed the client dropping only the first Authorization, Cookie and Proxy-Authorization header when a redirect crossed an origin -- by :user:arshsmith1.

      Related issues and pull requests on GitHub: :issue:13180.

    • Fixed error message construction in the C HTTP parser -- by :user:bdraco.

      Related issues and pull requests on GitHub: :issue:13222.


    3.14.2 (2026-07-20)

    Bug fixes

    • Fixed :py:attr:~aiohttp.web.StreamResponse.last_modified rounding a :class:datetime.datetime with a fractional second down.

      Related issues and pull requests on GitHub: :issue:5303.

    • Fixed resolving localhost on Windows to fall back without AI_ADDRCONFIG when the first lookup fails, so localhost still works without an active network.

      Related issues and pull requests on GitHub: :issue:5357.

    ... (truncated)

    Commits

    Updates `datamodel-code-generator` from 0.56.0 to 0.64.0
    Release notes

    Sourced from datamodel-code-generator's releases.

    0.64.0

    Breaking Changes

    Code Generation Changes

    • Self-referencing fields are now quoted with --disable-future-imports - When --disable-future-imports is set (no from __future__ import annotations and no native PEP 649 deferred evaluation on Python < 3.14), self-referencing and forward-referencing field annotations in regular BaseModel classes are now emitted as quoted forward references instead of bare names. Previously such annotations were left unquoted, producing invalid code that raised NameError (Ruff F821) at class-evaluation time. Output for the common case (with from __future__ import annotations or Python 3.14 native deferred annotations) is unchanged. Users who snapshot/golden-file generated output for the --disable-future-imports configuration with self-referencing models will see the annotation change from unquoted to quoted, e.g. children: Optional[List[Node]]children: Optional[List["Node"]]. (#3387)

    What's Changed

    ... (truncated)

    Changelog

    Sourced from datamodel-code-generator's changelog.

    0.64.0 - 2026-06-14

    Breaking Changes

    Code Generation Changes

    • Self-referencing fields are now quoted with --disable-future-imports - When --disable-future-imports is set (no from __future__ import annotations and no native PEP 649 deferred evaluation on Python < 3.14), self-referencing and forward-referencing field annotations in regular BaseModel classes are now emitted as quoted forward references instead of bare names. Previously such annotations were left unquoted, producing invalid code that raised NameError (Ruff F821) at class-evaluation time. Output for the common case (with from __future__ import annotations or Python 3.14 native deferred annotations) is unchanged. Users who snapshot/golden-file generated output for the --disable-future-imports configuration with self-referencing models will see the annotation change from unquoted to quoted, e.g. children: Optional[List[Node]]children: Optional[List["Node"]]. (#3387)

    What's Changed

    ... (truncated)

    Commits

    Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- engine/pyproject.toml | 2 +- engine/uv.lock | 302 +++++++++++++++++++++--------------------- 2 files changed, 152 insertions(+), 152 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 8cbb94daf8..29f2dcbdf7 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "AI Document Engine" requires-python = ">=3.13" dependencies = [ - "cryptography>=44.0.0", + "cryptography>=50.0.0", "fastapi>=0.116.0", "pgvector>=0.3.6", "psycopg[binary,pool]>=3.2", diff --git a/engine/uv.lock b/engine/uv.lock index fd10a81182..4f0ff52acd 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -41,7 +41,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -52,72 +52,72 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -473,52 +473,52 @@ wheels = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, ] [[package]] @@ -538,21 +538,21 @@ wheels = [ [[package]] name = "datamodel-code-generator" -version = "0.56.0" +version = "0.64.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, - { name = "black" }, + { name = "black", marker = "sys_platform != 'emscripten'" }, { name = "genson" }, { name = "inflect" }, - { name = "isort" }, + { name = "isort", marker = "sys_platform != 'emscripten'" }, { name = "jinja2" }, { name = "pydantic" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/7d/7fc2bb3d8946ca45851da3f23497a2c6e252e92558ccbd89d609cf1e13d4/datamodel_code_generator-0.56.0.tar.gz", hash = "sha256:e7c003fb5421b890aabe12f66ae65b57198b04cfe1da7c40810798020835b3a8", size = 837708, upload-time = "2026-04-04T09:46:19.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/d2/86c94a2836ed42231653a7ddaefa0a5bc23418167a876bba7376c96b3a35/datamodel_code_generator-0.64.0.tar.gz", hash = "sha256:9c592900a00b20e416494273c22435f5a9aef6ea8c7b9190747522a60497a1cb", size = 1316440, upload-time = "2026-06-14T17:24:50.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/23/94/71338e2f0146ac10747a5537b3a1e45256e66b7c229869eb0ee787111b41/datamodel_code_generator-0.64.0-py3-none-any.whl", hash = "sha256:b7cd8bd41a312aa997aec6150670bad781847c5b674f17e4d70e78208a0fb990", size = 374698, upload-time = "2026-06-14T17:24:48.809Z" }, ] [package.optional-dependencies] @@ -632,7 +632,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "cryptography", specifier = ">=44.0.0" }, + { name = "cryptography", specifier = ">=50.0.0" }, { name = "fastapi", specifier = ">=0.116.0" }, { name = "opentelemetry-sdk", specifier = ">=1.39.0" }, { name = "pgvector", specifier = ">=0.3.6" }, @@ -800,7 +800,7 @@ name = "ffmpeg-python" version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "future", marker = "python_full_version < '3.14'" }, + { name = "future" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/5e/d5f9105d59c1325759d838af4e973695081fbbc97182baf73afc78dec266/ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127", size = 21543, upload-time = "2019-07-06T00:19:08.989Z" } wheels = [ @@ -1346,7 +1346,7 @@ name = "jsonpatch" version = "1.33" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jsonpointer", marker = "python_full_version < '3.14'" }, + { name = "jsonpointer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } wheels = [ @@ -1444,15 +1444,15 @@ name = "langchain-core" version = "1.4.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jsonpatch", marker = "python_full_version < '3.14'" }, - { name = "langchain-protocol", marker = "python_full_version < '3.14'" }, - { name = "langsmith", marker = "python_full_version < '3.14'" }, - { name = "packaging", marker = "python_full_version < '3.14'" }, - { name = "pydantic", marker = "python_full_version < '3.14'" }, - { name = "pyyaml", marker = "python_full_version < '3.14'" }, - { name = "tenacity", marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, - { name = "uuid-utils", marker = "python_full_version < '3.14'" }, + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } wheels = [ @@ -1464,7 +1464,7 @@ name = "langchain-protocol" version = "0.0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } wheels = [ @@ -1476,7 +1476,7 @@ name = "langchain-text-splitters" version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core", marker = "python_full_version < '3.14'" }, + { name = "langchain-core" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580, upload-time = "2026-04-16T14:20:39.162Z" } wheels = [ @@ -1488,20 +1488,20 @@ name = "langsmith" version = "0.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version < '3.14'" }, - { name = "distro", marker = "python_full_version < '3.14'" }, - { name = "httpx", marker = "python_full_version < '3.14'" }, - { name = "orjson", marker = "python_full_version < '3.14' and platform_python_implementation != 'PyPy'" }, - { name = "packaging", marker = "python_full_version < '3.14'" }, - { name = "pydantic", marker = "python_full_version < '3.14'" }, - { name = "requests", marker = "python_full_version < '3.14'" }, - { name = "requests-toolbelt", marker = "python_full_version < '3.14'" }, - { name = "sniffio", marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, - { name = "uuid-utils", marker = "python_full_version < '3.14'" }, - { name = "websockets", marker = "python_full_version < '3.14'" }, - { name = "xxhash", marker = "python_full_version < '3.14'" }, - { name = "zstandard", marker = "python_full_version < '3.14'" }, + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/26/b72987d947278f63ec1e85f01ce85ca7ab2621c7efc0845d4a3a8e5d5dfb/langsmith-0.9.1.tar.gz", hash = "sha256:e5eb905224d156bcece4985285c55b51fffcb06c9353b2c4adb42e1c48b0d05d", size = 4557557, upload-time = "2026-06-23T17:04:23.233Z" } wheels = [ @@ -2879,7 +2879,7 @@ name = "requests-toolbelt" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests", marker = "python_full_version < '3.14'" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } wheels = [ @@ -3343,16 +3343,16 @@ name = "voyageai" version = "0.3.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "python_full_version < '3.14'" }, - { name = "aiolimiter", marker = "python_full_version < '3.14'" }, - { name = "ffmpeg-python", marker = "python_full_version < '3.14'" }, - { name = "langchain-text-splitters", marker = "python_full_version < '3.14'" }, - { name = "numpy", marker = "python_full_version < '3.14'" }, - { name = "pillow", marker = "python_full_version < '3.14'" }, - { name = "pydantic", marker = "python_full_version < '3.14'" }, - { name = "requests", marker = "python_full_version < '3.14'" }, - { name = "tenacity", marker = "python_full_version < '3.14'" }, - { name = "tokenizers", marker = "python_full_version < '3.14'" }, + { name = "aiohttp" }, + { name = "aiolimiter" }, + { name = "ffmpeg-python" }, + { name = "langchain-text-splitters" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "tenacity" }, + { name = "tokenizers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/16/1b46b3cd401e1717a68197c1fe336d7bb4e0a1833f8105e1738f5b1add05/voyageai-0.3.7.tar.gz", hash = "sha256:826cd97f97223f42b5babc5c459c9c80f3a8215ce5c0e007b0b276550f790d24", size = 26485, upload-time = "2025-12-16T18:43:05.26Z" } wheels = [ From 8476d3cdec02ce59a51b055c8ade1e4405a322a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:20:27 +0000 Subject: [PATCH 088/122] build(deps-dev): bump eslint from 10.1.0 to 10.8.0 in /frontend in the eslint group across 1 directory (#7274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the eslint group with 1 update in the /frontend directory: [eslint](https://github.com/eslint/eslint). Updates `eslint` from 10.1.0 to 10.8.0
    Release notes

    Sourced from eslint's releases.

    v10.8.0

    Features

    • 2fee9bb feat: export ConfigObject from eslint/config (#21082) (sethamus)

    Bug Fixes

    • 6b8d2f7 fix: escape reserved characters in rule id in html formatter (#21129) (Francesco Trotta)
    • 9091071 fix: prevent no-unreachable-loop crash when all loop types are ignored (#21116) (Pixel)
    • e23fafe fix: prefer-object-spread add semicolon when adding parenthesis (#21081) (synthex-byte)
    • 20b5ad0 fix: quadratic-time regex in prefer-template (#21096) (Milos Djermanovic)
    • 8b6f6c0 fix: apply ignore configs to computed methods in class-methods-use-this (#21094) (Pixel)
    • b2c608c fix: NewExpression with parenthesized callee in preserve-caught-error (#21083) (Francesco Trotta)

    Documentation

    • 6ddf858 docs: fix broken Specify Parser Options anchor link (#21106) (Minsu)
    • 784dfbe docs: Clarify no-eq-null description (#21120) (Park Harin)
    • 7ec733a docs: Fix typos and grammar in glossary (#21095) (Marry (Subin Yang))
    • 92bb13f docs: replace quake link (#21108) (Jung Hyeon Jun)
    • 68eb4a5 docs: fix broken Specify Globals anchor links in rule pages (#21103) (Minsu)
    • d28f697 docs: replace Code Climate CLI links with Qlty CLI links (#21099) (Jung Hyeon Jun)
    • eccc68d docs: correct --suppressions-location option description (#21093) (Ga eun Lee)
    • c5963f7 docs: Update README (GitHub Actions Bot)

    Chores

    • 4fbf46d test: pin webpack version to 5.108.4 (#21137) (Francesco Trotta)
    • 2d063e2 chore: update HTTP URLs to HTTPS in JSDoc and comments (#21101) (Bo Hyun Kim)
    • eccbe7b test: add error locations to no-class-assign (#21123) (devoil)
    • e7d1e43 ci: bump actions/setup-go from 6 to 7 (#21118) (dependabot[bot])
    • e9d66d0 ci: bump actions/setup-node from 6 to 7 (#21119) (dependabot[bot])
    • ee225b6 test: Add error location details to no-eq-null rule (#21117) (Park Harin)
    • 044a627 chore: update minimatch to ^10.2.5 (#21107) (김채영)
    • fb09aa8 chore: update ecosystem plugins (#21115) (ESLint Bot)
    • 5abd878 test: add error locations to no-proto (#21114) (Gihyeon Jeong / 정기현)
    • 9715887 test: Add error location details to no-div-regex (#21110) (Park Harin)
    • a746ec6 test: add error locations to no-new-wrappers (#21109) (Gihyeon Jeong / 정기현)
    • 8dde645 test: add error locations to no-ex-assign (#21102) (devoil)
    • 13ab0ec test: add error locations to no-label-var (#21098) (Gihyeon Jeong / 정기현)
    • a99906f test: Add error location details to no-delete-var rule (#21105) (Park Harin)
    • c47e8dc chore: add missing backticks to languages/js/index.js (#21104) (beeen)
    • 0174428 chore: add missing backticks to translate-cli-options.js (#21097) (dongkyu lee)
    • 3d36589 chore: add missing backticks to serialization.js (#21091) (이규환)
    • dcc9312 test: add error locations to eqeqeq (#21090) (Ga eun Lee)
    • 2710b18 ci: Add explicit permissions to rebuild-docs-sites workflow (#21089) (Marry (Subin Yang))
    • 5d2f866 chore: update dependency prettier to v3.9.5 (#21086) (renovate[bot])
    • d584e31 chore: fix failing ecosystem test for eslint-plugin-unicorn (#21084) (Francesco Trotta)
    • bf3eda0 chore: update ecosystem plugins (#21079) (ESLint Bot)

    v10.7.0

    Features

    • cf2a9bf feat: add errorClassNames option to preserve-caught-error rule (#21032) (sethamus)
    • f8b873a feat: max-nested-callbacks option for constructor callbacks (#21063) (fnx)

    ... (truncated)

    Commits

    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 65 ++++++++++++++++++++------------------ frontend/package.json | 2 +- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5abc659e3..300a409229 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -119,7 +119,7 @@ "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", "dpdm": "^3.14.0", - "eslint": "^10.0.2", + "eslint": "^10.8.0", "fake-indexeddb": "^6.2.5", "jsdom": "^27.0.0", "json-schema-to-typescript": "^15.0.4", @@ -1961,13 +1961,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.3", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", - "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.3", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" }, @@ -1976,22 +1976,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", - "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1" + "@eslint/core": "^1.2.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", - "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2023,9 +2023,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", - "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2033,13 +2033,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", - "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { @@ -9286,18 +9286,21 @@ } }, "node_modules/eslint": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", - "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.3", - "@eslint/core": "^1.1.1", - "@eslint/plugin-kit": "^0.6.1", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -9319,7 +9322,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -12905,13 +12908,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" diff --git a/frontend/package.json b/frontend/package.json index dfcfa2842c..1798abc6b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -141,7 +141,7 @@ "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", "dpdm": "^3.14.0", - "eslint": "^10.0.2", + "eslint": "^10.8.0", "fake-indexeddb": "^6.2.5", "jsdom": "^27.0.0", "json-schema-to-typescript": "^15.0.4", From de93a1b5e2685f2a6022f54ed1eb888c9b5de469 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:20:33 +0000 Subject: [PATCH 089/122] build(deps): bump org.sonarqube from 7.2.3.7755 to 7.3.1.8318 (#7271) Bumps org.sonarqube from 7.2.3.7755 to 7.3.1.8318. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.sonarqube&package-manager=gradle&previous-version=7.2.3.7755&new-version=7.3.1.8318)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 31fa6b1bdb..de0861d8fa 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { id "com.diffplug.spotless" version "8.8.0" id "com.github.jk1.dependency-license-report" //id "nebula.lint" version "19.0.3" - id "org.sonarqube" version "7.2.3.7755" + id "org.sonarqube" version "7.3.1.8318" } import com.github.jk1.license.render.* From ad8830b6459a74fd4f6b3b3cb479b97ae8dc9df4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:20:38 +0000 Subject: [PATCH 090/122] build(deps): bump com.sun.xml.bind:jaxb-core from 4.0.7 to 4.0.9 (#7270) Bumps com.sun.xml.bind:jaxb-core from 4.0.7 to 4.0.9. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=com.sun.xml.bind:jaxb-core&package-manager=gradle&previous-version=4.0.7&new-version=4.0.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/core/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/build.gradle b/app/core/build.gradle index 33aa679994..5e90672f75 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -57,7 +57,7 @@ dependencies { // veraPDF still uses javax.xml.bind, not the new jakarta namespace implementation 'javax.xml.bind:jaxb-api:2.3.1' implementation 'com.sun.xml.bind:jaxb-impl:2.3.9' - implementation 'com.sun.xml.bind:jaxb-core:4.0.7' + implementation 'com.sun.xml.bind:jaxb-core:4.0.9' // CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7) implementation "com.google.code.gson:gson:${gsonVersion}" From 74c53001cf4e652d96d2948d0b1058a827b98e24 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:05:24 +0100 Subject: [PATCH 091/122] fix(saas): give auth-bootstrap data fetching a single owner (#7194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The problem Three pieces of user data (pro status, avatar metadata, profile picture) were being fetched from four different places: `initializeAuth` on mount, the `SIGNED_IN` handler, the `TOKEN_REFRESHED` handler, and the post-upgrade path. On a fresh login the first two both see a session, so everything got fetched twice. It didn't stop after login either — Supabase re-fires `SIGNED_IN` on token refresh and tab-visibility wakeups, so a refresh that emitted both events cost around 7 Supabase reads. ## The fix All four call sites now go through one `loadUserData(session)` that is idempotent per identity. The guard key is `user.id` + `is_anonymous`: - not the access token, which changes on every refresh and would defeat the guard entirely - the anonymous flag matters because a guest to authenticated upgrade keeps the same user id, and that is the one case where the data genuinely does need reloading **Per login: 6 fetches to 3. A repeat `SIGNED_IN` or `TOKEN_REFRESHED` fetches nothing.** The tests count real calls rather than asserting on shape. ## Two behaviour changes worth naming - `initializeAuth` now awaits the full load, so the initial spinner also waits on the profile-picture URL. Net login is still faster, since an entire duplicate pass is gone. - A tab-wake `SIGNED_IN` no longer revalidates entitlements. That revalidation was accidental rather than designed — `refreshProStatus()` is the intended path, and post-checkout is already handled by `CheckoutContext`. ## Scope Supabase-origin traffic only. This does not touch the ~20 authenticated requests hitting `SupabaseAuthenticationFilter`, because those go to the Stirling backend rather than the hosted Supabase project. That is a separate problem and is unmeasured, so it needs measuring before anything is optimised. Remaining items (a double `/api/v1/team/my` fetch, an effect keyed on `[user]` identity in `FolderContext`, the `portalAccess` spinner flash, and caching the auth filter's per-request Postgres round-trips) are tracked separately. ## Verification ``` npx tsc --noEmit --project editor/src/saas/tsconfig.json # exit 0 npx eslint --max-warnings=0 editor/src/saas/auth # exit 0 npx prettier --check editor/src/saas/auth/ # clean npx vitest run --project saas # 75 passed (20 files) ``` --- .../src/saas/auth/AuthProvider.test.tsx | 339 ++++++++++++++++++ frontend/editor/src/saas/auth/UseSession.tsx | 165 +++++---- 2 files changed, 427 insertions(+), 77 deletions(-) create mode 100644 frontend/editor/src/saas/auth/AuthProvider.test.tsx diff --git a/frontend/editor/src/saas/auth/AuthProvider.test.tsx b/frontend/editor/src/saas/auth/AuthProvider.test.tsx new file mode 100644 index 0000000000..588507d59c --- /dev/null +++ b/frontend/editor/src/saas/auth/AuthProvider.test.tsx @@ -0,0 +1,339 @@ +import { act, render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Session, User } from "@supabase/supabase-js"; + +/** + * Request-count tests for {@link AuthProvider}'s data loading. It used to fetch + * pro status, avatar metadata and the picture from two places at once, and + * Supabase re-fires SIGNED_IN on token refresh and tab wakeups, so it kept + * happening. These pin the call counts. + */ + +type AuthCallback = (event: string, session: Session | null) => void; + +const rpc = vi.fn(); +const createSignedUrl = vi.fn(); +const storageFrom = vi.fn((_bucket: string) => ({ createSignedUrl })); +const getSession = vi.fn(); +const onAuthStateChange = vi.fn(); +const unsubscribe = vi.fn(); + +vi.mock("@app/auth/supabase", () => ({ + supabase: { + auth: { + getSession: () => getSession(), + onAuthStateChange: (cb: AuthCallback) => onAuthStateChange(cb), + refreshSession: vi + .fn() + .mockResolvedValue({ data: { session: null }, error: null }), + signOut: vi.fn().mockResolvedValue({ error: null }), + }, + rpc: (...args: unknown[]) => rpc(...args), + storage: { from: (bucket: string) => storageFrom(bucket) }, + }, + debugAuthEvents: vi.fn(), +})); + +const syncOAuthAvatar = vi.fn(); +const getProfilePictureMetadata = vi.fn(); + +vi.mock("@app/services/avatarSyncService", () => ({ + syncOAuthAvatar: (...args: unknown[]) => syncOAuthAvatar(...args), + getProfilePictureMetadata: (...args: unknown[]) => + getProfilePictureMetadata(...args), + getProviderAvatarUrl: () => null, +})); + +const synchronizeUserUpgrade = vi.fn(); + +vi.mock("@app/services/userService", () => ({ + synchronizeUserUpgrade: (...args: unknown[]) => + synchronizeUserUpgrade(...args), +})); + +// Imported after the mocks so the provider picks them up. +const { AuthProvider, useAuth } = await import("./UseSession"); + +/** Surfaces `loading` so a test can assert on it rather than on the container. */ +function LoadingProbe() { + const { loading } = useAuth(); + return {String(loading)}; +} + +const USER_ID = "11111111-2222-3333-4444-555555555555"; + +function makeSession( + overrides: { token?: string; userId?: string; anonymous?: boolean } = {}, +): Session { + const user = { + id: overrides.userId ?? USER_ID, + email: "someone@example.com", + is_anonymous: overrides.anonymous ?? false, + app_metadata: { provider: "google" }, + user_metadata: { full_name: "Some One" }, + } as unknown as User; + + return { + access_token: overrides.token ?? "token-1", + refresh_token: "refresh-1", + expires_in: 3600, + token_type: "bearer", + user, + } as unknown as Session; +} + +/** Total requests the provider makes per user-data load. */ +function callCounts() { + return { + proStatus: rpc.mock.calls.length, + metadata: getProfilePictureMetadata.mock.calls.length, + picture: createSignedUrl.mock.calls.length, + avatarSync: syncOAuthAvatar.mock.calls.length, + }; +} + +function renderProvider() { + let authCallback: AuthCallback = () => {}; + onAuthStateChange.mockImplementation((cb: AuthCallback) => { + authCallback = cb; + return { data: { subscription: { unsubscribe } } }; + }); + + const utils = render( + + + , + ); + + /** + * Deliver an auth event and let its work finish. The provider defers with + * setTimeout(0), so a microtask flush is not enough: without draining real + * macrotasks the assertions run before any refetch and prove nothing. + */ + const fire = async (event: string, s: Session | null) => { + await act(async () => { + authCallback(event, s); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }; + + return { ...utils, fire }; +} + +describe("AuthProvider user-data loading", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + sessionStorage.clear(); + + rpc.mockResolvedValue({ data: true, error: null }); + createSignedUrl.mockResolvedValue({ + data: { signedUrl: "https://example.test/avatar" }, + error: null, + }); + getProfilePictureMetadata.mockResolvedValue(null); + syncOAuthAvatar.mockResolvedValue(false); + synchronizeUserUpgrade.mockResolvedValue(undefined); + getSession.mockResolvedValue({ + data: { session: makeSession() }, + error: null, + }); + }); + + it("fetches each piece of user data exactly once on login", async () => { + const { fire } = renderProvider(); + + await waitFor(() => expect(createSignedUrl).toHaveBeenCalled()); + // The SIGNED_IN that follows a fresh login must not repeat the work. + await fire("SIGNED_IN", makeSession()); + + expect(callCounts()).toEqual({ + proStatus: 1, + metadata: 1, + picture: 1, + avatarSync: 1, + }); + }); + + it("does not refetch when SIGNED_IN repeats with a new access token", async () => { + const { fire } = renderProvider(); + await waitFor(() => expect(createSignedUrl).toHaveBeenCalled()); + const before = callCounts(); + + // What a tab-visibility wakeup or token refresh looks like: same user, + // different token. + await fire("SIGNED_IN", makeSession({ token: "token-2" })); + + expect(callCounts()).toEqual(before); + }); + + it("does not refetch on TOKEN_REFRESHED for the same identity", async () => { + const { fire } = renderProvider(); + await waitFor(() => expect(createSignedUrl).toHaveBeenCalled()); + const before = callCounts(); + + await fire("TOKEN_REFRESHED", makeSession({ token: "token-3" })); + + expect(callCounts()).toEqual(before); + }); + + it("keeps loading false across repeat auth events", async () => { + // Guards the Landing -> HomePage unmount: toggling `loading` on a wakeup + // would tear down the tree on every tab switch. + const { fire, getByTestId } = renderProvider(); + await waitFor(() => + expect(getByTestId("loading").textContent).toBe("false"), + ); + + await fire("SIGNED_IN", makeSession({ token: "token-4" })); + expect(getByTestId("loading").textContent).toBe("false"); + + await fire("TOKEN_REFRESHED", makeSession({ token: "token-5" })); + expect(getByTestId("loading").textContent).toBe("false"); + + expect(callCounts().proStatus).toBe(1); + }); + + it("clears the initial spinner without waiting for the avatar upload", async () => { + // syncOAuthAvatar re-uploads the provider image on a first login. Gating + // `loading` on it would stall account creation behind an image upload. + let releaseSync = () => {}; + syncOAuthAvatar.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSync = () => resolve(false); + }), + ); + + const { getByTestId } = renderProvider(); + + await waitFor(() => + expect(getByTestId("loading").textContent).toBe("false"), + ); + // The picture read chains behind the sync, so it has not run yet either. + expect(createSignedUrl).not.toHaveBeenCalled(); + + await act(async () => { + releaseSync(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }); + + it("refetches after a guest upgrade, which keeps the same user id", async () => { + // The upgrade path is the one case where the id is unchanged but the data + // must be reloaded - hence keying on is_anonymous, not the id alone. + getSession.mockResolvedValue({ + data: { session: makeSession({ anonymous: true }) }, + error: null, + }); + sessionStorage.setItem("pendingUpgrade", "true"); + sessionStorage.setItem("upgradeProvider", "google"); + + const { fire } = renderProvider(); + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(1)); + + await fire("USER_UPDATED", makeSession({ anonymous: false })); + + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(2)); + expect(synchronizeUserUpgrade).toHaveBeenCalledWith("google"); + }); + + it("does not drop an upgrade that lands while the guest load is in flight", async () => { + // Coalescing on "something is in flight" alone would hand the upgrade the + // guest's promise and never fetch the real user's data. + getSession.mockResolvedValue({ + data: { session: makeSession({ anonymous: true }) }, + error: null, + }); + sessionStorage.setItem("pendingUpgrade", "true"); + sessionStorage.setItem("upgradeProvider", "google"); + + let releaseGuestLoad = () => {}; + const guestLoadBlocked = new Promise((resolve) => { + releaseGuestLoad = resolve; + }); + rpc.mockImplementationOnce(async () => { + await guestLoadBlocked; + return { data: false, error: null }; + }); + + const { fire } = renderProvider(); + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(1)); + + // Deliver the upgrade with the guest load still deliberately unsettled, so + // the guard genuinely has an in-flight load to reason about. + await fire("USER_UPDATED", makeSession({ anonymous: false })); + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(2), { + timeout: 1000, + }); + + // Let the abandoned guest load settle inside act, so its trailing state + // updates do not land after the test finishes. + await act(async () => { + releaseGuestLoad(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }); + + it("reloads for the same user after a sign-out", async () => { + const { fire } = renderProvider(); + await waitFor(() => expect(createSignedUrl).toHaveBeenCalled()); + + await fire("SIGNED_OUT", null); + await fire("SIGNED_IN", makeSession()); + + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(2)); + }); + + it("keeps the initial spinner up when SIGNED_IN wins the race with initializeAuth", async () => { + // initializeAuth yields at `await getSession()`, so SIGNED_IN can land + // first. Marking the identity loaded up front would then clear the spinner + // while pro status was still in flight. + const session = makeSession(); + + let releaseSession = () => {}; + getSession.mockReturnValueOnce( + new Promise<{ data: { session: Session }; error: null }>((resolve) => { + releaseSession = () => resolve({ data: { session }, error: null }); + }), + ); + + let releaseProStatus = () => {}; + rpc.mockImplementationOnce( + () => + new Promise<{ data: boolean; error: null }>((resolve) => { + releaseProStatus = () => resolve({ data: true, error: null }); + }), + ); + + const { getByTestId, fire } = renderProvider(); + + // SIGNED_IN lands first and starts the load; pro status stays unsettled. + await fire("SIGNED_IN", session); + expect(rpc).toHaveBeenCalledTimes(1); + + // initializeAuth must now adopt that in-flight load, not short-circuit. + await act(async () => { + releaseSession(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(getByTestId("loading").textContent).toBe("true"); + // Adopted, not restarted. + expect(rpc).toHaveBeenCalledTimes(1); + + await act(async () => { + releaseProStatus(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await waitFor(() => + expect(getByTestId("loading").textContent).toBe("false"), + ); + expect(rpc).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index 087fa99b65..101ed58d06 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, + useRef, useState, ReactNode, useCallback, @@ -247,6 +248,78 @@ export function AuthProvider({ children }: { children: ReactNode }) { await fetchProfilePictureMetadata(); }, [fetchProfilePictureMetadata]); + // Refs, not state: the auth effect below has an empty dep array. + const loadedForRef = useRef(null); + const inFlightRef = useRef<{ key: string; promise: Promise } | null>( + null, + ); + + /** + * Sole owner of the per-user data fetching: mount-time init and every auth + * event route through here. Idempotent per identity, since Supabase re-fires + * SIGNED_IN and TOKEN_REFRESHED on token refresh and tab wakeups; pass + * `force` for a genuine reload. The returned promise excludes the profile + * picture, so awaiting it never blocks on an image download. + */ + const loadUserData = useCallback( + ( + sessionToLoad: Session | null, + opts?: { force?: boolean }, + ): Promise => { + const user = sessionToLoad?.user; + if (!user) { + loadedForRef.current = null; + return Promise.resolve(); + } + + // Not the access token, which changes every refresh. The anonymous flag + // matters: a guest upgrade keeps the same id and must still refetch. + const key = `${user.id}:${Boolean(user.is_anonymous)}`; + if (!opts?.force && loadedForRef.current === key) + return Promise.resolve(); + + // The second of a concurrent init/SIGNED_IN pair adopts this promise so it + // still awaits the load. A forced reload must not: the guest upgrade + // would be silently dropped. + if (!opts?.force && inFlightRef.current?.key === key) + return inFlightRef.current.promise; + + const run = (async () => { + // Off the awaited path: a first login re-uploads the provider avatar. + // The signed-URL read chains behind it because reading first 404s and + // silently falls back to the provider photo. + const avatarSync = syncOAuthAvatar(user).catch((err) => { + console.debug("[Auth Debug] Failed to sync OAuth avatar:", err); + return false; + }); + void avatarSync + .then(() => fetchProfilePicture(sessionToLoad)) + .catch((err) => { + console.debug("[Auth Debug] Failed to fetch profile picture:", err); + }); + + await Promise.all([ + fetchProStatus(sessionToLoad), + fetchProfilePictureMetadata(sessionToLoad), + ]); + // Only on success: set up front, a concurrent caller short-circuits on + // it and returns to a still-empty state. Also lets a failure retry. + loadedForRef.current = key; + })() + .catch((err) => { + console.debug("[Auth Debug] Failed to load user data:", err); + }) + .finally(() => { + // Only clear our own entry; a newer load may have superseded us. + if (inFlightRef.current?.promise === run) inFlightRef.current = null; + }); + + inFlightRef.current = { key, promise: run }; + return run; + }, + [fetchProStatus, fetchProfilePictureMetadata, fetchProfilePicture], + ); + const refreshSession = async () => { try { setLoading(true); @@ -312,23 +385,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); setSession(data.session); - // Fetch pro status, profile picture metadata, and profile picture using the session from the response - if (data.session?.user) { - // Sync OAuth avatar in background; fetch the picture once the - // sync settles instead of guessing with a fixed delay. - syncOAuthAvatar(data.session.user) - .catch((err) => { - console.debug( - "[Auth Debug] Failed to sync OAuth avatar on init:", - err, - ); - return false; - }) - .then(() => fetchProfilePicture(data.session)); - - await fetchProStatus(data.session); - await fetchProfilePictureMetadata(data.session); - } + // Awaited so the spinner does not clear before pro status is known. + await loadUserData(data.session); } } catch (err) { console.error( @@ -374,58 +432,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { setIsPro(null); setProfilePictureUrl(null); setProfilePictureMetadata(null); - } else if (event === "SIGNED_IN") { - console.debug("[Auth Debug] User signed in successfully"); - if (newSession?.user) { - // Note: we deliberately do NOT toggle `loading` here. Supabase - // also fires SIGNED_IN on tab visibility / token-refresh wakeups - // (per its docs: "SIGNED_IN is fired when a user signs in OR - // when the access token is refreshed"), and gating the UI on - // `loading` would unmount Landing -> HomePage every time the - // user switches tabs back. Initial-mount loading is handled by - // `initializeAuth` above; downstream fetches expose their own - // null/loading states. - - // Sync OAuth avatar in background (don't block other fetches) - const avatarSync = syncOAuthAvatar(newSession.user).catch( - (err) => { - console.debug( - "[Auth Debug] Failed to sync OAuth avatar:", - err, - ); - return false; - }, - ); - - // Fetch user data in parallel - Promise.all([ - fetchProStatus(newSession), - fetchProfilePictureMetadata(newSession), - ]).then(() => { - // Fetch the picture once the avatar sync settles. - avatarSync.then(() => { - fetchProfilePicture(newSession).finally(() => { - console.debug( - "[Auth Debug] User data fully loaded after sign in", - ); - }); - }); - }); - } - } else if (event === "TOKEN_REFRESHED") { - console.debug("[Auth Debug] Token refreshed"); - // Optionally refresh pro status, profile picture metadata, and profile picture on token refresh - if (newSession?.user) { - Promise.all([ - fetchProStatus(newSession), - fetchProfilePictureMetadata(newSession), - fetchProfilePicture(newSession), - ]).then(() => { - console.debug( - "[Auth Debug] User data refreshed after token refresh", - ); - }); - } + loadedForRef.current = null; + } else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") { + console.debug("[Auth Debug] Signed in or token refreshed"); + // Deliberately does not touch `loading`: Supabase also fires + // SIGNED_IN on tab wakeups, and gating the UI on it would unmount + // Landing -> HomePage on every tab switch. Pinned by a test. + void loadUserData(newSession); } else if (event === "USER_UPDATED") { console.debug("[Auth Debug] User updated"); @@ -454,14 +467,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { "[Auth Debug] User upgrade synchronized successfully", ); - // Refresh pro status, profile picture metadata, and profile picture after upgrade - if (newSession?.user) { - return Promise.all([ - fetchProStatus(newSession), - fetchProfilePictureMetadata(newSession), - fetchProfilePicture(newSession), - ]); - } + // Forced: same user id, so the guest's data must be replaced. + return loadUserData(newSession, { force: true }); }) .then(() => { console.debug( @@ -484,6 +491,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { mounted = false; subscription.unsubscribe(); }; + // Empty and load-bearing: must subscribe once. The closures are recreated + // when `session` changes, so listing deps would re-subscribe on every auth + // event; every call above passes its session explicitly instead. No lint + // rule enforces this, so do not "fix" these deps. }, []); const { t } = useTranslation(); From 86d4a344767b183a395dc12495eda85fb10958c6 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 15:54:44 +0200 Subject: [PATCH 092/122] deps: Upgrade posthog-js to 1.405.2 (#7115) # Description of Changes - Updated the frontend `posthog-js` dependency from `^1.268.0` to `^1.405.2`. - Refreshed `frontend/package-lock.json` and updated related PostHog transitive dependencies. - Removed obsolete OpenTelemetry and protobuf-related transitive packages no longer required by the newer PostHog version. - The existing PostHog APIs used by Stirling-PDF remain compatible. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- frontend/package-lock.json | 46 ++++++++++++++++++++++---------------- frontend/package.json | 2 +- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 300a409229..546e2ef705 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -70,7 +70,7 @@ "pdfjs-dist": "^5.4.149", "peerjs": "^1.5.5", "pixelmatch": "^7.1.0", - "posthog-js": "^1.268.0", + "posthog-js": "^1.405.2", "qrcode.react": "^4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -3154,12 +3154,12 @@ } }, "node_modules/@posthog/core": { - "version": "1.39.3", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.3.tgz", - "integrity": "sha512-oR+B8Q5O61N+W2+HVOBG9dbxAT/+OVxX+XvpNj6KYgptN2EB14JiKQ9Rm7DNpErNTLV7WApZEK/URkZgldOxfg==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.44.0.tgz", + "integrity": "sha512-uE+mdKvetxNQC6gWQf4MHIH8bGt+JN6z7ho0A4G3t9G1VGQTx9wHYHNwkKf3gzi+1oAXS9ej2VVY4pjWUU2PWg==", "license": "MIT", "dependencies": { - "@posthog/types": "^1.392.0" + "@posthog/types": "^1.397.0" } }, "node_modules/@posthog/react": { @@ -3179,9 +3179,9 @@ } }, "node_modules/@posthog/types": { - "version": "1.392.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz", - "integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==", + "version": "1.397.1", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.397.1.tgz", + "integrity": "sha512-W/LpWbKVaaUnfZKuFuHa+Dg03D+fC87cM+PQbG+59JcSPW8F0JcBtSoXmpfrqbpuxUToMo+gktutrUkAb/KQBw==", "license": "MIT" }, "node_modules/@puppeteer/browsers": { @@ -14053,17 +14053,17 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.396.4", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.396.4.tgz", - "integrity": "sha512-PycBmwKQD1T7YFYrGRb8rjQET/UVnexgUy8gVe6UBEhwHXEIhZF4na5VakJbn4zu1wg4tzjt8r7PA4VLu6bDjg==", - "license": "SEE LICENSE IN LICENSE", + "version": "1.405.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.405.2.tgz", + "integrity": "sha512-KPbbAX4EKM8UTU13gW/fZHyonfVee+GIj9QyiXtI2N4ooku69eZzfgk3CxQOUcnuvCz6xaQaUlvRmIsROxTCvw==", + "license": "(Apache-2.0 AND MIT)", "dependencies": { - "@posthog/core": "^1.39.3", - "@posthog/types": "^1.392.0", - "core-js": "^3.38.1", + "@posthog/core": "^1.44.0", + "@posthog/types": "^1.397.1", + "core-js": "^3.49.0", "dompurify": "^3.3.2", "fflate": "^0.4.8", - "preact": "^10.29.2", + "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0" } @@ -14082,13 +14082,21 @@ } }, "node_modules/preact": { - "version": "10.29.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.3.tgz", - "integrity": "sha512-D9NL1GAnJZhc3RndVs4gDdxEeU9TcHgywMrhhOsnpdlvFjdbx0gAsLUnH6JEhlJH5giL7Tx5biWPUSEXE/HPzw==", + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } } }, "node_modules/prelude-ls": { diff --git a/frontend/package.json b/frontend/package.json index 1798abc6b2..f48fad8571 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,7 +67,7 @@ "pdfjs-dist": "^5.4.149", "peerjs": "^1.5.5", "pixelmatch": "^7.1.0", - "posthog-js": "^1.268.0", + "posthog-js": "^1.405.2", "qrcode.react": "^4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", From fa2eb6712445c741ff7b5167420ed5b28505bd96 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 16:56:12 +0200 Subject: [PATCH 093/122] deps(frontend): align dependency scopes and remove redundant ESLint packages (#6991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes This change reorganizes frontend dependencies by moving development-only packages into `devDependencies`, removing obsolete packages, and updating several development tooling dependencies to newer versions. ### What was changed - Moved runtime-independent packages to `devDependencies`: - `@iconify/react` - `globals` - Removed unused TypeScript ESLint packages: - `@typescript-eslint/eslint-plugin` - `@typescript-eslint/parser` - Updated development dependencies: - `@iconify-json/material-symbols` → `1.2.83` - `@iconify/utils` → `3.1.4` - `globals` → `17.7.0` These changes reduce redundant dependency declarations and ensure packages are classified according to their actual usage. The main challenge was distinguishing direct dependencies from packages already provided transitively by frontend tooling. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- frontend/package-lock.json | 716 ++++++++++++++++++++++++++++++------- frontend/package.json | 14 +- 2 files changed, 594 insertions(+), 136 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 546e2ef705..6f19db35b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,7 +37,6 @@ "@embedpdf/plugin-zoom": "^2.14.4", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@iconify/react": "^6.0.2", "@mantine/core": "^8.3.1", "@mantine/dates": "^8.3.1", "@mantine/dropzone": "^8.3.1", @@ -62,7 +61,6 @@ "autoprefixer": "^10.4.21", "axios": "^1.15.0", "d3": "^7.9.0", - "globals": "^17.5.0", "i18next": "^25.10.10", "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", @@ -89,8 +87,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@iconify-json/material-symbols": "^1.2.53", - "@iconify/utils": "^3.1.0", + "@iconify-json/material-symbols": "^1.2.83", + "@iconify/react": "^6.0.2", + "@iconify/utils": "^3.1.4", "@playwright/test": "^1.55.0", "@storybook/addon-a11y": "^9.1.20", "@storybook/addon-docs": "^9.1.20", @@ -115,12 +114,13 @@ "@typescript-eslint/parser": "^8.65.0", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react-swc": "^4.1.0", - "@vitest/browser": "^3.2.6", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/browser": "3.2.7", + "@vitest/coverage-v8": "3.2.7", "dotenv": "^16.4.7", "dpdm": "^3.14.0", "eslint": "^10.8.0", "fake-indexeddb": "^6.2.5", + "globals": "^17.7.0", "jsdom": "^27.0.0", "json-schema-to-typescript": "^15.0.4", "license-checker": "^25.0.1", @@ -142,7 +142,7 @@ "vite-plugin-compression2": "^2.5.3", "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.4" + "vitest": "3.2.7" } }, "node_modules/@acemir/cssom": { @@ -2170,9 +2170,9 @@ } }, "node_modules/@iconify-json/material-symbols": { - "version": "1.2.63", - "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.63.tgz", - "integrity": "sha512-R4PS/l8K6j+dk2P2MoYFLJgfbZ4YDo6XjCOpX6b1tvX+BhiSpSOjc1b6cnb/mvWe+JWBKlt4pcvPNiAijFLPnA==", + "version": "1.2.83", + "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.83.tgz", + "integrity": "sha512-4I2rfNlaoyn4zIcdJDxUMuPV1pVp8Tgwy+eJvyAZuOpmPtOsniQ8Dug6wzRD5s9KLyQA3smFvuBphVdYG7NWQA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2183,6 +2183,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@iconify/react/-/react-6.0.2.tgz", "integrity": "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg==", + "dev": true, "license": "MIT", "dependencies": { "@iconify/types": "^2.0.0" @@ -2198,18 +2199,19 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, "license": "MIT" }, "node_modules/@iconify/utils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", - "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", "dev": true, "license": "MIT", "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", - "mlly": "^1.8.0" + "import-meta-resolve": "^4.2.0" } }, "node_modules/@inquirer/ansi": { @@ -6433,16 +6435,16 @@ } }, "node_modules/@vitest/browser": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.6.tgz", - "integrity": "sha512-CNjSynGBtAVOMTfQITv6Bc8da4/XTU1izorocbDStjUsynXcgx2FHVssh+10a8bKd/BxoqDdQtuSbYHfk302Wg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.7.tgz", + "integrity": "sha512-gIzazUkbQfv6T1rJHOLhMMKQnplKAvvQ7QNGaFwI6oCsp4z2aSDZCojGpX3QX3+MYsvJdyy/8BRIYVEbAkMkEA==", "dev": true, "license": "MIT", "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", - "@vitest/mocker": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/mocker": "3.2.7", + "@vitest/utils": "3.2.7", "magic-string": "^0.30.17", "sirv": "^3.0.1", "tinyrainbow": "^2.0.0", @@ -6453,7 +6455,7 @@ }, "peerDependencies": { "playwright": "*", - "vitest": "3.2.6", + "vitest": "3.2.7", "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" }, "peerDependenciesMeta": { @@ -6469,13 +6471,13 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -6496,9 +6498,9 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -6509,9 +6511,9 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6522,13 +6524,13 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -6537,9 +6539,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", - "integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz", + "integrity": "sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==", "dev": true, "license": "MIT", "dependencies": { @@ -6561,8 +6563,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.6", - "vitest": "3.2.6" + "@vitest/browser": "3.2.7", + "vitest": "3.2.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -6628,13 +6630,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", - "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.6", + "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -6643,9 +6645,9 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -6656,13 +6658,13 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -6671,13 +6673,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", - "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -6686,9 +6688,9 @@ } }, "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -8078,13 +8080,6 @@ "dev": true, "license": "MIT" }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -10193,9 +10188,10 @@ } }, "node_modules/globals": { - "version": "17.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", - "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -12962,17 +12958,14 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" + "engines": { + "node": ">=10" } }, "node_modules/mrmime": { @@ -13716,18 +13709,6 @@ "pixelmatch": "bin/pixelmatch" } }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, "node_modules/playwright": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", @@ -17409,9 +17390,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -17427,6 +17408,490 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/tsx/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -17512,13 +17977,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", - "dev": true, - "license": "MIT" - }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -18097,20 +18555,20 @@ } }, "node_modules/vitest": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", - "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.6", - "@vitest/mocker": "3.2.6", - "@vitest/pretty-format": "^3.2.6", - "@vitest/runner": "3.2.6", - "@vitest/snapshot": "3.2.6", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -18140,8 +18598,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.6", - "@vitest/ui": "3.2.6", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, @@ -18170,15 +18628,15 @@ } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", - "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -18187,13 +18645,13 @@ } }, "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -18214,9 +18672,9 @@ } }, "node_modules/vitest/node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -18227,9 +18685,9 @@ } }, "node_modules/vitest/node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18240,13 +18698,13 @@ } }, "node_modules/vitest/node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, diff --git a/frontend/package.json b/frontend/package.json index f48fad8571..bc01367cef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,7 +34,6 @@ "@embedpdf/plugin-zoom": "^2.14.4", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@iconify/react": "^6.0.2", "@mantine/core": "^8.3.1", "@mantine/dates": "^8.3.1", "@mantine/dropzone": "^8.3.1", @@ -59,7 +58,6 @@ "autoprefixer": "^10.4.21", "axios": "^1.15.0", "d3": "^7.9.0", - "globals": "^17.5.0", "i18next": "^25.10.10", "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", @@ -111,8 +109,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@iconify-json/material-symbols": "^1.2.53", - "@iconify/utils": "^3.1.0", + "@iconify-json/material-symbols": "^1.2.83", + "@iconify/react": "^6.0.2", + "@iconify/utils": "^3.1.4", "@playwright/test": "^1.55.0", "@storybook/addon-a11y": "^9.1.20", "@storybook/addon-docs": "^9.1.20", @@ -137,12 +136,13 @@ "@typescript-eslint/parser": "^8.65.0", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react-swc": "^4.1.0", - "@vitest/browser": "^3.2.6", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/browser": "3.2.7", + "@vitest/coverage-v8": "3.2.7", "dotenv": "^16.4.7", "dpdm": "^3.14.0", "eslint": "^10.8.0", "fake-indexeddb": "^6.2.5", + "globals": "^17.7.0", "jsdom": "^27.0.0", "json-schema-to-typescript": "^15.0.4", "license-checker": "^25.0.1", @@ -164,7 +164,7 @@ "vite-plugin-compression2": "^2.5.3", "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.4" + "vitest": "3.2.7" }, "depcheck": { "ignoreMatches": [ From 2cf6db99ceb0b8321f29f4a4cde18dbb4baec1e0 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:47:09 +0100 Subject: [PATCH 094/122] Fix timing-fragile Valkey rate-limit boundary test (#7302) # Description of Changes Fix timing-fragile Valkey rate-limit boundary test --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../valkey/LiveValkeyIntegrationTest.java | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/valkey/LiveValkeyIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/valkey/LiveValkeyIntegrationTest.java index c26528a715..0e6cb2f4de 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/valkey/LiveValkeyIntegrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/valkey/LiveValkeyIntegrationTest.java @@ -298,24 +298,44 @@ class LiveValkeyIntegrationTest { ValkeyRateLimitStore store = newRateLimitStore(factoryA); String key = "boundary-" + java.util.UUID.randomUUID(); long capacity = 5; - Duration window = Duration.ofMillis(500); + // refillGreedy tops the bucket up continuously, one token every window/capacity. A 500ms + // window left the drain loop only 100ms before a 6th token appeared, so a slow Valkey + // round-trip broke the count; 4s spaces refills 800ms apart, clear of any burst. + Duration window = Duration.ofSeconds(4); + long refillIntervalMs = window.toMillis() / capacity; + long drainStart = System.nanoTime(); int firstAllowed = 0; for (int i = 0; i < 10; i++) { if (store.tryConsume(key, capacity, window).allowed()) firstAllowed++; } - assertEquals(capacity, firstAllowed, "must allow exactly capacity tokens initially"); + long drainMs = (System.nanoTime() - drainStart) / 1_000_000; + // Refill never pauses, so a slow drain earns extra tokens honestly - allow exactly the + // number the elapsed time can have produced and no more. + long earned = drainMs / refillIntervalMs; + assertTrue( + firstAllowed >= capacity && firstAllowed <= capacity + earned, + "initial burst must be capacity (" + + capacity + + ") plus at most the " + + earned + + " token(s) refilled during a " + + drainMs + + "ms drain, got " + + firstAllowed); - Thread.sleep(window.toMillis() + 50); + // A fixed-window limiter would hand back a whole fresh capacity at the boundary; a token + // bucket hands back one token per refill interval. + Thread.sleep(refillIntervalMs + 200); int secondAllowed = 0; - long start = System.nanoTime(); - for (int i = 0; i < 20 && (System.nanoTime() - start) < 20_000_000L; i++) { + for (int i = 0; i < 10; i++) { if (store.tryConsume(key, capacity, window).allowed()) secondAllowed++; } assertTrue( - secondAllowed <= capacity, - "token-bucket must not let a fresh full capacity be consumed instantly across" - + " the boundary; got " + secondAllowed >= 1 && secondAllowed < capacity, + "one refill interval must yield about one token, not a fresh full window of " + + capacity + + "; got " + secondAllowed); } From fd1c955648c8c2ba28d382d1ba0db01b260e8c93 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:52:13 +0200 Subject: [PATCH 095/122] refactor(ui): redesign VersionTimeline UI (#7162) # Description of Changes I felt the old VersionTimeline was a bit too crowded/not very "good" looking so i had a crack at redesigning it. Mainly aimed for: - less info - less crowding - more spacing ### New image ### Old image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../public/locales/en-US/translation.toml | 3 +- .../core/components/filesPage/FilesPage.css | 174 ++++++----- .../filesPage/VersionHistoryModal.tsx | 16 +- .../components/filesPage/VersionTimeline.tsx | 275 +++++++++--------- 4 files changed, 256 insertions(+), 212 deletions(-) diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index feb42149f7..ea4f0399b9 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3977,6 +3977,7 @@ refresh = "Refresh from server" remove = "Delete" removeVersion = "Remove this version" rename = "Rename" +renamed = "Renamed" renameFolder = "Rename folder" resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)" save = "Save" @@ -4079,9 +4080,7 @@ count = "Files" folder = "Folder" labels = "Labels" modified = "Modified" -name = "Name" size = "Size" -toolHistoryAtVersion = "Cumulative tool chain" totalSize = "Total size" type = "Type" versionHistory = "Version journey" diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index 9de4a276aa..4815a1b31e 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -956,178 +956,204 @@ like a commit graph; clicking a row's summary toggles its expanded detail (cumulative tool chain, full meta). Long chains (> 6) collapse the middle behind a "Show N earlier versions" button. */ +/* Version Timeline styling */ .files-page-details-version-timeline { display: flex; flex-direction: column; - gap: 0.4rem; - padding: 0.55rem 0.7rem 0.7rem; - background: var(--c-surface); - border: 1px solid var(--c-border-subtle); - border-radius: 0.5rem; + gap: 0.6rem; + padding: 0.5rem 0; + background: transparent; } + .files-page-details-version-timeline-label { display: flex; align-items: center; - gap: 0.35rem; - font-size: 0.72rem; + gap: 0.4rem; + font-size: 0.75rem; + font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--c-text-subtle); + margin-bottom: 0.85rem; } + .files-page-details-version-timeline-count { margin-left: auto; font-weight: 600; - color: var(--c-text-muted, var(--c-text-subtle)); + color: var(--c-primary); text-transform: none; letter-spacing: 0; } + .files-page-details-version-timeline-list { - list-style: none; - margin: 0; - padding: 0; + list-style: none !important; + margin: 0 !important; + padding: 1.1rem 0 0 0 !important; display: flex; flex-direction: column; + gap: 1.25rem; } + .files-page-details-version-timeline-row, .files-page-details-version-timeline-ellipsis { display: flex; - gap: 0.6rem; - padding: 0.15rem 0; + gap: 0.85rem; + padding: 0; position: relative; + list-style: none !important; } + +.files-page-details-version-timeline-row::before, +.files-page-details-version-timeline-ellipsis::before { + content: none !important; +} + .files-page-details-version-timeline-rail { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; - width: 0.8rem; - padding-top: 0.45rem; + width: 1rem; + padding-top: 0.85rem; } + .files-page-details-version-timeline-rail-dot { - width: 0.55rem; - height: 0.55rem; + width: 0.65rem; + height: 0.65rem; border-radius: 50%; background: var(--c-bg-raised); border: 2px solid var(--c-border-strong, var(--c-border-subtle)); - z-index: 1; + z-index: 2; flex-shrink: 0; + transition: all 0.2s ease; } + .files-page-details-version-timeline-rail-dot.is-active { background: var(--c-primary); border-color: var(--c-primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 25%, transparent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 30%, transparent); } + .files-page-details-version-timeline-rail-dot.is-ellipsis { - width: 0.35rem; - height: 0.35rem; + width: 0.4rem; + height: 0.4rem; background: var(--c-text-subtle); border-color: transparent; } + .files-page-details-version-timeline-rail-line { width: 2px; flex: 1; background: var(--c-border-subtle); - min-height: 0.6rem; - margin-top: 2px; + min-height: 1.25rem; + margin-top: 6px; } + .files-page-details-version-timeline-body { flex: 1; min-width: 0; display: flex; flex-direction: column; - gap: 0.2rem; - padding: 0.25rem 0.3rem 0.4rem; - border-radius: 0.35rem; - transition: background-color 0.12s ease; + gap: 0.5rem; + padding: 1rem 1.25rem; + border-radius: 0.65rem; + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; } + +.files-page-details-version-timeline-body:hover { + border-color: var(--c-border-strong, var(--c-border-subtle)); +} + .files-page-details-version-timeline-row.is-active .files-page-details-version-timeline-body { - background: color-mix(in srgb, var(--c-primary) 10%, transparent); + border-color: color-mix(in srgb, var(--c-primary) 40%, transparent); + background: color-mix(in srgb, var(--c-primary) 5%, var(--c-surface)); + box-shadow: 0 2px 6px color-mix(in srgb, var(--c-primary) 12%, transparent); } -.files-page-details-version-timeline-summary { + +.files-page-details-version-timeline-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.files-page-details-version-timeline-tool-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--c-text); +} + +.files-page-details-version-timeline-card-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding-top: 0.1rem; +} + +.files-page-details-version-timeline-expand-btn { appearance: none; background: none; border: 0; padding: 0; - margin: 0; + cursor: pointer; display: flex; align-items: center; - gap: 0.4rem; - cursor: pointer; - text-align: left; - color: inherit; - font: inherit; } -.files-page-details-version-timeline-summary:hover - .files-page-details-version-timeline-chevron { + +.files-page-details-version-timeline-expand-btn:hover span { color: var(--c-text); } -.files-page-details-version-timeline-delta { - font-size: 0.82rem; - color: var(--c-text); - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: inline-flex; - align-items: baseline; - gap: 0.25rem; -} -.files-page-details-version-timeline-delta.is-origin { - font-weight: 400; - color: var(--c-text-subtle); - font-style: italic; -} -.files-page-details-version-timeline-delta-plus { - color: var(--c-primary); - font-weight: 700; -} -.files-page-details-version-timeline-spacer { - flex: 1; -} + .files-page-details-version-timeline-chevron { color: var(--c-text-subtle); transition: transform 0.15s ease; } + .files-page-details-version-timeline-chevron.is-expanded { transform: rotate(180deg); - color: var(--c-text); -} -.files-page-details-version-timeline-meta-line { - display: flex; - align-items: center; - gap: 0.35rem; - font-size: 0.7rem; - color: var(--c-text-subtle); + color: var(--c-primary); } + .files-page-details-version-timeline-expanded { display: flex; flex-direction: column; gap: 0.4rem; - margin-top: 0.45rem; + margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px dashed var(--c-border-subtle); } + .files-page-details-version-timeline-toolchain { display: flex; flex-direction: column; gap: 0.2rem; } + .files-page-details-version-timeline-toolchain-label { font-size: 0.65rem; color: var(--c-text-subtle); text-transform: uppercase; letter-spacing: 0.05em; } + .files-page-details-version-timeline-ellipsis-btn, .files-page-details-version-timeline-collapse-btn { appearance: none; background: none; border: 1px dashed var(--c-border-subtle); - border-radius: 0.3rem; - padding: 0.25rem 0.5rem; + border-radius: 0.4rem; + padding: 0.35rem 0.7rem; margin: 0.1rem 0; - font-size: 0.72rem; + font-size: 0.75rem; color: var(--c-text-subtle); cursor: pointer; text-align: left; @@ -1135,11 +1161,13 @@ border-color 0.12s ease, color 0.12s ease; } + .files-page-details-version-timeline-ellipsis-btn:hover, .files-page-details-version-timeline-collapse-btn:hover { color: var(--c-text); - border-color: var(--c-border-strong, var(--c-text-subtle)); + border-color: var(--c-primary); } + .files-page-details-version-timeline-collapse-btn { align-self: flex-start; margin-top: 0.2rem; diff --git a/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx b/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx index 077924155d..2bef30c763 100644 --- a/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Center, Loader, Modal, Text } from "@mantine/core"; +import HistoryIcon from "@mui/icons-material/History"; +import { Center, Group, Loader, Modal, Text } from "@mantine/core"; import type { FileId } from "@app/types/file"; import type { StirlingFileStub } from "@app/types/fileContext"; @@ -99,7 +100,17 @@ export function VersionHistoryModal({ onClose={onClose} centered size="md" - title={t("filesPage.field.versionHistory", "Version journey")} + title={ + + + + {t("filesPage.field.versionHistory", "Version journey")} + + + } > {loading ? (
    @@ -111,6 +122,7 @@ export function VersionHistoryModal({ currentId={file.id} onAddToWorkspace={handleAddToWorkspace} onRemove={handleRemove} + hideHeader /> ) : ( diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx index 47495fe61b..049fc2365a 100644 --- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Badge, Menu } from "@mantine/core"; +import { Badge, Group, Menu, Text } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; @@ -8,16 +8,14 @@ import DeleteIcon from "@mui/icons-material/Delete"; import DownloadIcon from "@mui/icons-material/Download"; import HistoryIcon from "@mui/icons-material/History"; import MoreVertIcon from "@mui/icons-material/MoreVert"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { FileId, ToolOperation } from "@app/types/file"; import { ToolId } from "@app/types/toolId"; import { StirlingFileStub } from "@app/types/fileContext"; import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; import { downloadFileFromStorage } from "@app/utils/downloadUtils"; -import ToolChain from "@app/components/shared/ToolChain"; -/** Small label/value row; shared with FileDetailsPanel. */ +/** Small label/value row with crisp flex alignment and colon separation. */ export function DetailField({ label, value, @@ -26,9 +24,31 @@ export function DetailField({ value: string; }) { return ( -
    - {label} - {value} +
    + + {label}: + + + {value} +
    ); } @@ -60,7 +80,7 @@ export interface VersionTimelineProps { hideHeader?: boolean; } -/** Version timeline with per-row tool deltas and collapse-when-long. */ +/** Clean, spacious version timeline with minimal clutter. */ export function VersionTimeline({ chain, currentId, @@ -69,7 +89,6 @@ export function VersionTimeline({ hideHeader = false, }: VersionTimelineProps) { const { t } = useTranslation(); - const [expandedIds, setExpandedIds] = useState>(new Set()); const [showAllCollapsed, setShowAllCollapsed] = useState(false); // Newest-first ordering. @@ -113,15 +132,6 @@ export function VersionTimeline({ return [...head, { kind: "ellipsis", hidden }, ...tail]; }, [collapsible, showAllCollapsed, ordered]); - const toggleExpand = (id: FileId) => { - setExpandedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - return (
    {!hideHeader && ( @@ -135,7 +145,10 @@ export function VersionTimeline({
    )} -
      +
        {rows.map((row, idx) => { const isLast = idx === rows.length - 1; if (row.kind === "ellipsis") { @@ -143,6 +156,7 @@ export function VersionTimeline({
      • @@ -152,7 +166,7 @@ export function VersionTimeline({
        -
        - {formatFileSize(v.size)} - {v.lastModified ? ( - <> - · - - {getFileDate({ lastModified: v.lastModified })} - - - ) : null} - {/* Kebab on every row - the original/active version also - needs download + open-in-workspace. */} - - - - e.stopPropagation()} - > - - - - - } - onClick={() => onAddToWorkspace([v.id])} - > - {t( - "filesPage.openVersionInWorkspace", - "Open in workspace", - )} - - } - onClick={() => { - void downloadFileFromStorage(v); - }} - > - {t( - "filesPage.downloadVersion", - "Download this version", - )} - - - } - onClick={() => onRemove([v.id])} - > - {t("filesPage.removeVersion", "Remove this version")} - - - -
        - {isExpanded && ( - // Filename + full cumulative tool chain. -
        - - {v.toolHistory && v.toolHistory.length > 0 && ( -
        - - {t( - "filesPage.field.toolHistoryAtVersion", - "Cumulative tool chain", + + {delta ? ( + + ) : ( + t("filesPage.versionOrigin", "Original upload") + )} + + + + {!isActive && ( + + + - -
        - )} -
        + onClick={(e) => e.stopPropagation()} + > + + + + + } + onClick={() => onAddToWorkspace([v.id])} + > + {t( + "filesPage.openVersionInWorkspace", + "Open in workspace", + )} + + } + onClick={() => { + void downloadFileFromStorage(v); + }} + > + {t( + "filesPage.downloadVersion", + "Download this version", + )} + + + } + onClick={() => onRemove([v.id])} + > + {t("filesPage.removeVersion", "Remove this version")} + + + + )} +
    + + {/* Quiet Meta Line: File Size · Date */} + + {formatFileSize(v.size)} + {v.lastModified && ( + <> · {getFileDate({ lastModified: v.lastModified })} + )} + + + {/* Show filename ONLY if original upload or if name changed */} + {(isOriginal || nameChanged) && ( + + {nameChanged + ? `${t("filesPage.renamed", "Renamed")}: ` + : `${t("filesPage.file", "File")}: `} + + {v.name} + + )} ); })} - + {collapsible && showAllCollapsed && ( + + {/* Active Scale Display */} +
    + + {t("scaleSettings.activeScale", "Active Scale")}:{" "} + {currentScale && currentScale.ratio + ? generateScaleLabel(currentScale.ratio, currentScale.unit) + : currentScale && !currentScale.ratio + ? `${currentScale.unit} (custom)` + : t("scaleSettings.noneSet", "No custom scale set")} + +
    + + {/* Calibration Mode */} + + + {/* Reset Button */} + {currentScale && ( + + )} + + ); +} diff --git a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx index 14a308e0f3..d5d39c4c0c 100644 --- a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx +++ b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx @@ -26,11 +26,19 @@ import StraightenIcon from "@mui/icons-material/Straighten"; import LayersIcon from "@mui/icons-material/Layers"; import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import StopIcon from "@mui/icons-material/Stop"; +import SettingsIcon from "@mui/icons-material/Settings"; import { useViewerReadAloud } from "@app/components/viewer/useViewerReadAloud"; +import { RulerScaleSettingsButton } from "@app/components/viewer/RulerScaleSettingsButton"; +import type { MeasureScale } from "@app/utils/measurementTypes"; export function useViewerWorkbenchBarButtons( isRulerActive?: boolean, setIsRulerActive?: (v: boolean) => void, + customScale?: MeasureScale | null, + setCustomScale?: (scale: MeasureScale | null) => void, + isScaleCalibrationActive?: boolean, + startScaleCalibration?: () => void, + cancelScaleCalibration?: () => void, ) { const { t, i18n } = useTranslation(); const viewer = useViewer(); @@ -118,11 +126,36 @@ export function useViewerWorkbenchBarButtons( const annotationsLabel = t("workbenchBar.annotations", "Annotations"); const formFillLabel = t("workbenchBar.formFill", "Fill Form"); const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure"); + const rulerSettingsLabel = t("workbenchBar.rulerSettings", "Scale Settings"); const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud"); const readAloudSpeedLabel = t("workbenchBar.readAloudSpeed", "Speed"); const isFormFillActive = (selectedTool as string) === "formFill"; + const handleStartScaleCalibration = useCallback(() => { + startScaleCalibration?.(); + setIsRulerActive?.(true); + if (isPanning) { + viewer.panActions.disablePan(); + setIsPanning(false); + } + }, [isPanning, setIsRulerActive, startScaleCalibration, viewer.panActions]); + + const handleCancelScaleCalibration = useCallback(() => { + cancelScaleCalibration?.(); + }, [cancelScaleCalibration]); + + const handleApplyRulerScale = useCallback( + (scale: MeasureScale) => { + setCustomScale?.(scale); + }, + [setCustomScale], + ); + + const handleResetRulerScale = useCallback(() => { + setCustomScale?.(null); + }, [setCustomScale]); + // Filter languages based on available voices const filteredLanguages = useMemo( () => @@ -234,6 +267,32 @@ export function useViewerWorkbenchBarButtons( } }, }, + // Ruler scale settings button - only visible when ruler is active + ...(isRulerActive + ? [ + { + id: "viewer-ruler-settings", + icon: , + tooltip: rulerSettingsLabel, + ariaLabel: rulerSettingsLabel, + section: "top" as const, + order: 25.5, + render: ({ disabled }: { disabled?: boolean }) => ( + + ), + }, + ] + : []), { id: "viewer-rotate-left", icon: , @@ -553,8 +612,15 @@ export function useViewerWorkbenchBarButtons( formFillLabel, isFormFillActive, rulerLabel, + rulerSettingsLabel, isRulerActive, setIsRulerActive, + handleStartScaleCalibration, + handleCancelScaleCalibration, + handleApplyRulerScale, + handleResetRulerScale, + customScale, + isScaleCalibrationActive, readAloudLabel, readAloudSpeedLabel, isReadingAloud, diff --git a/frontend/editor/src/core/contexts/ViewerContext.tsx b/frontend/editor/src/core/contexts/ViewerContext.tsx index 5e5d08dbb4..78535b41ac 100644 --- a/frontend/editor/src/core/contexts/ViewerContext.tsx +++ b/frontend/editor/src/core/contexts/ViewerContext.tsx @@ -176,6 +176,9 @@ export interface ViewerContextType { registerImmediatePanUpdate: ( callback: (isPanning: boolean) => void, ) => () => void; + registerImmediateRotationUpdate: ( + callback: (rotation: number) => void, + ) => () => void; // Internal - for bridges to trigger immediate updates triggerImmediateScrollUpdate: ( @@ -188,6 +191,7 @@ export interface ViewerContextType { isDualPage?: boolean, ) => void; triggerImmediatePanUpdate: (isPanning: boolean) => void; + triggerImmediateRotationUpdate: (rotation: number) => void; // Action handlers - call EmbedPDF APIs directly scrollActions: ScrollActions; @@ -310,6 +314,10 @@ export const ViewerProvider: React.FC = ({ children }) => { register: registerImmediatePanUpdate, trigger: triggerImmediatePanInternal, } = useImmediateNotifier<[boolean]>(); + const { + register: registerImmediateRotationUpdate, + trigger: triggerImmediateRotationInternal, + } = useImmediateNotifier<[number]>(); const triggerImmediateZoomUpdate = useCallback( (percent: number) => { @@ -339,6 +347,13 @@ export const ViewerProvider: React.FC = ({ children }) => { [triggerImmediatePanInternal], ); + const triggerImmediateRotationUpdate = useCallback( + (rotation: number) => { + triggerImmediateRotationInternal(rotation); + }, + [triggerImmediateRotationInternal], + ); + const registerBridge = useCallback( ( type: K, @@ -638,10 +653,12 @@ export const ViewerProvider: React.FC = ({ children }) => { registerImmediateScrollUpdate, registerImmediateSpreadUpdate, registerImmediatePanUpdate, + registerImmediateRotationUpdate, triggerImmediateScrollUpdate, triggerImmediateZoomUpdate, triggerImmediateSpreadUpdate, triggerImmediatePanUpdate, + triggerImmediateRotationUpdate, // Actions scrollActions, diff --git a/frontend/editor/src/core/hooks/useMeasurementManager.ts b/frontend/editor/src/core/hooks/useMeasurementManager.ts new file mode 100644 index 0000000000..f6f6b9d6b1 --- /dev/null +++ b/frontend/editor/src/core/hooks/useMeasurementManager.ts @@ -0,0 +1,293 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type RefObject, +} from "react"; +import type { + Measurement, + MeasureScale, + PageMeasureScales, +} from "@app/utils/measurementTypes"; +import type { RulerOverlayHandle } from "@app/components/viewer/RulerOverlay"; +import { + loadSessionMap, + saveSessionMap, + validateMeasureScale, + validateMeasurement, +} from "@app/utils/measurementUtils"; +import type { StirlingFile } from "@app/types/fileContext"; +import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext"; +import { extractPageMeasureScales } from "@app/utils/pdfMeasurementExtraction"; +import type { ScaleCalibrationMeasurement } from "@app/components/viewer/ScaleCalibrationDialog"; + +// ─── Hook: useMeasurementManager ────────────────────────────────────────────── + +interface EffectiveFileLike { + file: Blob | File; + url: string | null; +} + +type ViewerFile = StirlingFile | File | null | undefined; + +interface UseMeasurementManagerProps { + currentFile: ViewerFile; + effectiveFile: EffectiveFileLike | null | undefined; + rulerOverlayRef: RefObject; +} + +interface UseMeasurementManagerReturn { + isRulerActive: boolean; + setIsRulerActive: (v: boolean) => void; + pageMeasureScales: PageMeasureScales | null; + customScale: MeasureScale | null; + handleSetCustomScale: (scale: MeasureScale | null) => void; + isScaleCalibrationActive: boolean; + scaleCalibrationMeasurement: ScaleCalibrationMeasurement | null; + startScaleCalibration: () => void; + cancelScaleCalibration: () => void; + handleScaleCalibrationMeasurement: ( + measurement: ScaleCalibrationMeasurement, + ) => void; + applyScaleCalibration: (scale: MeasureScale) => void; +} + +export function useMeasurementManager({ + currentFile, + effectiveFile, + rulerOverlayRef, +}: UseMeasurementManagerProps): UseMeasurementManagerReturn { + const [isRulerActive, setIsRulerActive] = useState(false); + const [pageMeasureScales, setPageMeasureScales] = + useState(null); + const [customScale, setCustomScale] = useState(null); + const [isScaleCalibrationActive, setIsScaleCalibrationActive] = + useState(false); + const [scaleCalibrationMeasurement, setScaleCalibrationMeasurement] = + useState(null); + const [scalesByFileId, setScalesByFileId] = useState< + Map + >(new Map()); + const [measurementsByFileId, setMeasurementsByFileId] = useState< + Map + >(new Map()); + + const restoredFileKeyRef = useRef(null); + + const getStableFileKey = useCallback((file: ViewerFile): string | null => { + if (!file) return null; + if (isStirlingFile(file)) { + return file.fileId; + } + return getFormFillFileId(file); + }, []); + + const currentFileKey = getStableFileKey(currentFile); + + function persistSessionValue( + storageKey: string, + fileKey: string, + value: MeasureScale | Measurement[] | null, + label: string, + ) { + try { + saveSessionMap(storageKey, fileKey, value); + } catch (error) { + console.error(`[Measurement] Failed to persist ${label}:`, error); + } + } + + function readStoredScale(fileKey: string): MeasureScale | null | undefined { + const storedMap = loadSessionMap("stirling_scales"); + if (!(fileKey in storedMap)) { + return undefined; + } + + const storedValue = storedMap[fileKey]; + return validateMeasureScale(storedValue) ? storedValue : null; + } + + function readStoredMeasurements(fileKey: string): Measurement[] | undefined { + const storedMap = loadSessionMap("stirling_measurements"); + if (!(fileKey in storedMap)) { + return undefined; + } + + const storedValue = storedMap[fileKey]; + if (!Array.isArray(storedValue)) { + return []; + } + + return storedValue.filter((measurement) => + validateMeasurement(measurement), + ); + } + + function persistScale(fileKey: string, scale: MeasureScale | null) { + persistSessionValue("stirling_scales", fileKey, scale, "scale"); + } + + function persistMeasurements(fileKey: string, value: Measurement[]) { + persistSessionValue( + "stirling_measurements", + fileKey, + value, + "measurements", + ); + } + + const handleSetCustomScale = useCallback( + (scale: MeasureScale | null) => { + const fileKey = currentFileKey; + + if (fileKey) { + setScalesByFileId((prev) => new Map(prev).set(fileKey, scale)); + persistScale(fileKey, scale); + } + + setCustomScale(scale); + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + }, + [currentFileKey], + ); + + const handleSetRulerActive = useCallback((active: boolean) => { + setIsRulerActive(active); + if (!active) { + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + } + }, []); + + const startScaleCalibration = useCallback(() => { + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(true); + setIsRulerActive(true); + }, []); + + const cancelScaleCalibration = useCallback(() => { + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + }, []); + + const handleScaleCalibrationMeasurement = useCallback( + (measurement: ScaleCalibrationMeasurement) => { + setScaleCalibrationMeasurement(measurement); + setIsScaleCalibrationActive(false); + }, + [], + ); + + const applyScaleCalibration = useCallback( + (scale: MeasureScale) => { + handleSetCustomScale(scale); + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + }, + [handleSetCustomScale], + ); + + useEffect(() => { + if (!currentFileKey) { + setPageMeasureScales(null); + setCustomScale(null); + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + setIsRulerActive(false); + rulerOverlayRef.current?.clearAll(true); + restoredFileKeyRef.current = null; + return; + } + + if (restoredFileKeyRef.current === currentFileKey) { + return; + } + restoredFileKeyRef.current = currentFileKey; + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + + const storedScale = readStoredScale(currentFileKey); + const savedScale = + storedScale === undefined + ? (scalesByFileId.get(currentFileKey) ?? null) + : storedScale; + + setCustomScale(savedScale); + + const storedMeasurements = readStoredMeasurements(currentFileKey); + const savedMeasurements = + storedMeasurements === undefined + ? (measurementsByFileId.get(currentFileKey) ?? []) + : storedMeasurements; + + rulerOverlayRef.current?.clearAll(true); + rulerOverlayRef.current?.restoreMeasurements(savedMeasurements); + }, [currentFileKey, measurementsByFileId, rulerOverlayRef, scalesByFileId]); + + useEffect(() => { + const fileBlob = effectiveFile?.file; + if (!fileBlob || !currentFileKey) { + setPageMeasureScales(null); + return; + } + + setPageMeasureScales(null); + + let cancelled = false; + extractPageMeasureScales(fileBlob) + .then((scales) => { + if (!cancelled) { + setPageMeasureScales(scales); + } + }) + .catch((error) => { + if (!cancelled) { + console.warn("[Measurement] Failed to load PDF scales", error); + setPageMeasureScales(null); + } + }); + + return () => { + cancelled = true; + }; + }, [currentFileKey, effectiveFile?.file]); + + useEffect(() => { + if (!rulerOverlayRef.current || !currentFileKey) return; + + const unsubscribe = rulerOverlayRef.current.onMeasurementsChange( + (newMeasurements: Measurement[]) => { + const validMeasurements = newMeasurements.filter((measurement) => + validateMeasurement(measurement), + ); + + setMeasurementsByFileId((prev) => + new Map(prev).set(currentFileKey, validMeasurements), + ); + persistMeasurements(currentFileKey, validMeasurements); + }, + ); + + return () => { + if (typeof unsubscribe === "function") { + unsubscribe(); + } + }; + }, [currentFileKey, rulerOverlayRef]); + + return { + isRulerActive, + setIsRulerActive: handleSetRulerActive, + pageMeasureScales, + customScale, + handleSetCustomScale, + isScaleCalibrationActive, + scaleCalibrationMeasurement, + startScaleCalibration, + cancelScaleCalibration, + handleScaleCalibrationMeasurement, + applyScaleCalibration, + }; +} diff --git a/frontend/editor/src/core/utils/measurementPreferences.ts b/frontend/editor/src/core/utils/measurementPreferences.ts new file mode 100644 index 0000000000..c339ed64f8 --- /dev/null +++ b/frontend/editor/src/core/utils/measurementPreferences.ts @@ -0,0 +1,24 @@ +// Persist calibration unit preference across sessions +const STORAGE_KEY_LAST_CALIBRATION_UNIT = "stirling_calibration_last_unit"; + +export function getLastCalibrationUnit(defaultUnit: string): string { + try { + const stored = localStorage.getItem(STORAGE_KEY_LAST_CALIBRATION_UNIT); + return stored && stored.trim() ? stored : defaultUnit; + } catch { + // Storage unavailable - private browsing or quota exceeded + return defaultUnit; + } +} + +export function setLastCalibrationUnit(unit: string): void { + try { + localStorage.setItem(STORAGE_KEY_LAST_CALIBRATION_UNIT, unit); + } catch (error) { + // Storage unavailable - preference won't be retained + console.debug( + "[MeasurementPreferences] Unable to persist unit preference:", + error, + ); + } +} diff --git a/frontend/editor/src/core/utils/measurementTypes.ts b/frontend/editor/src/core/utils/measurementTypes.ts new file mode 100644 index 0000000000..caa47ff876 --- /dev/null +++ b/frontend/editor/src/core/utils/measurementTypes.ts @@ -0,0 +1,45 @@ +// Page coordinates with absolute page index +export interface PagePoint { + pageIndex: number; + x: number; + y: number; +} + +// Real-world units per PDF point (factor) vs. architectural ratio for display +export interface MeasureScale { + factor: number; // Real-world units per PDF point + ratio: number | null; // Architectural ratio (e.g., 100 for "1:100") - display only + unit: string; // m, cm, mm, km, ft, in, yd, mi +} + +export type MeasureScaleLike = MeasureScale; + +// Calibration result with full context for audit trail +export interface CalibrationMetadata { + pdfDistancePts: number; // PDF space distance in points + realDistance: number; // User-specified real-world distance + scale: MeasureScale; // Resulting calculated scale + timestamp: string; // ISO 8601 format + unitUsed: string; // Unit active during calibration +} + +// Single measurement between two page points on same page +export interface Measurement { + id: string; + start: PagePoint; + end: PagePoint; +} + +// Viewport area with its own scale (for multi-region PDFs) +export interface ViewportScale { + bbox: [number, number, number, number] | null; // PDF user space or null for entire page + scale: MeasureScale; +} + +// Scale information for a single page with all viewports +export interface PageScaleInfo { + viewports: ViewportScale[]; + pageHeight: number; // PDF points - used to flip screen-y to PDF-y +} + +export type PageMeasureScales = Map; diff --git a/frontend/editor/src/core/utils/measurementUtils.test.ts b/frontend/editor/src/core/utils/measurementUtils.test.ts new file mode 100644 index 0000000000..33cf3f5c2d --- /dev/null +++ b/frontend/editor/src/core/utils/measurementUtils.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import { + POINT_TO_UNIT, + calculateCalibratedScale, + calculateScaleFactor, + convertUnit, + deriveRatioFromFactor, + parsePresetRatio, +} from "@app/utils/measurementUtils"; + +describe("measurementUtils", () => { + describe("calculateScaleFactor", () => { + test("calculates real-world units per PDF point from a scale ratio", () => { + expect(calculateScaleFactor(100, "m")).toBeCloseTo(POINT_TO_UNIT.m * 100); + expect(calculateScaleFactor(50, " cm ")).toBeCloseTo( + POINT_TO_UNIT.cm * 50, + ); + expect(calculateScaleFactor(12, "FT")).toBeCloseTo(POINT_TO_UNIT.ft * 12); + }); + + test("rejects invalid scale ratios", () => { + expect(() => calculateScaleFactor(0, "m")).toThrow("Invalid scale ratio"); + expect(() => calculateScaleFactor(-1, "m")).toThrow( + "Invalid scale ratio", + ); + expect(() => calculateScaleFactor(Number.NaN, "m")).toThrow( + "Invalid scale ratio", + ); + expect(() => calculateScaleFactor(Number.POSITIVE_INFINITY, "m")).toThrow( + "Invalid scale ratio", + ); + }); + + test("rejects unsupported units", () => { + expect(() => calculateScaleFactor(100, "px")).toThrow("Unsupported unit"); + }); + }); + + describe("convertUnit", () => { + test("converts representative metric and imperial values", () => { + expect(convertUnit(1, "m", "cm")).toBeCloseTo(100); + expect(convertUnit(12, "in", "ft")).toBeCloseTo(1); + expect(convertUnit(3, "ft", "yd")).toBeCloseTo(1); + expect(convertUnit(1, "ft", "m")).toBeCloseTo(0.3048); + }); + + test("returns null for invalid values or unsupported units", () => { + expect(convertUnit(Number.NaN, "m", "cm")).toBeNull(); + expect(convertUnit(Number.POSITIVE_INFINITY, "m", "cm")).toBeNull(); + expect(convertUnit(1, "px", "cm")).toBeNull(); + expect(convertUnit(1, "m", "px")).toBeNull(); + }); + }); + + describe("parsePresetRatio", () => { + test("parses supported preset ratios", () => { + expect(parsePresetRatio("1:5")).toBe(5); + expect(parsePresetRatio("1:100")).toBe(100); + expect(parsePresetRatio(" 1 : 150 ")).toBe(150); + }); + + test("returns null for malformed or non-positive presets", () => { + expect(parsePresetRatio("2:100")).toBeNull(); + expect(parsePresetRatio("1:0")).toBeNull(); + expect(parsePresetRatio("1:-10")).toBeNull(); + expect(parsePresetRatio("1:not-a-number")).toBeNull(); + expect(parsePresetRatio("bad")).toBeNull(); + expect(parsePresetRatio("1:10:20")).toBeNull(); + }); + }); + + describe("deriveRatioFromFactor", () => { + test("recovers the scale ratio from a factor and unit", () => { + const factor = calculateScaleFactor(100, "m"); + + expect(deriveRatioFromFactor(factor, "m")).toBeCloseTo(100); + }); + + test("returns null for invalid factors or unsupported units", () => { + expect(deriveRatioFromFactor(0, "m")).toBeNull(); + expect(deriveRatioFromFactor(-1, "m")).toBeNull(); + expect(deriveRatioFromFactor(Number.NaN, "m")).toBeNull(); + expect(deriveRatioFromFactor(1, "px")).toBeNull(); + }); + }); + + describe("calculateCalibratedScale", () => { + test("calculates a calibrated scale from a known physical distance", () => { + const scale = calculateCalibratedScale(72, 1, "in"); + + expect(scale.factor).toBeCloseTo(POINT_TO_UNIT.in); + expect(scale.ratio).toBeCloseTo(1); + expect(scale.unit).toBe("in"); + }); + + test("calculates architectural ratios for metric calibration", () => { + const scale = calculateCalibratedScale(72, 0.0254, "m"); + + expect(scale.factor).toBeCloseTo(POINT_TO_UNIT.m); + expect(scale.ratio).toBeCloseTo(1); + expect(scale.unit).toBe("m"); + }); + + test("rejects invalid calibration inputs", () => { + expect(() => calculateCalibratedScale(0, 1, "m")).toThrow( + "Invalid PDF distance", + ); + expect(() => calculateCalibratedScale(72, 0, "m")).toThrow( + "Invalid real-world distance", + ); + expect(() => calculateCalibratedScale(72, 1, "px")).toThrow( + "Unsupported unit", + ); + }); + }); +}); diff --git a/frontend/editor/src/core/utils/measurementUtils.ts b/frontend/editor/src/core/utils/measurementUtils.ts new file mode 100644 index 0000000000..e35645fb2b --- /dev/null +++ b/frontend/editor/src/core/utils/measurementUtils.ts @@ -0,0 +1,398 @@ +// PDF point to real-world unit conversions + +import type { + Measurement, + MeasureScale, + PagePoint, + CalibrationMetadata, +} from "@app/utils/measurementTypes"; + +// 1 PDF point in meters (1/72 inch) +const POINT_TO_METERS = 0.0254 / 72; + +// Conversion factors: units per PDF point +export const POINT_TO_UNIT = { + m: POINT_TO_METERS, + cm: POINT_TO_METERS * 100, + mm: POINT_TO_METERS * 1000, + km: POINT_TO_METERS / 1000, + ft: POINT_TO_METERS / 0.3048, + in: POINT_TO_METERS / 0.0254, + yd: POINT_TO_METERS / 0.9144, + mi: POINT_TO_METERS / 1609.344, +} as const; + +// Valid measurement units from POINT_TO_UNIT +export type MeasurementUnit = keyof typeof POINT_TO_UNIT; + +function normalizeUnit(unit: string): string { + return unit.toLowerCase().trim(); +} + +function isMeasurementUnit(unit: string): unit is MeasurementUnit { + return Object.hasOwn(POINT_TO_UNIT, unit); +} + +export function getUnitFactor(unit: string): number | undefined { + const normalized = normalizeUnit(unit); + if (!isMeasurementUnit(normalized)) { + return undefined; + } + return POINT_TO_UNIT[normalized]; +} + +export function calculateScaleFactor(ratio: number, unit: string): number { + if (!Number.isFinite(ratio) || ratio <= 0) { + throw new Error(`Invalid scale ratio: ${ratio}`); + } + + const normalized = normalizeUnit(unit); + if (!isMeasurementUnit(normalized)) { + throw new Error(`Unsupported unit: ${unit}`); + } + + return POINT_TO_UNIT[normalized] * ratio; +} + +export function generateScaleLabel(ratio: number | null, unit: string): string { + if (ratio === null || ratio === undefined) { + return unit; + } + const display = Number.isInteger(ratio) + ? ratio.toString() + : ratio.toFixed(2).replace(/\.?0+$/, ""); + return `1:${display} (${unit})`; +} + +// Imperial units +const IMPERIAL_UNITS = ["ft", "in", "yd", "mi"] as const; +export function isImperialUnit(unit: string): boolean { + const normalized = normalizeUnit(unit); + return isMeasurementUnit(normalized) + ? (IMPERIAL_UNITS as readonly MeasurementUnit[]).includes(normalized) + : false; +} + +export function convertUnit( + value: number, + sourceUnit: string, + targetUnit: string, +): number | null { + if (!Number.isFinite(value)) { + return null; + } + + const src = normalizeUnit(sourceUnit); + const tgt = normalizeUnit(targetUnit); + + if (!isMeasurementUnit(src) || !isMeasurementUnit(tgt)) { + return null; + } + + const sourceFactor = POINT_TO_UNIT[src]; + const targetFactor = POINT_TO_UNIT[tgt]; + + return value * (targetFactor / sourceFactor); +} + +export function parsePresetRatio(preset: string): number | null { + const parts = preset.split(":"); + + // Must have exactly 2 parts and first part must be "1" + if (parts.length !== 2 || parts[0].trim() !== "1") { + return null; + } + + const value = Number(parts[1]); + return Number.isFinite(value) && value > 0 ? value : null; +} + +// UI dropdown options - shared across components +export const UNIT_OPTIONS = [ + { value: "m", label: "Meters (m)" }, + { value: "cm", label: "Centimeters (cm)" }, + { value: "mm", label: "Millimeters (mm)" }, + { value: "km", label: "Kilometers (km)" }, + { value: "ft", label: "Feet (ft)" }, + { value: "in", label: "Inches (in)" }, + { value: "yd", label: "Yards (yd)" }, + { value: "mi", label: "Miles (mi)" }, +] as const; + +const MAX_SESSION_ENTRIES = 50; +const TRIMMED_SESSION_ENTRIES = 40; + +/** + * Detect quota exceeded errors across browser implementations. + * Handles: name "QuotaExceededError", code 22 (legacy), "NS_ERROR_DOM_QUOTA_REACHED" + * + * Note: DOMException may not be instanceof Error in all browsers, + * so we check by shape and properties rather than type. + * Note: DOMException.code is deprecated but kept for legacy browser support. + */ +function isQuotaExceededError(error: unknown): boolean { + if (error === null || error === undefined) return false; + + // Check if it's a DOMException when available (standard) + if (typeof DOMException !== "undefined" && error instanceof DOMException) { + if (error.name === "QuotaExceededError") return true; + } + + // Fallback: check by shape for any object with name/code properties + if (typeof error === "object") { + const err = error as Record; + + // Modern standard: check name property (works in all modern browsers) + if (err.name === "QuotaExceededError") return true; + if (err.name === "NS_ERROR_DOM_QUOTA_REACHED") return true; + + // Legacy support: check deprecated code property for very old browsers + // Use Object.hasOwn for safe own-property check + if (Object.hasOwn(err, "code") && err.code === 22) return true; + } + + return false; +} + +// Load entries from sessionStorage +export function loadSessionMap(key: string): Record { + try { + const raw = sessionStorage.getItem(key); + if (!raw) return {}; + + const data = JSON.parse(raw); + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return {}; + } + + return data as Record; + } catch { + // Silently return empty object on parse error + try { + sessionStorage.removeItem(key); + } catch { + // Ignore cleanup errors + } + return {}; + } +} + +// Save entry to sessionStorage with quota management. +export function saveSessionMap( + key: string, + fileKey: string, + value: MeasureScale | Measurement[] | null, +): void { + if (!fileKey) return; + + try { + const existing: Record = { + ...loadSessionMap(key), + }; + + // Delete first to move fileKey to end (maintains insertion order recency) + delete existing[fileKey]; + existing[fileKey] = value; + + // Trim back below the max to avoid pruning again on every subsequent save. + const keys = Object.keys(existing); + if (keys.length > MAX_SESSION_ENTRIES) { + const entriesToDelete = keys.slice( + 0, + keys.length - TRIMMED_SESSION_ENTRIES, + ); + entriesToDelete.forEach((k) => delete existing[k]); + } + + sessionStorage.setItem(key, JSON.stringify(existing)); + } catch (e) { + // Quota exceeded - try clearing and retrying (handles cross-browser error variants) + if (isQuotaExceededError(e)) { + try { + sessionStorage.removeItem(key); + // Retry with fresh storage + const fresh: Record = { [fileKey]: value }; + sessionStorage.setItem(key, JSON.stringify(fresh)); + } catch { + // Silently ignore if retry fails - data loss is acceptable + } + } + // Silently ignore other storage errors + } +} + +// Validation helpers + +export function validatePagePoint(obj: unknown): obj is PagePoint { + if (typeof obj !== "object" || obj === null) return false; + + const pt = obj as Record; + return ( + typeof pt.pageIndex === "number" && + Number.isFinite(pt.pageIndex) && + pt.pageIndex >= 0 && + typeof pt.x === "number" && + Number.isFinite(pt.x) && + typeof pt.y === "number" && + Number.isFinite(pt.y) + ); +} + +// MeasureScale can be null (reset) or valid object +export function validateMeasureScale(obj: unknown): obj is MeasureScale | null { + // null is allowed (reset to default) + if (obj === null) return true; + + if (typeof obj !== "object") return false; + + const s = obj as Record; + + // Validate factor: must be positive finite number + if ( + typeof s.factor !== "number" || + !Number.isFinite(s.factor) || + s.factor <= 0 + ) { + return false; + } + + // Validate ratio: optional, but if present must be positive finite number + if ( + s.ratio !== null && + (typeof s.ratio !== "number" || !Number.isFinite(s.ratio) || s.ratio <= 0) + ) { + return false; + } + + // Validate unit: must be non-empty string and exist in POINT_TO_UNIT + if (typeof s.unit !== "string" || s.unit.trim().length === 0) { + return false; + } + + const normalized = normalizeUnit(s.unit); + if (!isMeasurementUnit(normalized)) { + return false; + } + + return true; +} + +// Reject cross-page measurements +export function validateMeasurement(obj: unknown): obj is Measurement { + if (typeof obj !== "object" || obj === null) return false; + + const m = obj as Record; + + // Validate structure + if ( + !( + typeof m.id === "string" && + m.id.trim().length > 0 && + validatePagePoint(m.start) && + validatePagePoint(m.end) + ) + ) { + return false; + } + + // Reject cross-page measurements + const start = m.start as PagePoint; + const end = m.end as PagePoint; + if (start.pageIndex !== end.pageIndex) { + return false; + } + + return true; +} + +export function formatPaperDistance(distancePts: number): string { + if (!Number.isFinite(distancePts) || distancePts < 0) { + return "0 mm"; + } + + const inches = distancePts / 72; + const mm = inches * 25.4; + + if (mm < 100) { + return `${mm.toFixed(1)} mm`; + } + if (mm < 1000) { + return `${(mm / 10).toFixed(1)} cm`; + } + return `${(mm / 1000).toFixed(2)} m`; +} + +export function validateRealDistance(value: unknown): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + const num = typeof value === "number" ? value : Number(value); + + if (!Number.isFinite(num) || num <= 0) { + return null; + } + + return num; +} + +export function deriveRatioFromFactor( + factor: number, + unit: string, +): number | null { + if (!Number.isFinite(factor) || factor <= 0) { + return null; + } + + const baseFactor = getUnitFactor(unit); + if (!baseFactor) { + return null; + } + + // ratio = factor / baseFactor + const ratio = factor / baseFactor; + return Number.isFinite(ratio) && ratio > 0 ? ratio : null; +} + +export function calculateCalibratedScale( + pdfDistancePts: number, + realDistance: number, + unit: string, +): MeasureScale { + if (!Number.isFinite(pdfDistancePts) || pdfDistancePts <= 0) { + throw new Error("Invalid PDF distance (must be positive)"); + } + + if (!Number.isFinite(realDistance) || realDistance <= 0) { + throw new Error("Invalid real-world distance (must be positive)"); + } + + const baseFactor = getUnitFactor(unit); + if (!baseFactor) { + throw new Error(`Unsupported unit: ${unit}`); + } + + const factor = realDistance / pdfDistancePts; + const ratio = deriveRatioFromFactor(factor, unit); + + return { + factor, + ratio, + unit, + }; +} + +export function createCalibrationMetadata( + pdfDistancePts: number, + realDistance: number, + scale: MeasureScale, + unitUsed: string, +): CalibrationMetadata { + return { + pdfDistancePts, + realDistance, + scale, + timestamp: new Date().toISOString(), + unitUsed, + }; +} diff --git a/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts b/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts new file mode 100644 index 0000000000..3c40e3209f --- /dev/null +++ b/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts @@ -0,0 +1,215 @@ +import type { + PDFArray, + PDFDict, + PDFHexString, + PDFName, + PDFNumber, + PDFString, +} from "@cantoo/pdf-lib"; +import type { + MeasureScale, + PageMeasureScales, + PageScaleInfo, + ViewportScale, +} from "@app/utils/measurementTypes"; +import { getUnitFactor } from "@app/utils/measurementUtils"; + +type PdfMeasurementObjects = Pick< + typeof import("@cantoo/pdf-lib"), + | "PDFArray" + | "PDFDict" + | "PDFHexString" + | "PDFName" + | "PDFNumber" + | "PDFString" +>; + +function asPdfArray( + value: unknown, + { PDFArray }: PdfMeasurementObjects, +): PDFArray | null { + return value instanceof PDFArray ? value : null; +} + +function asPdfDict( + value: unknown, + { PDFDict }: PdfMeasurementObjects, +): PDFDict | null { + return value instanceof PDFDict ? value : null; +} + +function asPdfNumber( + value: unknown, + { PDFNumber }: PdfMeasurementObjects, +): PDFNumber | null { + return value instanceof PDFNumber ? value : null; +} + +function asPdfText( + value: unknown, + { PDFHexString, PDFName, PDFString }: PdfMeasurementObjects, +): PDFHexString | PDFName | PDFString | null { + if ( + value instanceof PDFString || + value instanceof PDFHexString || + value instanceof PDFName + ) { + return value; + } + return null; +} + +function lookupArray( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): PDFArray | null { + return asPdfArray(dict.lookup(pdfObjects.PDFName.of(key)), pdfObjects); +} + +function lookupDict( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): PDFDict | null { + return asPdfDict(dict.lookup(pdfObjects.PDFName.of(key)), pdfObjects); +} + +function lookupNumber( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): number | null { + return ( + asPdfNumber( + dict.lookup(pdfObjects.PDFName.of(key)), + pdfObjects, + )?.asNumber() ?? null + ); +} + +function lookupText( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): string | null { + return ( + asPdfText( + dict.lookup(pdfObjects.PDFName.of(key)), + pdfObjects, + )?.decodeText() ?? null + ); +} + +function readArrayNumber( + array: PDFArray, + index: number, + pdfObjects: PdfMeasurementObjects, +): number | null { + return asPdfNumber(array.lookup(index), pdfObjects)?.asNumber() ?? null; +} + +function readBBox( + bboxArray: PDFArray | null, + pdfObjects: PdfMeasurementObjects, +): ViewportScale["bbox"] { + if (!bboxArray || bboxArray.size() < 4) { + return null; + } + + const x0 = readArrayNumber(bboxArray, 0, pdfObjects); + const y0 = readArrayNumber(bboxArray, 1, pdfObjects); + const x1 = readArrayNumber(bboxArray, 2, pdfObjects); + const y1 = readArrayNumber(bboxArray, 3, pdfObjects); + + if (x0 === null || y0 === null || x1 === null || y1 === null) { + return null; + } + + return [x0, y0, x1, y1]; +} + +function parseScale( + measureDict: PDFDict | null, + pdfObjects: PdfMeasurementObjects, +): MeasureScale | null { + if (!measureDict) return null; + + const fmtArray = + lookupArray(measureDict, "D", pdfObjects) ?? + lookupArray(measureDict, "X", pdfObjects); + if (!fmtArray || fmtArray.size() === 0) return null; + + const firstFmt = asPdfDict(fmtArray.lookup(0), pdfObjects); + if (!firstFmt) return null; + + const factor = lookupNumber(firstFmt, "C", pdfObjects); + if (factor === null || factor <= 0) return null; + + const unit = lookupText(firstFmt, "U", pdfObjects)?.trim().toLowerCase(); + if (!unit) return null; + + const baseFactor = getUnitFactor(unit); + if (!baseFactor) return null; + + const ratio = factor / baseFactor; + return { factor, ratio, unit }; +} + +export async function extractPageMeasureScales( + file: Blob, +): Promise { + try { + const pdfLib = await import("@cantoo/pdf-lib"); + const { PDFDocument, PDFArray, PDFDict, PDFName } = pdfLib; + const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), { + ignoreEncryption: true, + }); + + const result: PageMeasureScales = new Map(); + + for (let i = 0; i < pdfDoc.getPageCount(); i++) { + const page = pdfDoc.getPage(i); + const pageHeight = page.getHeight(); + const viewports: ViewportScale[] = []; + + const vpObj = page.node.lookup(PDFName.of("VP")); + if (vpObj instanceof PDFArray) { + for (let j = 0; j < vpObj.size(); j++) { + const vpEntry = vpObj.lookup(j); + if (!(vpEntry instanceof PDFDict)) continue; + + const scale = parseScale( + lookupDict(vpEntry, "Measure", pdfLib), + pdfLib, + ); + if (!scale) continue; + + viewports.push({ + bbox: readBBox(lookupArray(vpEntry, "BBox", pdfLib), pdfLib), + scale, + }); + } + } + + if (viewports.length === 0) { + const scale = parseScale( + lookupDict(page.node, "Measure", pdfLib), + pdfLib, + ); + if (scale) { + viewports.push({ bbox: null, scale }); + } + } + + if (viewports.length > 0) { + result.set(i, { viewports, pageHeight } satisfies PageScaleInfo); + } + } + + return result.size > 0 ? result : null; + } catch (error) { + console.warn("[Measurement] Failed to extract PDF scales", error); + return null; + } +} From 82e1bd62a2ad166312656b657f35978dbe59970a Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:27:23 +0100 Subject: [PATCH 103/122] Move CODEOWNERS to review teams (#7325) # Description of Changes CODEOWNERS now points at review teams (`maintainers`, `backend-reviewers`, `frontend-reviewers`, `devops-reviewers`, `all`) instead of individual usernames, so membership is managed in the org rather than in this file. Ludy87 and balazs-szucs stay listed by hand since outside collaborators cannot be team members. including the deploy and demo-comment allowlists. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/CODEOWNERS | 36 ++++++++++++------- .github/config/repo_devs.json | 1 - .github/workflows/PR-Auto-Deploy-V2.yml | 2 +- .../workflows/PR-Demo-Comment-with-react.yml | 1 - 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 14f6ca750f..a2e3241c65 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,18 +1,28 @@ -# All PRs must be approved by Frooodle or Ludy87 -* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh +# Review ownership is assigned to teams where possible. +# Teams can only contain org members, so outside collaborators are listed by hand. +# +# @Stirling-Tools/maintainers - Frooodle, jbrunton96, ConnorYoh +# @Stirling-Tools/backend-reviewers - Frooodle, jbrunton96, ConnorYoh +# @Stirling-Tools/frontend-reviewers - Frooodle, jbrunton96, ConnorYoh, reecebrowne, EthanHealy01 +# @Stirling-Tools/devops-reviewers - Frooodle, jbrunton96, ConnorYoh +# @Stirling-Tools/all - all of the above +# +# Outside collaborators (need Write access to count as owners): @Ludy87 @balazs-szucs + +# Default owners for everything +* @Stirling-Tools/maintainers @Ludy87 # Backend -/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs +/app/** @Stirling-Tools/backend-reviewers @Ludy87 @balazs-szucs -#V2 frontend -/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs -/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs +# V2 frontend +/frontend/** @Stirling-Tools/frontend-reviewers @balazs-szucs +/app/core/src/main/resources/static/** @Stirling-Tools/frontend-reviewers @Ludy87 @balazs-szucs -#V2 docker -/docker/backend/** @Frooodle @Ludy87 @DarioGii -/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 -/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 +# V2 docker +/docker/backend/** @Stirling-Tools/devops-reviewers @Ludy87 +/docker/frontend/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87 +/docker/compose/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87 - -#GHA (All users) -/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs +# GHA (all users) +/.github/** @Stirling-Tools/all @Ludy87 @balazs-szucs diff --git a/.github/config/repo_devs.json b/.github/config/repo_devs.json index 8b0bb97a81..597a84dade 100644 --- a/.github/config/repo_devs.json +++ b/.github/config/repo_devs.json @@ -11,7 +11,6 @@ "LaserKaspar", "sbplat", "reecebrowne", - "DarioGii", "ConnorYoh", "EthanHealy01", "jbrunton96", diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 6b37029f90..cd99f6a4cc 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -86,7 +86,7 @@ jobs: fi fi else - auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs") + auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs") is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done if [ "$is_auth" = true ]; then should=true diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 3826897a39..d1e2000b82 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -54,7 +54,6 @@ jobs: github.event.comment.user.login == 'Ludy87' || github.event.comment.user.login == 'balazs-szucs' || github.event.comment.user.login == 'reecebrowne' || - github.event.comment.user.login == 'DarioGii' || github.event.comment.user.login == 'EthanHealy01' || github.event.comment.user.login == 'jbrunton96' || github.event.comment.user.login == 'ConnorYoh' From 2c74b5bf81be0853e0e8503402834e21b7508a42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:01:07 +0100 Subject: [PATCH 104/122] build(deps): bump js-yaml from 4.2.0 to 4.3.1 in /devTools (#7323) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.1.
    Changelog

    Sourced from js-yaml's changelog.

    4.3.1 - 2026-07-31

    Security

    • [backport] Remove quadratic complexity from !!omap duplicate key detection.

    4.3.0 - 2026-06-27

    Added

    • [backport] Added maxTotalMergeKeys (10000) loader option to limit the total number of keys processed by YAML merge (<<) across one load() / loadAll() call.

    Fixed

    • Restore umd builds back to es5.

    Removed

    • [backport] maxMergeSeqLength replaced with maxTotalMergeKeys for limiting YAML merge processing.
    Commits
    • 86e91b8 4.3.1 released
    • c3cc4b0 Backport quadratic complexity fix for !!omap
    • 33d05b5 4.3.0 released
    • 663bfab Drop demo publish, to not override new v5 one.
    • 1cb8c7b Add v4-legacy tag for publish
    • 02f27af Restore umd builds back to es5
    • 8be84ed Fix es5 compatibility
    • 59423c6 Replace maxMergeSeqLength option with maxTotalMergeKeys (more robust). Ba...
    • 6842ef6 doc polish
    • See full diff in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-yaml&package-manager=npm_and_yarn&previous-version=4.2.0&new-version=4.3.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- devTools/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devTools/package-lock.json b/devTools/package-lock.json index 39db3d2046..394237a684 100644 --- a/devTools/package-lock.json +++ b/devTools/package-lock.json @@ -894,9 +894,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { From 1867c8f285e92adf7fa7dc3f50c41b05cbd572e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:01:11 +0100 Subject: [PATCH 105/122] build(deps): bump js-yaml from 4.2.0 to 4.3.1 in /frontend (#7322) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.1.
    Changelog

    Sourced from js-yaml's changelog.

    4.3.1 - 2026-07-31

    Security

    • [backport] Remove quadratic complexity from !!omap duplicate key detection.

    4.3.0 - 2026-06-27

    Added

    • [backport] Added maxTotalMergeKeys (10000) loader option to limit the total number of keys processed by YAML merge (<<) across one load() / loadAll() call.

    Fixed

    • Restore umd builds back to es5.

    Removed

    • [backport] maxMergeSeqLength replaced with maxTotalMergeKeys for limiting YAML merge processing.
    Commits
    • 86e91b8 4.3.1 released
    • c3cc4b0 Backport quadratic complexity fix for !!omap
    • 33d05b5 4.3.0 released
    • 663bfab Drop demo publish, to not override new v5 one.
    • 1cb8c7b Add v4-legacy tag for publish
    • 02f27af Restore umd builds back to es5
    • 8be84ed Fix es5 compatibility
    • 59423c6 Replace maxMergeSeqLength option with maxTotalMergeKeys (more robust). Ba...
    • 6842ef6 doc polish
    • See full diff in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-yaml&package-manager=npm_and_yarn&previous-version=4.2.0&new-version=4.3.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 500 +------------------------------------ 1 file changed, 3 insertions(+), 497 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6f19db35b7..762cee13c5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11150,9 +11150,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -12968,16 +12968,6 @@ "node": ">=10" } }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -17408,490 +17398,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/tsx/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", From cff6549a40340b01e8be7211af5be3ab34ee3ba6 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:36:46 +0100 Subject: [PATCH 106/122] fix(frontend): keep file persistence working when IndexedDB refuses blobs (#7314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Fixes the WebKit nightly failures ([run 31067620195](https://github.com/Stirling-Tools/Stirling-PDF/actions/runs/31067620195/attempts/1)): 8 tests failed on `stubbed-webkit` only, and every one of them logs the same thing in its trace: ``` IndexedDB add error: UnknownError: Error preparing Blob/File data to be stored in object store ``` ## What broke `storeStirlingFile` stores the `File` itself in IndexedDB, so multi-GB uploads are persisted by reference and never materialize in JS memory. That came in with #7175 (`data: stirlingFile` replacing `data: await stirlingFile.arrayBuffer()`), which is a real memory win and worth keeping. WebKit refuses blob values whenever it can't write the blob's backing file, and rejects the request with the error above. The rejection was only `console.error`d, so on WebKit **no upload ever persisted**, and everything that reads the bytes back behaved as if the upload never happened: - `file-state-across-tools` — file gone after navigating; the sidebar shows "No files yet" - `compare` — `FileSelectorPicker: upload failed`, so the slot stays `data-slot-state="empty"` - `classification-grouping` / `classification-heuristic-upload` — the label backfill and thumbnails read from IDB (`not in IndexedDB (likely remote-only stub)`), so files land in "Recent" with no category headers Chromium and Firefox store blobs fine, and PR CI only runs the `stubbed` (chromium) project, so nightly was the only gate that could catch it. ## The fix Try the blob first, keep a fallback: - `storeStirlingFile`'s `add` is extracted into `addFileRecord` so it can run twice - if the value was a Blob and the failure is `UnknownError` / `DataCloneError`, re-add the record with an `ArrayBuffer` copy and set `blobValuesSupported = false`, so later files in that session go straight to the copy path instead of losing the blob attempt every time - deliberately narrow: `QuotaExceededError` and `ConstraintError` still propagate, because a copy would fail the same way and retrying would hide the real cause - dropped two internal `console.error`s: every caller already reports (`addFiles`, `FileSelectorPicker`, `zipFileService` collects into `result.errors`), so they were duplicate noise Every writer goes through `storeStirlingFile` (uploads, the file picker, zip extraction, folder automation, `IndexedDBContext`), so this one seam covers all of them. The read paths already accept either shape (`new Blob([record.data], ...)`). Net effect: Chromium and Firefox keep the no-copy path; engines that refuse blobs degrade to the pre-#7175 behaviour instead of silently losing files. On such an engine a very large file can still exhaust renderer memory — the fallback warns about exactly that. Fixing that properly means chunked storage, which is out of scope here. ## Verification Reproduced and confirmed the cause by A/B on a branch that predates #7175: as-is 8/8 pass on WebKit, and applying only #7175's `data: stirlingFile` line reproduces the exact CI failure set. | Check | Result | |---|---| | `stubbed-webkit`: the 8 nightly failures + `classification-heuristic-upload` | 9 passed | | `stubbed-webkit`: `files-page`, `page-editor-rotation`, `encrypted-pdf-unlock` | 32 passed, 1 skipped | | `stubbed` (chromium): the same specs + `files-page` | 35 passed, 1 skipped | | Frontend unit suite | 210 files, 1797 passed | | `typecheck:core`, `typecheck:proprietary`, eslint, prettier | clean | New unit coverage in `fileStorage.blobFallback.test.ts` pins the contract over `fake-indexeddb` with `add` instrumented to count blob vs copy attempts: blob path when accepted, blob-then-copy when refused (and readable back), one attempt only for later files, and quota not retried. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Testing (if applicable) - [x] Frontend typecheck (core + proprietary), eslint, prettier, the unit suite, and the affected Playwright specs on chromium and webkit all pass Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../services/fileStorage.blobFallback.test.ts | 146 ++++++++++++++++++ .../editor/src/core/services/fileStorage.ts | 57 +++++-- 2 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 frontend/editor/src/core/services/fileStorage.blobFallback.test.ts diff --git a/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts b/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts new file mode 100644 index 0000000000..d8530af026 --- /dev/null +++ b/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test, afterEach, beforeEach, vi } from "vitest"; +import "fake-indexeddb/auto"; +import { expectConsole } from "@app/tests/failOnConsole"; + +/** + * Regression test for the WebKit nightly breakage introduced with the + * large-file OOM fix (#7175): `storeStirlingFile` began putting the `File` + * itself into IndexedDB (persisted by reference, so multi-GB uploads never + * materialize in JS memory). WebKit refuses blob values whenever it can't write + * the blob's backing file and rejects the request with `UnknownError: Error + * preparing Blob/File data to be stored in object store`, so on WebKit every + * upload silently failed to persist: files vanished on navigation, Compare + * slots never filled, and the classification backfill had no bytes to read. + * + * The service now retries such a rejection with an ArrayBuffer copy and stops + * offering blobs for the rest of the session. + */ + +const nativeAdd = IDBObjectStore.prototype.add; + +/** What each `add` attempt carried in `data` — the blob path or the copy path. */ +let attempts: Array<"blob" | "copy"> = []; + +/** An IDBRequest that fails asynchronously, the way WebKit rejects blob puts. */ +class FailingRequest extends EventTarget { + onerror: ((event: Event) => void) | null = null; + onsuccess: ((event: Event) => void) | null = null; + + constructor(readonly error: DOMException) { + super(); + queueMicrotask(() => this.onerror?.(new Event("error"))); + } +} + +/** + * Record every add attempt, optionally failing the blob-valued ones the way an + * engine without blob storage does. + */ +function instrumentAdd(options: { rejectBlobs: boolean }) { + IDBObjectStore.prototype.add = function ( + this: IDBObjectStore, + value: unknown, + key?: IDBValidKey, + ) { + const isBlob = (value as { data?: unknown } | null)?.data instanceof Blob; + attempts.push(isBlob ? "blob" : "copy"); + if (isBlob && options.rejectBlobs) { + return new FailingRequest( + new DOMException( + "Error preparing Blob/File data to be stored in object store", + "UnknownError", + ), + ) as unknown as IDBRequest; + } + return key === undefined + ? nativeAdd.call(this, value) + : nativeAdd.call(this, value, key); + } as typeof IDBObjectStore.prototype.add; +} + +/** + * A fresh service per test: whether the engine accepts blobs is remembered for + * the process lifetime by design, so tests must not inherit that decision from + * each other. + */ +async function freshFileStorage() { + vi.resetModules(); + const [{ fileStorage }, { createStirlingFile, createNewStirlingFileStub }] = + await Promise.all([ + import("@app/services/fileStorage"), + import("@app/types/fileContext"), + ]); + const store = async (name: string) => { + const file = new File(["%PDF-1.7 stirling"], name, { + type: "application/pdf", + }); + const stub = createNewStirlingFileStub(file); + await fileStorage.storeStirlingFile( + createStirlingFile(file, stub.id), + stub, + ); + return stub.id; + }; + return { fileStorage, store }; +} + +beforeEach(() => { + attempts = []; +}); + +afterEach(() => { + IDBObjectStore.prototype.add = nativeAdd; +}); + +describe("storeStirlingFile — blob-value fallback", () => { + test("stores the File by reference when the engine accepts blob values", async () => { + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + + const id = await store("by-reference.pdf"); + + expect(attempts).toEqual(["blob"]); + expect((await fileStorage.getStirlingFile(id))?.name).toBe( + "by-reference.pdf", + ); + }); + + test("falls back to a copy when the engine rejects blob values, and the file stays readable", async () => { + expectConsole.warn(/IndexedDB rejected a Blob value/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: true }); + + const id = await store("webkit.pdf"); + + expect(attempts).toEqual(["blob", "copy"]); + // Readable back is what every downstream consumer depends on: rehydration + // after navigation, thumbnails, the classification backfill. + expect((await fileStorage.getStirlingFile(id))?.name).toBe("webkit.pdf"); + }); + + test("remembers the rejection, so later files skip the doomed blob attempt", async () => { + expectConsole.warn(/IndexedDB rejected a Blob value/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: true }); + + await store("first.pdf"); + attempts = []; + const id = await store("second.pdf"); + + // Straight to the copy path — no repeated blob probe, and only the single + // warning expected above. + expect(attempts).toEqual(["copy"]); + expect((await fileStorage.getStirlingFile(id))?.name).toBe("second.pdf"); + }); + + test("does not retry a failure a copy can't fix (quota)", async () => { + const { store } = await freshFileStorage(); + IDBObjectStore.prototype.add = function (this: IDBObjectStore) { + attempts.push("blob"); + throw new DOMException("no space left", "QuotaExceededError"); + } as typeof IDBObjectStore.prototype.add; + + await expect(store("too-big.pdf")).rejects.toThrow(/no space left/); + expect(attempts).toEqual(["blob"]); + }); +}); diff --git a/frontend/editor/src/core/services/fileStorage.ts b/frontend/editor/src/core/services/fileStorage.ts index 4f82af3a8f..40f62642f0 100644 --- a/frontend/editor/src/core/services/fileStorage.ts +++ b/frontend/editor/src/core/services/fileStorage.ts @@ -63,9 +63,27 @@ export function legacyDerivedFromTool( return undefined; } +/** + * Can't persist a Blob/File value, so a copy would work? WebKit reports + * `UnknownError` ("Error preparing Blob/File data...") when it can't write the + * blob's backing file; a refused structured clone is `DataCloneError`. + * Narrow on purpose: retrying quota or duplicate-key failures would fail again + * and hide the real cause. + */ +function isBlobValueRejection(error: unknown): boolean { + const name = (error as DOMException | null)?.name; + return name === "UnknownError" || name === "DataCloneError"; +} + class FileStorageService { private readonly dbConfig = DATABASE_CONFIGS.FILES; private readonly storeName = "files"; + /** + * Whether this engine accepts Blob/File values in IndexedDB. Optimistic: the + * blob path avoids copying multi-GB files into JS memory, so we try it and + * remember the answer, rather than pre-emptively degrading everywhere. + */ + private blobValuesSupported = true; /** * Get database connection using centralized manager @@ -132,7 +150,10 @@ class FileStorageService { createdAt: stub.createdAt, // Store the File (a Blob) itself: IndexedDB persists it by reference and // streams to disk, so multi-GB files never materialize in JS memory. - data: stirlingFile, + // Engines that reject blob values fall back to a copy — see addFileRecord. + data: this.blobValuesSupported + ? stirlingFile + : await stirlingFile.arrayBuffer(), thumbnail: stub.thumbnailUrl, thumbnailStoredAt: stub.thumbnailUrl ? Date.now() : undefined, isLeaf: stub.isLeaf ?? true, @@ -160,6 +181,30 @@ class FileStorageService { classificationLabels: stub.classificationLabels, }; + try { + await this.addFileRecord(db, record); + } catch (error) { + // Recoverable: re-add as a copy, and stop offering blobs this session. + // Anything else is the caller's to report. + if (!(record.data instanceof Blob) || !isBlobValueRejection(error)) { + throw error; + } + this.blobValuesSupported = false; + console.warn( + "IndexedDB rejected a Blob value; falling back to in-memory copies for this session. " + + "Very large files may now exhaust renderer memory.", + error, + ); + record.data = await record.data.arrayBuffer(); + await this.addFileRecord(db, record); + } + } + + /** Single `add` of a file record. Rejects with the underlying IDB error. */ + private addFileRecord( + db: IDBDatabase, + record: StoredStirlingFileRecord, + ): Promise { return new Promise((resolve, reject) => { try { // Verify store exists before creating transaction @@ -174,15 +219,9 @@ class FileStorageService { const request = store.add(record); - request.onerror = () => { - console.error("IndexedDB add error:", request.error); - reject(request.error); - }; - request.onsuccess = () => { - resolve(); - }; + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); } catch (error) { - console.error("Transaction error:", error); reject(error); } }); From 408f9ef1488cdd6a8f70e8dec122ad2e92f9318e Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 7 Aug 2026 14:49:19 +0100 Subject: [PATCH 107/122] Fix `any` type usages in frontend code (#7326) # Description of Changes Continued effort towards removing all uses of the `any` type in our frontend code. This PR fixes 10 more folders and removes them from the exclude list. All of them were really simple fixes. --- .../components/pageEditor/commands/pageCommands.ts | 1 - .../components/pageEditor/hooks/useEditorCommands.ts | 9 ++++----- .../pageEditor/hooks/useUndoManagerState.ts | 7 +++++-- .../components/shared/config/SettingsSearchBar.tsx | 2 +- .../shared/pageEditor/useFileItemDragDrop.ts | 8 +++++--- .../bookletImposition/BookletImpositionSettings.tsx | 11 +++++++---- .../components/tools/shared/ToolWorkflowTitle.tsx | 3 ++- .../components/tools/shared/renderToolButtons.tsx | 10 ++++++++-- .../core/hooks/signing/useSigningSessionController.ts | 5 +++-- .../adjustContrast/useAdjustContrastOperation.ts | 5 +++-- .../core/hooks/tools/convert/useConvertOperation.ts | 4 ++-- .../removePassword/useRemovePasswordOperation.test.ts | 2 +- frontend/eslint.config.mjs | 10 ---------- 13 files changed, 41 insertions(+), 36 deletions(-) diff --git a/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts b/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts index 1f80b940cd..db9385fc37 100644 --- a/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts +++ b/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts @@ -686,7 +686,6 @@ export class InsertFilesCommand extends DOMCommand { private insertedPages: PDFPage[] = []; private originalDocument: PDFDocument | null = null; private fileDataMap = new Map(); // Store file data for thumbnail generation - private originalProcessedFile: any = null; // Store original ProcessedFile for undo private insertedFileMap = new Map(); // Store inserted files for export constructor( diff --git a/frontend/editor/src/core/components/pageEditor/hooks/useEditorCommands.ts b/frontend/editor/src/core/components/pageEditor/hooks/useEditorCommands.ts index a790e94085..aca8390d64 100644 --- a/frontend/editor/src/core/components/pageEditor/hooks/useEditorCommands.ts +++ b/frontend/editor/src/core/components/pageEditor/hooks/useEditorCommands.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from "react"; import { BulkRotateCommand, DeletePagesCommand, + DOMCommand, PageBreakCommand, ReorderPagesCommand, SplitCommand, @@ -24,7 +25,7 @@ interface UsePageEditorCommandsParams { selectedPageIds: string[]; setSelectedPageIds: (ids: string[]) => void; getPageNumbersFromIds: (pageIds: string[]) => number[]; - executeCommandWithTracking: (command: any) => void; + executeCommandWithTracking: (command: DOMCommand) => void; updateFileOrderFromPages: (pages: PDFPage[]) => void; actions: FileActions; selectors: FileSelectors; @@ -145,10 +146,8 @@ export const usePageEditorCommands = ({ [executeCommandWithTracking, setSplitPositions], ); - const executeCommand = useCallback((command: any) => { - if (command && typeof command.execute === "function") { - command.execute(); - } + const executeCommand = useCallback((command: { execute: () => void }) => { + command.execute(); }, []); const handleRotate = useCallback( diff --git a/frontend/editor/src/core/components/pageEditor/hooks/useUndoManagerState.ts b/frontend/editor/src/core/components/pageEditor/hooks/useUndoManagerState.ts index 86fdf347d2..0e11aa1e80 100644 --- a/frontend/editor/src/core/components/pageEditor/hooks/useUndoManagerState.ts +++ b/frontend/editor/src/core/components/pageEditor/hooks/useUndoManagerState.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { UndoManager } from "@app/components/pageEditor/commands/pageCommands"; +import { + DOMCommand, + UndoManager, +} from "@app/components/pageEditor/commands/pageCommands"; interface UseUndoManagerStateParams { setHasUnsavedChanges: (dirty: boolean) => void; @@ -29,7 +32,7 @@ export const useUndoManagerState = ({ }, [updateUndoRedoState]); const executeCommandWithTracking = useCallback( - (command: any) => { + (command: DOMCommand) => { undoManagerRef.current.executeCommand(command); setHasUnsavedChanges(true); }, diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx index ea1819dfb2..397347dcf6 100644 --- a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx +++ b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx @@ -138,7 +138,7 @@ export const SettingsSearchBar: React.FC = ({ const translationPrefixes = getTranslationPrefixesForNavKey(item.key); const translationContent = translationPrefixes.flatMap((prefix) => flattenTranslationStrings( - t(prefix, { returnObjects: true, defaultValue: {} } as any), + t(prefix, { returnObjects: true, defaultValue: {} }), ), ); diff --git a/frontend/editor/src/core/components/shared/pageEditor/useFileItemDragDrop.ts b/frontend/editor/src/core/components/shared/pageEditor/useFileItemDragDrop.ts index b51b90737a..c1f5d9aaf3 100644 --- a/frontend/editor/src/core/components/shared/pageEditor/useFileItemDragDrop.ts +++ b/frontend/editor/src/core/components/shared/pageEditor/useFileItemDragDrop.ts @@ -111,8 +111,7 @@ export const useFileItemDragDrop = ({ if (!element) return; const rect = element.getBoundingClientRect(); - const clientY = - (source as any).element?.getBoundingClientRect().top || 0; + const clientY = source.element?.getBoundingClientRect().top || 0; const midpoint = rect.top + rect.height / 2; setDropPosition(clientY < midpoint ? "below" : "above"); @@ -121,7 +120,10 @@ export const useFileItemDragDrop = ({ setIsDragOver(false); const dropPos = dropPositionRef.current; setDropPosition("below"); - const sourceData = source.data as any; + const sourceData = source.data as { + type?: string; + fromIndex?: number; + }; if (sourceData?.type === "file-item") { const fromIndex = sourceData.fromIndex as number; let toIndex = indexRef.current; diff --git a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx index fab1f3ff5e..1fbf447a21 100644 --- a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx +++ b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx @@ -14,9 +14,9 @@ import ButtonSelector from "@app/components/shared/ButtonSelector"; interface BookletImpositionSettingsProps { parameters: BookletImpositionParameters; - onParameterChange: ( - key: keyof BookletImpositionParameters, - value: any, + onParameterChange: ( + key: K, + value: BookletImpositionParameters[K], ) => void; disabled?: boolean; } @@ -214,7 +214,10 @@ const BookletImpositionSettings = ({ )} value={parameters.gutterSize} onChange={(value) => - onParameterChange("gutterSize", value || 12) + onParameterChange( + "gutterSize", + typeof value === "number" ? value : 12, + ) } min={6} max={72} diff --git a/frontend/editor/src/core/components/tools/shared/ToolWorkflowTitle.tsx b/frontend/editor/src/core/components/tools/shared/ToolWorkflowTitle.tsx index bc5d362666..60f7448f7e 100644 --- a/frontend/editor/src/core/components/tools/shared/ToolWorkflowTitle.tsx +++ b/frontend/editor/src/core/components/tools/shared/ToolWorkflowTitle.tsx @@ -2,13 +2,14 @@ import React from "react"; import { Flex, Text, Divider } from "@mantine/core"; import LocalIcon from "@app/components/shared/LocalIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; +import { TooltipTip } from "@app/types/tips"; export interface ToolWorkflowTitleProps { title: string; description?: string; tooltip?: { content?: React.ReactNode; - tips?: any[]; + tips?: TooltipTip[]; header?: { title: string; logo?: React.ReactNode; diff --git a/frontend/editor/src/core/components/tools/shared/renderToolButtons.tsx b/frontend/editor/src/core/components/tools/shared/renderToolButtons.tsx index b17cad7c61..d63230914e 100644 --- a/frontend/editor/src/core/components/tools/shared/renderToolButtons.tsx +++ b/frontend/editor/src/core/components/tools/shared/renderToolButtons.tsx @@ -2,7 +2,10 @@ import { Box } from "@mantine/core"; import ToolButton from "@app/components/tools/toolPicker/ToolButton"; import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader"; -import { getSubcategoryLabel } from "@app/data/toolsTaxonomy"; +import { + getSubcategoryLabel, + type ToolRegistryEntry, +} from "@app/data/toolsTaxonomy"; import { TFunction } from "i18next"; import { SubcategoryGroup } from "@app/hooks/useToolSections"; import { ToolId } from "@app/types/toolId"; @@ -15,7 +18,10 @@ export const renderToolButtons = ( onSelect: (id: ToolId) => void, showSubcategoryHeader: boolean = true, disableNavigation: boolean = false, - searchResults?: Array<{ item: [string, any]; matchedText?: string }>, + searchResults?: Array<{ + item: [ToolId, ToolRegistryEntry]; + matchedText?: string; + }>, hasStars: boolean = false, ) => { // Create a map of matched text for quick lookup diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts b/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts index 8fb1ddd5b6..e114a16f9d 100644 --- a/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts +++ b/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { isAxiosError } from "axios"; import apiClient from "@app/services/apiClient"; import { alert } from "@app/components/toast"; import { fileStorage } from "@app/services/fileStorage"; @@ -333,8 +334,8 @@ export function useSigningSessionController(enabled: boolean) { pdfFile = new File([pdfResponse.data], session.documentName, { type: "application/pdf", }); - } catch (pdfError: any) { - if (pdfError?.response?.status === 404) { + } catch (pdfError) { + if (isAxiosError(pdfError) && pdfError.response?.status === 404) { alert({ alertType: "warning", title: t("certSign.sessions.pdfNotReady", "PDF Not Ready"), diff --git a/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts b/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts index 18981b58d0..858ab8c7ef 100644 --- a/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts +++ b/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts @@ -13,9 +13,10 @@ import { pdfWorkerManager } from "@app/services/pdfWorkerManager"; import { createFileFromApiResponse } from "@app/utils/fileResponseUtils"; import { getPdfiumModule, saveRawDocument } from "@app/services/pdfiumService"; import { copyRgbaToBgraHeap } from "@app/utils/pdfiumBitmapUtils"; +import type { PDFDocumentProxy } from "pdfjs-dist"; async function renderPdfPageToCanvas( - pdf: any, + pdf: PDFDocumentProxy, pageNumber: number, scale: number, ): Promise { @@ -26,7 +27,7 @@ async function renderPdfPageToCanvas( canvas.height = viewport.height; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas 2D context unavailable"); - await page.render({ canvasContext: ctx, viewport }).promise; + await page.render({ canvasContext: ctx, canvas, viewport }).promise; return canvas; } diff --git a/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts index d6175e2b8b..d912cc4ede 100644 --- a/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts @@ -210,8 +210,8 @@ export const buildConvertFormData = ( // Static function that can be used by both the hook and automation executor export const createFileFromResponse = ( - responseData: any, - headers: any, + responseData: Blob, + headers: Record, originalFileName: string, targetExtension: string, ): File => { diff --git a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.test.ts b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.test.ts index b60aa09762..8f2953afde 100644 --- a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.test.ts @@ -85,7 +85,7 @@ describe("useRemovePasswordOperation", () => { const testFile = new File(["test content"], "test.pdf", { type: "application/pdf", }); - const formData = buildFormData(testParameters, testFile as any); + const formData = buildFormData(testParameters, testFile); // Verify the form data contains the file expect(formData.get("fileInput")).toBe(testFile); diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 256bdf640a..43fe732a4e 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -284,28 +284,18 @@ export default defineConfig( ignores: [ "editor/src/core/components/annotation/**/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/pageEditor/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/pageEditor/commands/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/pageEditor/hooks/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/shared/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/shared/config/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/shared/config/configSections/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/shared/pageEditor/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/addStamp/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/automate/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/tools/bookletImposition/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/certSign/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/pdfTextEditor/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/tools/shared/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/viewer/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/file/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/viewer/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/hooks/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/signing/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/tools/adjustContrast/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/tools/convert/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/tools/removePassword/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/hooks/tools/shared/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/services/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/tools/annotate/useAnnotationSelection.ts", From 37a48aa7a700fc0d9e33a8dad87da4a0857b3e3a Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 7 Aug 2026 17:02:11 +0100 Subject: [PATCH 108/122] Replace ESLint and dpdm with Oxlint (#7330) # Description of Changes Smaller scope than #6689 to try and get this finished. Replace ESLint and dpdm with Oxlint, a TS linter written in Rust so its performance is dramatically better than the existing tools we use. ## Speed improvement - Current ESLint run: 13.76s - Current dpdm run: 3.59s - Total time: 17.35s - New Oxlint run: 0.90s So Oxlint is about a 20x speed improvement. ## Differences When I last tried to do this, we could recreate our rules identically with Oxlint, but that's not true any more. Oxlint has no current equivalent for ESLint's `no-restricted-syntax` rule, which we were using to ban usages of `