Compare commits

...
Author SHA1 Message Date
Anthony Stirling 07de12746c Add controller endpoints for the DocParse editor tools 2026-07-31 01:01:56 +01:00
Anthony Stirling 469e7c499c Add Parse Document, Smart Split, RAG chunking, and Fill Template editor tools 2026-07-31 00:49:57 +01:00
Anthony Stirling 40ccbc15cc Complete field extraction wiring across engine, API, editor tool, and policies 2026-07-31 00:22:34 +01:00
Anthony Stirling 62944d7423 Add DocParse field extraction with citations, schema suggestion, and policy category 2026-07-31 00:01:24 +01:00
Anthony Stirling 7ce98d29f2 Fix export-only ingest semantics and skip page extraction on advanced tier 2026-07-30 23:22:41 +01:00
Anthony Stirling 65c01e8078 Add advanced DocParse parsing tier (Docling addon) with table extraction 2026-07-30 23:04:11 +01:00
Anthony Stirling 4700542c75 Add ingestion policies with knowledge-base indexing and corpus export 2026-07-30 22:40:11 +01:00
James Brunton 9d01866c83 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.
2026-07-30 14:08:04 +00:00
Reece Browne b35329c8f5 a11y scan: generate required assets, and fail when a story file can't load (#7201)
## 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.
2026-07-30 08:13:41 +00:00
Reece Browne 4dc0927104 a11y job: emit the affected-story list on one line (#7196)
## 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.
2026-07-29 17:08:08 +00:00
ConnorYoh b4a264239c fix(saas): provision a new user and their personal team atomically (#7193)
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.
2026-07-29 15:15:49 +00:00
James Brunton bbd4d2c3ac 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.
2026-07-29 14:33:34 +00:00
Reece Browne 4d207f0c3f a11y job: scan stories when their component changes; fix cold-start false failures (#7191)
## 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.
2026-07-29 14:18:55 +00:00
Reece Browne 8a5470dd01 Add an accessibility regression gate for Storybook (#7086)
## What

Follow-up to #7073. Turns the story scan into an accessibility gate:
stories run axe in a real browser, and CI flags a change that adds a
**new** violation.

The app has plenty of existing a11y problems (mostly theme-level colour
contrast), so rather than block everything on those, they're recorded in
`.storybook/a11y-baseline.json` and grandfathered. The gate cares about
three things:

- a story breaking a rule it wasn't already breaking
- a story that fails to render at all
- a scan that didn't cover everything it was asked to

Starting point: 839 stories carry a known violation, 1058 story-rule
pairs.

## Where it runs

- **Pull requests** scan only the stories the branch touches — usually
seconds. A full sweep is ~30 minutes, too slow to sit in front of every
merge, and the `frontend` path filter is broad enough that unrelated
changes would pay for it.
- **Nightly** scans every story, so a violation introduced somewhere
other than the story itself — a shared component, a theme token — still
surfaces within a day.
- Both upload their scan reports as artifacts; the reports carry the
offending selector and help text, without which a red run can only be
understood by reproducing it locally.
- **Advisory to start with.** It is deliberately not in
`all-checks-passed`, so it reports without blocking. Worth promoting
once a few weeks of runs show the pass/fail is stable.

## Using it

- **Fixed some violations?** `task frontend:storybook:a11y:record`
re-records so the gate locks the improvement in.
- **Locally:** `task frontend:storybook:a11y:changed` for your branch,
`task frontend:storybook:a11y` for everything.
- **New component?** Its story is picked up automatically.

## Testing

- Every story — 526 files, ~1,450 stories — runs in a real browser with
no render failures, and the gate reports no regressions against the
baseline.
- Running the gate over a single changed story takes seconds, which is
the pull-request path.
- The gate's own behaviour is covered against synthetic scan reports: a
new rule fails, the same rule on more nodes does not, a crashed story
fails, an incomplete scan refuses to report, and re-recording refuses
while anything is crashing.
- Typecheck (all build variants), ESLint and Prettier pass.

## Notes for reviewers

Some of this PR is making the mechanism trustworthy rather than adding
features, so it's worth knowing what changed and why:

- Rule ids come from the axe docs URL in each violation, not a
hand-maintained list of rule names — the old list silently ignored 39 of
axe's 104 rules, including `object-alt`, `target-size` and the table
rules.
- The baseline records **which** rules a story breaks, not how many
nodes break them. Node counts drift between runs because stories fetch
asynchronously and axe samples whatever has rendered, which made
unrelated changes look like regressions. For the same reason the
baseline is the union of repeated scans, so a run can only be a subset
of it.
- A story that fails for a non-a11y reason used to yield no rule id and
was recorded as clean, which hid crashes and could mask real violations.
Those now fail, and re-recording refuses to run while any story is
crashing.
- The scan writes a manifest of every story file it intends to cover and
the check fails unless all of them reported, so a dropped batch can't
read as "no violations".
- Vite was pre-bundling the JSX runtime mid-run and reloading the page,
which crashed whichever stories were loading; those deps are now named
up front and the per-story timeout is above the 5s default.

Colour contrast dominates the baseline and is theme-level, tracked
separately from this.
2026-07-29 11:28:24 +00:00
James Brunton 999b5e5995 Add persistent outputs to Processor (#7071)
# Description of Changes
<img width="1270" height="487" alt="image"
src="https://github.com/user-attachments/assets/64894e2b-aab9-42ab-96c2-2c11ba427b52"
/>

Change policies to point towards a source for its output instead of a
dynamically defined output location for the pipeline. This allows for
easy reuse of outputs in different pipelines and makes it impossible to
break complex pipelines by accidentally updating the source but not the
output and vice versa. Also makes outputs a list to match the inputs, so
it's possible for a pipeline to output to multiple locations.

We should consider whether we want to continue calling these Sources
since they're now being used as both inputs and outputs, but that
decision is beyond the scope of this PR.

Also updates the existing S3 DB migration script and adds a new one to
migrate to the new schema. Neither of these scripts are possible with
SQL since it involves parsing and restructuring JSON. I've updated them
so that they only ever run once on startup and mark themselves as
completed.
2026-07-29 11:22:47 +00:00
ConnorYoh 66b80a80c0 fix(saas): personal teams must be allowed to share a name (new signups get no team) (#7180)
## The bug

Every SaaS signup after the very first one is created with `team_id =
NULL`, no team membership and no `home_team_id`. A brand-new account:

```
user_id | username               | team_id | authenticationtype | home_team_id | memberships
    952 | hedewot627@candaba.com | null    | web                | null         | null
```

Since #7070 derives Processor access from leading a team, these accounts
are silently redirected out of the Processor and back to the editor.

## Cause

`SaasTeamService.createPersonalTeam` names every personal team the
literal `"My Team"`, and `stirling_pdf.teams.name` was unique — so the
insert throws a duplicate-key error for the second account onwards. Team
creation is best-effort (caught, logged at WARN), so the account is
created anyway, permanently team-less.

Migration `20251211000000` had already dropped that constraint for
exactly this reason, but it dropped it **by name** while the entity
still declared `@Column(unique = true)`. With Flyway retired for `:saas`
(#7100), `ddl-auto=update` reconciles the schema — so Hibernate
re-created the constraint on the next boot under a generated name the
old `DROP` could never match.

The data bug predates #7070; that PR only made it visible.

## Changes

- **`Team.name` no longer unique.** `TeamController` already enforces
uniqueness for admin-created teams (`existsByNameIgnoreCase` on create
and rename, 409), so nothing user-facing changes. `findByName` is only
used for the `Default`/`Internal` system teams.
- **Existing team-less accounts recover on authentication.** Signup is
the only other place a team is assigned and nothing back-fills
`team_id`, so without this they stay locked out. Guests excluded by
design; healthy accounts short-circuit on a null check (`team` is
`EAGER`).
- **Tests:** team recovered, existing team untouched, guest stays
team-less.

## Deploy order

Needs `20260806000000_teams_name_drop_unique` (SaaS repo, `v3`) to drop
the constraint from the live schema — **deployed after this**, or
Hibernate re-adds it on the next boot.

## Verification

`:saas:compileJava`, `:proprietary:compileJava`, spotless on both, and
the `:saas` team/auth-filter tests (`TeamRecovery`: 3 tests, 0
failures).
2026-07-29 09:31:47 +00:00
dependabot[bot] e05b7f12da build(deps): bump madrapps/jacoco-report from 1.7.2 to 1.8.0 (#6750)
Bumps
[madrapps/jacoco-report](https://github.com/madrapps/jacoco-report) from
1.7.2 to 1.8.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/madrapps/jacoco-report/releases">madrapps/jacoco-report's
releases</a>.</em></p>
<blockquote>
<h2>v1.8.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Bump <code>@​tsconfig/node20</code> from 20.1.4 to 20.1.9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/289">Madrapps/jacoco-report#289</a></li>
<li>Bump eslint-plugin-n from 17.15.1 to 18.0.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/290">Madrapps/jacoco-report#290</a></li>
<li>Bump webpack from 5.95.0 to 5.107.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/291">Madrapps/jacoco-report#291</a></li>
<li>Bump eslint-plugin-import from 2.31.0 to 2.32.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/292">Madrapps/jacoco-report#292</a></li>
<li>Bump picomatch from 2.3.1 to 2.3.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/301">Madrapps/jacoco-report#301</a></li>
<li>Bump brace-expansion by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/300">Madrapps/jacoco-report#300</a></li>
<li>Bump <code>@​eslint/eslintrc</code> from 3.1.0 to 3.3.5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/295">Madrapps/jacoco-report#295</a></li>
<li>Bump flatted from 3.2.7 to 3.4.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/298">Madrapps/jacoco-report#298</a></li>
<li>Bump ts-jest from 29.2.5 to 29.4.11 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/296">Madrapps/jacoco-report#296</a></li>
<li>Bump eslint-plugin-jest from 28.8.3 to 29.15.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/297">Madrapps/jacoco-report#297</a></li>
<li>Bump typescript-eslint from 8.32.0 to 8.60.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/303">Madrapps/jacoco-report#303</a></li>
<li>Bump prettier from 3.3.3 to 3.8.3 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/306">Madrapps/jacoco-report#306</a></li>
<li>Bump <code>@​types/node</code> from 22.10.2 to 25.9.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/307">Madrapps/jacoco-report#307</a></li>
<li>Bump webpack-cli from 5.1.4 to 7.0.3 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/308">Madrapps/jacoco-report#308</a></li>
<li>Bump <code>@​octokit/plugin-paginate-rest</code> by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/311">Madrapps/jacoco-report#311</a></li>
<li>Bump <code>@​octokit/request</code> by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/312">Madrapps/jacoco-report#312</a></li>
<li>Bump octokit from 4.0.2 to 5.0.5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/309">Madrapps/jacoco-report#309</a></li>
<li>Bump globals from 15.14.0 to 17.6.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/317">Madrapps/jacoco-report#317</a></li>
<li>Bump <code>@​octokit/request-error</code> from 5.1.0 to 5.1.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/319">Madrapps/jacoco-report#319</a></li>
<li>Bump eslint-plugin-promise from 7.1.0 to 7.3.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/314">Madrapps/jacoco-report#314</a></li>
<li>Update dependencies - Node 24 by <a
href="https://github.com/thsaravana"><code>@​thsaravana</code></a> in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/320">Madrapps/jacoco-report#320</a></li>
<li>Release 1.8.0 by <a
href="https://github.com/thsaravana"><code>@​thsaravana</code></a> in <a
href="https://redirect.github.com/Madrapps/jacoco-report/pull/321">Madrapps/jacoco-report#321</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/Madrapps/jacoco-report/compare/v1.7.2...v1.8.0">https://github.com/Madrapps/jacoco-report/compare/v1.7.2...v1.8.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/Madrapps/jacoco-report/commit/e51ce1f46f7f8b5331593f935e59cbaf44b84920"><code>e51ce1f</code></a>
Release 1.8.0 (<a
href="https://redirect.github.com/madrapps/jacoco-report/issues/321">#321</a>)</li>
<li><a
href="https://github.com/Madrapps/jacoco-report/commit/6e277c5e84a92823f9f2bedb98bcfc654f853b5d"><code>6e277c5</code></a>
Update dependencies - Node 24 (<a
href="https://redirect.github.com/madrapps/jacoco-report/issues/320">#320</a>)</li>
<li><a
href="https://github.com/Madrapps/jacoco-report/commit/8cd82edaad1cb66dce2fff04423d033d155731aa"><code>8cd82ed</code></a>
Bump eslint-plugin-promise from 7.1.0 to 7.3.0 (<a
href="https://redirect.github.com/madrapps/jacoco-report/issues/314">#314</a>)</li>
<li><a
href="https://github.com/Madrapps/jacoco-report/commit/fe40aed5b33ed6a74a74baf1b971ae8902dd4c80"><code>fe40aed</code></a>
Bump <code>@​octokit/request-error</code> from 5.1.0 to 5.1.1 (<a
href="https://redirect.github.com/madrapps/jacoco-report/issues/319">#319</a>)</li>
<li><a
href="https://github.com/Madrapps/jacoco-report/commit/fcffed9b340a2cb027a7491558945cb355e1389b"><code>fcffed9</code></a>
Bump globals from 15.14.0 to 17.6.0 (<a
href="https://redirect.github.com/madrapps/jacoco-report/issues/317">#317</a>)</li>
<li><a
href="https://github.com/Madrapps/jacoco-report/commit/97271b3031bbbea3c5b1708491a31c42c306f27d"><code>97271b3</code></a>
Bump octokit from 4.0.2 to 5.0.5 (<a
href="https://redirect.github.com/madrapps/jacoco-report/issues/309">#309</a>)</li>
<li><a
href="https://github.com/Madrapps/jacoco-report/commit/775c401329b49ebb8d018ca14e863ef4523ffc5f"><code>775c401</code></a>
Bump <code>@​octokit/request</code> (<a
href="https://redirect.github.com/madrapps/jacoco-report/issues/312">#312</a>)</li>
<li><a
href="https://github.com/Madrapps/jacoco-report/commit/a4d5c6ab9c2794fd73f6e9511b18d3d9978c0a7b"><code>a4d5c6a</code></a>
Bump <code>@​octokit/plugin-paginate-rest</code> (<a
href="https://redirect.github.com/madrapps/jacoco-report/issues/311">#311</a>)</li>
<li><a
href="https://github.com/Madrapps/jacoco-report/commit/566870688b61542ffbf72a8a55b9f72b61c9212f"><code>5668706</code></a>
Bump webpack-cli from 5.1.4 to 7.0.3 (<a
href="https://redirect.github.com/madrapps/jacoco-report/issues/308">#308</a>)</li>
<li><a
href="https://github.com/Madrapps/jacoco-report/commit/7783a4ed10087d6b5dda9f32211ff2d2db9896fa"><code>7783a4e</code></a>
Bump <code>@​types/node</code> from 22.10.2 to 25.9.1 (<a
href="https://redirect.github.com/madrapps/jacoco-report/issues/307">#307</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/madrapps/jacoco-report/compare/50d3aff4548aa991e6753342d9ba291084e63848...e51ce1f46f7f8b5331593f935e59cbaf44b84920">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=madrapps/jacoco-report&package-manager=github_actions&previous-version=1.7.2&new-version=1.8.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

You can trigger a rebase of this PR by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

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 <dependency name> 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)


</details>

> **Note**
> Automatic rebases have been disabled on this pull request as it has
been open for over 30 days.

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-29 09:13:30 +00:00
dependabot[bot] dff3101ca7 build(deps): bump pillow from 12.2.0 to 12.3.0 in /engine in the uv group across 1 directory (#7119)
Bumps the uv group with 1 update in the /engine directory:
[pillow](https://github.com/python-pillow/Pillow).

Updates `pillow` from 12.2.0 to 12.3.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/python-pillow/Pillow/releases">pillow's
releases</a>.</em></p>
<blockquote>
<h2>12.3.0</h2>
<p><a
href="https://pillow.readthedocs.io/en/stable/releasenotes/12.3.0.html">https://pillow.readthedocs.io/en/stable/releasenotes/12.3.0.html</a></p>
<h2>Removals</h2>
<ul>
<li>Remove non-image ImageCms modes <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9697">#9697</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
</ul>
<h2>Documentation</h2>
<ul>
<li>Add release notes for SBOM and performance improvements <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9747">#9747</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
<li>Add security release notes <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9741">#9741</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
<li>Add release notes for Python 3.15 beta wheels <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9696">#9696</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
<li>ImageFont can also be used with ImageText <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9597">#9597</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
<li>Additional guidelines for security reports <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9659">#9659</a>
[<a
href="https://github.com/wiredfool"><code>@​wiredfool</code></a>]</li>
<li>Fixed typo <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9636">#9636</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
<li>Added CVEs to 12.2.0 release notes <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9591">#9591</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
<li>Revise development support information in README <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9583">#9583</a>
[<a
href="https://github.com/aclark4life"><code>@​aclark4life</code></a>]</li>
<li>Add INCIDENT_RESPONSE.md <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9555">#9555</a>
[<a
href="https://github.com/aclark4life"><code>@​aclark4life</code></a>]</li>
<li>Add STRIDE threat model to security docs <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9562">#9562</a>
[<a
href="https://github.com/aclark4life"><code>@​aclark4life</code></a>]</li>
<li>Add CVEs to 12.2.0 release notes <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9556">#9556</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
<li>Update README with revised security policy <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9553">#9553</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
<li>Update security policy <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9552">#9552</a>
[<a
href="https://github.com/aclark4life"><code>@​aclark4life</code></a>]</li>
<li>Update macOS tested Python versions <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9534">#9534</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
</ul>
<h2>Dependencies</h2>
<ul>
<li>Update dependency harfbuzz to v14.2.1 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9720">#9720</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update dependency mypy to v2 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9653">#9653</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update dependency cibuildwheel to v4 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9665">#9665</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update github-actions <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9655">#9655</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update dependency libavif to v1.4.2 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9652">#9652</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update dependency lcms2 to v2.19.1 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9651">#9651</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update dependency check-jsonschema to v0.37.2 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9650">#9650</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update google/oss-fuzz digest to d872252 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9614">#9614</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update dependency lcms2 to v2.19 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9609">#9609</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update dependency libpng to v1.6.58 - autoclosed <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9608">#9608</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update dependency harfbuzz to v14 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9610">#9610</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update dependency mypy to v1.20.2 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9599">#9599</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update github-actions <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9611">#9611</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Update dependency cibuildwheel to v3.4.1 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9607">#9607</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
<li>Move dependency versions to single JSON and enable Renovate <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9559">#9559</a>
[<a href="https://github.com/hugovk"><code>@​hugovk</code></a>]</li>
<li>Updated raqm to 0.10.5 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9557">#9557</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
<li>Update dependency cibuildwheel to v3.4.0 <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9532">#9532</a>
[@<a href="https://github.com/apps/renovate">renovate[bot]</a>]</li>
</ul>
<h2>Testing</h2>
<ul>
<li>Remove matrix.os from benchmark <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9735">#9735</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
<li>Remove references to libavif patch <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9734">#9734</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
<li>Add benchmark tests <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9654">#9654</a>
[<a href="https://github.com/akx"><code>@​akx</code></a>]</li>
<li>Use reshape() instead of setting NumPy array shape directly <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9728">#9728</a>
[<a
href="https://github.com/radarhere"><code>@​radarhere</code></a>]</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/python-pillow/Pillow/commit/bb1d8e8ab8d29048624d96e3ee53cecf7c13d13d"><code>bb1d8e8</code></a>
12.3.0 version bump</li>
<li><a
href="https://github.com/python-pillow/Pillow/commit/e63fc481dc2e07e21d5403deafb8f1ed98a513af"><code>e63fc48</code></a>
Add release notes for SBOM and performance improvements (<a
href="https://redirect.github.com/python-pillow/Pillow/issues/9747">#9747</a>)</li>
<li><a
href="https://github.com/python-pillow/Pillow/commit/13b701bbab291eec4bc87ea17ba06c94e5fe3054"><code>13b701b</code></a>
Add release notes for <a
href="https://redirect.github.com/python-pillow/Pillow/issues/9679">#9679</a></li>
<li><a
href="https://github.com/python-pillow/Pillow/commit/5564ca72fcd59d040e270af5dcf17a0d7161c364"><code>5564ca7</code></a>
List methods</li>
<li><a
href="https://github.com/python-pillow/Pillow/commit/a0920fd384f800b5d0ba3dd29ecdeae4f1d4043b"><code>a0920fd</code></a>
Speed up ImageChops operations (<a
href="https://redirect.github.com/python-pillow/Pillow/issues/9738">#9738</a>)</li>
<li><a
href="https://github.com/python-pillow/Pillow/commit/07e9a6cd5336dc6cf8cae9165cd70cdd2b3e42fc"><code>07e9a6c</code></a>
Speed up <code>Image.filter()</code> (<a
href="https://redirect.github.com/python-pillow/Pillow/issues/9736">#9736</a>)</li>
<li><a
href="https://github.com/python-pillow/Pillow/commit/a94578cf9649ea13e426cf7fb2b71b39ffc0dd50"><code>a94578c</code></a>
Speed up <code>Image.getchannel()</code>, <code>Image.merge()</code>,
<code>Image.putalpha()</code> and `Image...</li>
<li><a
href="https://github.com/python-pillow/Pillow/commit/53e02c43c919d149b2a154a5180079f9df18fbbb"><code>53e02c4</code></a>
Speed up <code>Image.fill()</code>, <code>Image.linear_gradient()</code>
and `Image.radial_gradient...</li>
<li><a
href="https://github.com/python-pillow/Pillow/commit/af037475be8634ba739744243164ba9e2c8346a6"><code>af03747</code></a>
Speed up <code>Image.resample()</code> (<a
href="https://redirect.github.com/python-pillow/Pillow/issues/9739">#9739</a>)</li>
<li><a
href="https://github.com/python-pillow/Pillow/commit/5c9ca56c3e5fba52b647809fbb0986c87e73a571"><code>5c9ca56</code></a>
Speed up <code>alpha_composite</code>, <code>matrix</code>,
<code>negative</code>, <code>quantize</code> (<a
href="https://redirect.github.com/python-pillow/Pillow/issues/9740">#9740</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/python-pillow/Pillow/compare/12.2.0...12.3.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pillow&package-manager=uv&previous-version=12.2.0&new-version=12.3.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)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

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 <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> 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 <dependency name> 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 <dependency name>` 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 <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` 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).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-29 09:13:22 +00:00
dependabot[bot] af1acb68d5 build(deps): bump actions/setup-python from 6.2.0 to 7.0.0 (#7185)
Bumps [actions/setup-python](https://github.com/actions/setup-python)
from 6.2.0 to 7.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-python/releases">actions/setup-python's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>What's Changed</h2>
<h3>Enhancements</h3>
<ul>
<li>Migrate to ESM and upgrade dependencies by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1330">actions/setup-python#1330</a></li>
<li>Pin SHA commits and update docs with latest versions by <a
href="https://github.com/HarithaVattikuti"><code>@​HarithaVattikuti</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1338">actions/setup-python#1338</a></li>
<li>Remove the pip-install input by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-python/pull/1336">actions/setup-python#1336</a></li>
</ul>
<h3>Bug Fix</h3>
<ul>
<li>Fix to Classify stderr warning messages as warnings instead of
errors in annotations by <a
href="https://github.com/lmvysakh"><code>@​lmvysakh</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/1335">actions/setup-python#1335</a></li>
<li>Validate and retry manifest fetch to prevent silent failures by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1332">actions/setup-python#1332</a></li>
</ul>
<h3>Dependency Upgrade</h3>
<ul>
<li>Bump certifi from 2020.6.20 to 2024.7.4 in
/<strong>tests</strong>/data by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/1328">actions/setup-python#1328</a></li>
<li>Remove EOL Python versions and Bumps numpy text fixture by <a
href="https://github.com/priya-kinthali"><code>@​priya-kinthali</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1333">actions/setup-python#1333</a></li>
<li>Upgrade <code>@​actions/cache</code> to 6.2.0 by <a
href="https://github.com/philip-gai"><code>@​philip-gai</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/1337">actions/setup-python#1337</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/lmvysakh"><code>@​lmvysakh</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-python/pull/1335">actions/setup-python#1335</a></li>
<li><a
href="https://github.com/philip-gai"><code>@​philip-gai</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-python/pull/1337">actions/setup-python#1337</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-python/compare/v6...v7.0.0">https://github.com/actions/setup-python/compare/v6...v7.0.0</a></p>
<h2>v6.3.0</h2>
<h2>What's Changed</h2>
<h3>Enhancement</h3>
<ul>
<li>Add RHEL support and include Linux distro in cache keys by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1323">actions/setup-python#1323</a></li>
<li>Fix pip cache error handling on Windows by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1040">actions/setup-python#1040</a></li>
</ul>
<h3>Dependency update</h3>
<ul>
<li>Upgrade minimatch from 3.1.2 to 3.1.5 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/1281">actions/setup-python#1281</a></li>
<li>Upgrade actions dependencies by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a>
with <a href="https://github.com/Copilot"><code>@​Copilot</code></a> in
<a
href="https://redirect.github.com/actions/setup-python/pull/1303">actions/setup-python#1303</a></li>
<li>Upgrade <code>@​actions/cache</code> to 5.1.0, log cache write
denied by <a
href="https://github.com/jasongin"><code>@​jasongin</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/1324">actions/setup-python#1324</a></li>
<li>Upgrade dependency versions and test workflow configuration by <a
href="https://github.com/HarithaVattikuti"><code>@​HarithaVattikuti</code></a>
in <a
href="https://redirect.github.com/actions/setup-python/pull/1322">actions/setup-python#1322</a></li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Update advanced-usage.md by <a
href="https://github.com/Dunky-Z"><code>@​Dunky-Z</code></a> in <a
href="https://redirect.github.com/actions/setup-python/pull/811">actions/setup-python#811</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a>
with <a href="https://github.com/Copilot"><code>@​Copilot</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-python/pull/1303">actions/setup-python#1303</a></li>
<li><a href="https://github.com/jasongin"><code>@​jasongin</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-python/pull/1324">actions/setup-python#1324</a></li>
<li><a href="https://github.com/Dunky-Z"><code>@​Dunky-Z</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-python/pull/811">actions/setup-python#811</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-python/compare/v6.2.0...v6.3.0">https://github.com/actions/setup-python/compare/v6.2.0...v6.3.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/setup-python/commit/5fda3b95a4ea91299a34e894583c3862153e4b97"><code>5fda3b9</code></a>
Pin SHA commits and update docs with latest versions (<a
href="https://redirect.github.com/actions/setup-python/issues/1338">#1338</a>)</li>
<li><a
href="https://github.com/actions/setup-python/commit/4ab7e95f05e168b4356aebde89dd84f59c283d8e"><code>4ab7e95</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/setup-python/issues/1337">#1337</a>
from actions/philip-gai/bump-actions-cache-6-2-0</li>
<li><a
href="https://github.com/actions/setup-python/commit/0f3a009f475dbea83c0371cd85d099690fee8c5c"><code>0f3a009</code></a>
Remove the pip-install input (<a
href="https://redirect.github.com/actions/setup-python/issues/1336">#1336</a>)</li>
<li><a
href="https://github.com/actions/setup-python/commit/f8cf4291c8b8e273ddd26e569454615c7315d932"><code>f8cf429</code></a>
Migrate to ESM and upgrade dependencies (<a
href="https://redirect.github.com/actions/setup-python/issues/1330">#1330</a>)</li>
<li><a
href="https://github.com/actions/setup-python/commit/54baeea5b34417d10a7479663a23cca53ea209b5"><code>54baeea</code></a>
Validate and retry manifest fetch to prevent silent failures (<a
href="https://redirect.github.com/actions/setup-python/issues/1332">#1332</a>)</li>
<li><a
href="https://github.com/actions/setup-python/commit/c7092773a316760f4ecfe498e4af668a4dafeac5"><code>c709277</code></a>
Annotation code fix (<a
href="https://redirect.github.com/actions/setup-python/issues/1335">#1335</a>)</li>
<li><a
href="https://github.com/actions/setup-python/commit/6849080452e69b330395e8a6d23cf90f56d76a1a"><code>6849080</code></a>
remove EOL Python versions and Bumps numpy text fixture (<a
href="https://redirect.github.com/actions/setup-python/issues/1333">#1333</a>)</li>
<li><a
href="https://github.com/actions/setup-python/commit/0903b469fbf4441aadfe4f4b249dc5b1fba3a73e"><code>0903b46</code></a>
Bump certifi from 2020.6.20 to 2024.7.4 in /<strong>tests</strong>/data
(<a
href="https://redirect.github.com/actions/setup-python/issues/1328">#1328</a>)</li>
<li><a
href="https://github.com/actions/setup-python/commit/ece7cb06caefa5fff74198d8649806c4678c61a1"><code>ece7cb0</code></a>
Fix pip cache error handling on Windows. (<a
href="https://redirect.github.com/actions/setup-python/issues/1040">#1040</a>)</li>
<li><a
href="https://github.com/actions/setup-python/commit/1d18d7af5f767c1259ede05a0a5bcc30f3dcf1cf"><code>1d18d7a</code></a>
Update advanced-usage.md (<a
href="https://redirect.github.com/actions/setup-python/issues/811">#811</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405...5fda3b95a4ea91299a34e894583c3862153e4b97">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-python&package-manager=github_actions&previous-version=6.2.0&new-version=7.0.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)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

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 <dependency name> 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)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-29 09:13:07 +00:00
Reece Browne 22ec0947c9 Storybook coverage: scan harness + stories (#7073)
## What

Gets most of the app's components into Storybook and adds a scan that
runs every story in a real browser, so we have a base to build
accessibility testing on next.

- **~380 new stories**, taking story files from 144 to 526. Components
with a story:

  | Layer | Before | After |
  |---|---|---|
  | core | 41 / 309 (13%) | **183 / 309 (59%)** |
  | portal | 91 / 161 (57%) | **127 / 161 (79%)** |
  | proprietary | 1 / 105 (1%) | **39 / 105 (37%)** |
| cloud / desktop / saas / portal-saas / prototypes | 0 / 84 | 0 / 84
(unchanged) |
  | **Total** | **133 / 659 (20%)** | **349 / 659 (53%)** |

Both columns are counted the same way — every `.tsx` exporting a
component, so the denominator includes things that aren't really visual
units (contexts, providers, barrels). Excluding those it's 22% → 58%.
Either way it's reproducible from the tree rather than a number you have
to take on trust.

- **Scan harness** — the Storybook Vitest addon runs each story in
headless Chromium as a **render/smoke check** (a story must mount
without throwing). New task: `task frontend:storybook:test` (pass a
filter, e.g. `-- Button`). Separate Vitest config so it doesn't touch
the existing jsdom unit tests.

## Scope

- **Stories and Storybook config only, with one exception:** a one-line
fix to `ProviderCard`, which re-rendered forever whenever its optional
`settings` prop was omitted. Called out because it's the only component
source change here.
- The preview gains a `QueryClientProvider` (the portal app has one, so
stories reaching a query hook threw without it), and the scan task now
installs the browser it drives.
- **a11y is report-only** and **nothing runs the scan in CI yet** —
enforcing a11y and wiring it into CI is the follow-up, #7086.
- Components that can't render as an isolated unit are **not** included:
anything needing the full editor runtime (ToolWorkflow / FileManager /
AppConfig / a live PDF engine) or that's headless (providers, gates, API
bridges, config factories). Stories that only rendered by mounting the
whole `AppProviders` tree were dropped for the same reason — that isn't
isolation, and the tree's ErrorBoundary swallowed render failures so
those stories could never fail. A few that need assets the headless
browser can't serve are tagged `!test`, so they still show in the UI but
sit out the scan.

## Testing

Typecheck (all build variants), ESLint, Prettier and the unit suite
pass. Every story in the scanned set mounts without throwing.

## Notes for reviewers

- Stories use the `@app`/`@core`/`@portal`/`@proprietary` aliases (no
deep relative imports) and mock data-fetching components with MSW.
- Running the full suite in one go can flake on the Vite dep-optimizer;
scan in small batches (or by filter) for a stable local run.
2026-07-28 16:06:06 +00:00
ConnorYohandReece Browne ba404d3f90 PAYG bundle: server-authoritative price (inline amount_off coupon) + #7032 review nits (#7156)
## What

Follow-up to #7032. Makes the prepaid-bundle price
**server-authoritative** and removes the percent-coupon rounding drift,
by switching the 12-for-10 discount from a pre-made `percent_off` Stripe
coupon to an **edge-function-computed inline `amount_off` coupon**. Also
folds in Ethan's #7032 review nits.

This is a money-mechanism change, so it was verified against the Deno
tests and is ready for a V2-preview check before rollout.

## SaaS side — already on `v3` (purely additive)

The edge fn + migration were pushed **directly to `v3`** (commit
`4534ff1c1`), since the DB change is purely additive (a
backward-compatible function replacement — no table/column/data
changes):

- `create-payg-bundle-quote`: retrieves the Stripe Price for the bundle,
computes `subtotal = unit_amount x pool_credits` (falls back to
`round(unit_amount_decimal x pool_credits)`), `discount = round(subtotal
x 2 / 12)`, `total = subtotal - discount`; mints a single-use
fixed-amount coupon (`amount_off`, `duration: once`, `max_redemptions:
1`, `redeem_by = valid_until`) and applies it instead of the stored
percent coupon; persists `total` via `p_price_minor`.
- Migration `20260803000000_payg_bundle_quote_stripe_price_minor.sql`:
`payg_set_bundle_quote_stripe` gains `p_price_minor BIGINT DEFAULT NULL`
→ `price_minor = COALESCE(p_price_minor, price_minor)`.

**Deploy choreography (important):** the migration must apply **before**
the edge fn is deployed — the fn now calls the 4-arg
`payg_set_bundle_quote_stripe`. #7032's own Supabase migration is
already on `main`/`v3`.

## This PR (FE)

- **Server-authoritative price:** `bundlePriceMinor` now computes
`subtotal - round(subtotal x (granted-paid)/granted)` (round the
discount, then subtract) — identical to the edge fn — so the pre-mint
estimate matches the `amount_off` charged, and the persisted/frozen
total, to the penny (they previously diverged by a minor unit on
exact-half ties). Tie-case test added.

### Ethan's #7032 review nits

- **1** — comments in `ActivationChoiceModal` / `FreePlanView` no longer
assert the metered subscription is auto-provisioned off the saved card;
they describe it as a known, not-yet-wired follow-up.
- **2** — corrected the price-authority narrative (`stripe.ts`,
`BundleCheckoutModal`): the client-sent `p_price_minor` is a pre-mint
**display estimate only**; the edge fn overwrites `price_minor` with the
server total once the quote is minted. **Verified** the edge fn builds
the Stripe line from `bundle_price_id x pool_credits` with `amount_off`
from the retrieved Price — it never uses the client price.
- **4** — `ensureStripeQuote`'s reuse key now includes the
posture/size/pipeline ids (`buildStripeQuoteSig`), not just pool+PO, so
a same-pool sizing edit re-mints and re-persists instead of leaving
stale sizing on the row.
- **5** — `SpendLimitPicker`: a cleared field (maps to `0`) can no
longer proceed as a `$0` cap — the cap-step Continue is disabled and
`handleContinue` guards on it (empty = incomplete, distinct from the
explicit `null` "No limit").
- **6** — `"prepaid PDFs"` code fallbacks aligned to the `"prepaid
credits"` TOML (`usageMeters`, `PrepaidCapacityCard`).

## Testing

- SaaS Deno: **25/25** (coupon `amount_off == round(subtotal*2/12)`,
`p_price_minor == total` persisted, `unit_amount_decimal` fallback,
exact-half tie, zero-discount path, price/coupon failure paths).
- FE vitest: **50** billing/format tests pass; prettier + eslint clean;
tsc clean for all changed files.
- Pending: manual V2-preview check that the invoice shows a concrete
`-$X.00` discount line (labelled "12 months for the price of 10") equal
to the in-app total.

Closes the residual half of #7032 review finding #2 — once
merged/deployed, the in-app total, the persisted value, and the Stripe
invoice all agree.

---------

Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2026-07-28 13:05:20 +00:00
ConnorYoh a380a82234 build: raise Gradle daemon heap to avoid intermittent CI OOM (#7151)
## Problem

The `build (25, saas)` CI job intermittently fails with:

```
The Daemon will expire immediately since the JVM garbage collector is thrashing.
The currently configured max heap space is '512 MiB' and the configured max metaspace is '384 MiB'.
FAILURE: Build failed with an exception.
* What went wrong:
Gradle build daemon has been stopped: since the JVM garbage collector is thrashing
```

`gradle.properties` never set `org.gradle.jvmargs`, so the daemon runs
on Gradle's 512 MiB default heap. The larger builds — the `saas` flavor
in particular, which compiles core + proprietary + saas — exhaust it
under `org.gradle.parallel=true`, and the daemon dies mid-build. It's
flaky (passes on re-run), which makes it a recurring, noisy CI failure.

## Fix

```properties
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g
```

2 GiB heap + 1 GiB metaspace gives comfortable headroom on GitHub-hosted
runners and typical dev machines, well clear of the thrash point.
One-line, repo-wide config change.

## Verification

- `./gradlew help` starts the daemon cleanly with the new args (no
malformed-arg failure).
- The real signal is CI: this branch's `build (25, saas)` should stop
OOM-ing.

Split out from #7048 (Plan & Usage) since it's unrelated build
infrastructure.
2026-07-27 10:46:56 +00:00
ConnorYoh 3813ca360e PAYG prepaid usage bundles (#7032)
# Prepaid usage bundles

Teams on pay‑as‑you‑go can **buy a year of PDF processing up front, at a
discount** — *"12 months for the price of 10."* You pre‑buy a pool of
credits; they're spent **before** any metered billing and sit
**outside** the monthly spend limit; unused capacity expires after 12
months.

---

## What this PR delivers

**Buy → quote → invoice → pay (Stripe‑Quotes‑native).**
- A team lead sizes the pool in the calculator (persisted as a quote
row), which doubles as the quote page with a **"Download quote (PDF)"**
— the PDF is **Stripe's own rendered quote** (same mechanism procurement
uses), not an app‑generated document.
- **Finalise** turns the accepted quote into an invoice; the lead can
**download the invoice** or **pay online** (Stripe hosted invoice).
**Card and bank‑transfer / PO** are both supported (payment‑method
fork), on net terms.
- The billing page loads the in‑flight quote/invoice on open, so the CTA
resumes the right step (**View quote** / **Pay invoice to complete**)
and offers **Cancel purchase** (voids the invoice + quote and restarts).

**Prepaid is usable on its own — no subscription required.** The
entitlement gate honours a live prepaid pool in both cases:
- *Unsubscribed*: once the one‑time free grant is spent, a live pool
keeps the team **fully entitled** (all feature gates) rather than
degraded.
- *Subscribed*: a team **at/over its metered cap** but holding a live
pool stays fully entitled — prepaid draws are netted out of metered
spend, so the pool genuinely sits outside the cap.

Only when the free grant **and** the prepaid pool are both empty do
billable categories stop.

**Coordinated SaaS change (ships with this — `Stirling-PDF-SaaS` `v3`
branch):** the `invoice.paid` webhook credits the pool idempotently
(keyed on the invoice id) and settles the quote. Metered‑subscription
provisioning is best‑effort and **classified** — a permanent Stripe 4xx
(the single‑use hosted‑invoice card can't be attached) is a claimed
no‑op (HTTP 200, no retry) so Stripe doesn't redeliver forever; only
transient errors (5xx / connection / rate‑limit) retry. A failed credit
now retries rather than silently dropping a paid bundle.

### Flow

1. Lead sizes the pool and agrees to the terms → the browser sends team
+ capacity + consent, **never a price**.
2. A leader‑gated server function looks up the price and creates a
Stripe **quote** (line quantity = capacity).
3. Lead **finalises** → the quote becomes a Stripe **invoice**; download
it or pay online (card or bank transfer).
4. On `invoice.paid`, the webhook **credits the prepaid pool**
(idempotent) and settles the quote.
5. Usage then draws **free grant → prepaid pool → meter**; the pool is
usable with no subscription.

<img width="1280" height="920" alt="01-activation-fork"
src="https://github.com/user-attachments/assets/e8981dc7-809d-4fc5-bfde-71e619096b7b"
/>
<img width="1280" height="920" alt="02-calculator"
src="https://github.com/user-attachments/assets/81f6b886-1797-4c54-ba18-97d126be78e2"
/>
<img width="1120" height="600" alt="03-free-plan"
src="https://github.com/user-attachments/assets/c4057fde-d965-47dd-93e8-f2f0f612aa73"
/>
<img width="1105" height="1285" alt="04-subscribed-prepaid"
src="https://github.com/user-attachments/assets/9d1d0ef1-29d0-4bd4-9c17-ac5f68f88ca8"
/>


---

## In a follow‑up (not this PR)

1. **Authoritative price via an inline fixed‑amount coupon** *(in
progress in a separate PR).* Replace the percentage 12‑for‑10 coupon
with an edge‑function‑computed **`amount_off`** coupon: the invoice
shows a concrete "−$X.00" discount line, the total is deterministic (no
percentage‑rounding drift), and the persisted price becomes
**server‑authoritative**. Money‑mechanism change — needs validation
against the Stripe test env, so it warrants its own testable PR.
2. **Metered auto‑resume when the pool empties.** Save the paying card
at invoice time (`setup_future_usage`) for card payers → real
`charge_automatically`; a cardless `send_invoice` subscription for
bank‑transfer / PO. This makes the "processing continues at the metered
rate" promise true for everyone.
3. **Provisioning idempotency hardening** (SaaS repo). Idempotency key
on subscription creation + a conditional link RPC, so a webhook
redelivery or link‑RPC failure can't create duplicate or orphaned
subscriptions.
4. **Repo‑wide "credits" copy** across *all* of usage & billing (this PR
only makes its own additions consistent).

---

## Known edges (current state)

- **Cardless teams degrade when the pool empties.** An unsubscribed
bundle team that runs the pool dry hits DEGRADED (metered paused), not
automatic metered continuation — because no metered subscription gets
provisioned off a hosted‑invoice card. The consent copy states
processing "continues at the metered rate"; that promise is
intentionally **ahead of the mechanism** (follow‑up 2), and the 12‑month
term is the runway to deliver it. The prepaid capacity itself stays
fully usable in the meantime.
- **In‑app total vs charge can differ by ≤1¢** until follow‑up 1 lands.
The **shared approval document (the Stripe quote PDF) and the actual
invoice are already Stripe‑authoritative**; the persisted price shown
in‑app is still a front‑end estimate (percentage‑coupon rounding), so it
can differ from Stripe by a rounding cent. Resume‑time drift is fixed
(frozen to persisted); exact‑to‑the‑penny parity arrives with the
authoritative‑price follow‑up.
- **Provisioning idempotency is latent, not live.** The
duplicate/orphan‑subscription window only becomes reachable once
card‑linking (follow‑up 2) makes provisioning actually run; hardening is
tracked as follow‑up 3.
- **One job can overshoot the spend cap via a near‑empty pool.** A
subscribed team that has hit its metered cap but still holds a
*nearly‑exhausted* pool is let through (the pool overrides the cap
gate); if a job needs more than the pool has left, the pool drains to
zero and the **remainder meters**, so that single job's remainder can
bill just past the "never past your spend limit" ceiling. Bounded to one
job's overshoot and only at the pool's tail; the alternative — blocking
the job — would strand paid‑for capacity, so this is a deliberate trade.

---

## Testing

- **Java** — `EntitlementServiceTest` (18) incl.
unsubscribed‑live‑pool‑stays‑FULL,
subscribed‑over‑cap‑with‑pool‑stays‑FULL, and lazy‑read guards.
- **Frontend** — `useBundleFlowState` + `Usage` render tests; portal &
SaaS `tsc`; i18n audit; `lint:colors`; toml‑sort; prettier.
- **SaaS webhook** (`v3`) — Deno tests for terminal‑vs‑transient
provisioning classification (rate‑limit treated as retryable),
credit‑error‑retries, and an end‑to‑end no‑storm assertion on the
unusable‑card path.

*Preview:* the checkout runs in a Supabase function in
`Stirling-PDF-SaaS` (`v3`); a live V2 preview is linked in the
auto‑deploy comment below. Screenshots to be refreshed — the checkout
modal changed since the originals.
2026-07-24 12:53:16 +00:00
Anthony Stirling 1681b5d298 Source and connections changes to integrations (#7068) 2026-07-24 11:50:17 +01:00
Anthony Stirling 831bd4fe94 Remove depot.dev support from GitHub Actions workflows (#7148) 2026-07-24 10:52:26 +01:00
James Brunton 4fbb2fe885 Add PR Quiz skill (#7137)
# Description of Changes
Adds a Claude skill to quiz you about your PR, to help check that you
understand the code in the PR.

<img width="796" height="589" alt="image"
src="https://github.com/user-attachments/assets/0f520a99-9acf-4922-85fe-5f49f8f68823"
/>

<img width="811" height="621" alt="image"
src="https://github.com/user-attachments/assets/e440c775-d901-443b-a213-1cc4ee6303af"
/>
2026-07-24 09:37:10 +00:00
ConnorYoh 54bf32485f feat(portal): adopt TanStack Query with a shared per-resource query layer (#7135)
## Why

The processor/portal loads slowly because every view fetches its data on
mount with no client-side cache — navigating away and back refetches
everything, and shared data (policies, sources, roster, fleet stats) is
fetched repeatedly. This adopts **TanStack Query** so the portal caches,
dedupes, and revalidates instead.

Follows the Users-page proof-of-concept (kept as the reference A/B
example behind a dev flag); DevTools before/after confirmed revisiting a
cached view now costs zero network calls.

## What

**Shared per-resource query layer** (`portal/queries/`) — the mechanism
for both in-view and cross-view sharing:
- `keys.ts` (flavor-agnostic queryKey factory), `adapters.ts`
(`toAsyncState` → the existing `AsyncState` shape, so view bodies barely
change)
- One **base hook per endpoint**; **derived hooks**
(`usePoliciesOverview`, `useProcessorFlow`, `useOnboardingProgress`)
compose them
- The bundle functions (`fetchPolicies`, `fetchProcessorFlow`,
`useOnboardingProgress`) are decomposed into base queries — otherwise
the caches wouldn't dedupe against each other

**Migrated:** Documents, Policies, Pipelines, Sources + all of Home's
fetching cards. Mutations use `invalidateQueries` (Policies' `version`
bump removed; Source/Pipeline builders invalidate-then-navigate;
ConnectionsTab + S3 picker share one cache).

**SaaS `/team/my` collapse:** `resolveTeam()` reads through the shared
cache (`ensureQueryData`), so roster + teams resolve it once (2→1), with
a direct-fetch fallback when no provider is mounted.

`QueryClientProvider` is mounted once at the portal root (`PortalApp`),
above the router, so the cache survives navigation.

## Impact on duplicate fetches

- **In-view:** Home `/policies` ×3, `/policies/runs` ×3, `/sources` ×2,
`/v1/editor/deployment` ×2 → **1× each** per mount
- **Cross-view:** Policies / Sources / Users / EditorAdmin /
Infrastructure reuse Home's warmed cache within `staleTime` (no refetch
on navigation)
- **SaaS Users:** `/team/my` 2× → **1×**

## Testing

- Portal typecheck + SaaS typecheck, ESLint (`--max-warnings=0`),
Prettier — all green
- **224 portal tests pass** (existing component tests wrapped in a
shared `QueryClient` test provider)
- New: `queries/sharing.test.tsx` (in-view: 3 consumers → 1 fetch each;
cross-view: remount → 0 refetch) and a `/team/my` collapse assertion in
`UsersReactQuery.test.tsx`

## Notes for reviewers

- Keys are intentionally flavor-agnostic (local vs SaaS routing lives
inside the api fns), so one key addresses whichever backend the flavor
build resolves.
- `staleTime` 30s / `gcTime` 5m defaults; tier-dependent resources key
on tier.
- Users view keeps its dev flag/legacy path deliberately as the
documented reference.
2026-07-23 14:53:16 +00:00
Anthony Stirling a1b1f974a0 Show brand mark and Stirling name in processor sidebar (#7125)
# Description of Changes
Change logo in top left
<img width="872" height="160" alt="image"
src="https://github.com/user-attachments/assets/69f5366c-8640-41ca-9555-c5d30881678d"
/>

---

## 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.
2026-07-23 11:22:46 +00:00
James Brunton 29002d0b82 Improve UX around source folders in Processor (#7101)
# Description of Changes
Adds implicitly defined folders to the list of locations that folder
sources can look in, including the legacy watchedFolder folders, and the
server storage location (if enabled). Also adds a settings UI for
defining the list of allowed folders instead of having to manually edit
`settings.yml` (please excuse the styling, that's the standard styling
of the Processor, hoping it gets fixed by one of the styling PRs).

<img width="888" height="786" alt="image"
src="https://github.com/user-attachments/assets/cf6d0705-adcf-463c-8e80-6901a652068b"
/>

<img width="1103" height="713" alt="image"
src="https://github.com/user-attachments/assets/b7244592-249a-4149-994f-3a2b750f25f9"
/>
2026-07-23 11:19:24 +00:00
Anthony Stirling 1e2895a79f Add external-API integrations plus pipeline steps (#7098)
New Generic API mode with examples and integrations setup around it 

- Adds an integration operations catalogue so external-API connections
(e.g. Microsoft Purview) can be used as policy pipeline steps
- New generic external-API step calls a configured connection during a
policy run, with a verdict gate to pass/fail documents on the response
- Purview sensitivity-labelling step applies labels to processed
documents, gated behind the Purview connection being configured (WIP to
be changed later)

---

## 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.
2026-07-23 11:18:39 +00:00
EthanHealy01 67e10138b5 Follow-up: colour token migration (compat → --c-*, hardcoded hex) (#7011)
Follow-up to #7009, which built the theme token layer (`primitives` →
`colors` → `compat`). This PR moves the whole app onto it, removes
hardcoded colours, turns on enforcement so they can't come back, and
adds a user-selectable accent.

## What this does
- **Semantic tokens everywhere** — legacy colour aliases and raw hex are
rewritten to `--c-*` tokens (`--c-surface*`, `--c-text*`, `--c-primary`,
…). Straight rename, no visual change. Genuine literals (brand/OAuth,
colour pickers, data-viz) are left as-is.
- **Fixes missing colours** — some tokens the migration referenced were
never defined, so a few surfaces (login button, auth banners, badges,
procurement view) silently lost their colour. All defined now, and they
adapt to light/dark and the accent automatically.
- **Blocking colour lint** — CI now fails on hardcoded colours,
undefined tokens, or unreadable low-contrast status colours.
- **User-selectable accent** — light and dark each get their own accent
from Settings → Appearance, contrast-clamped so text stays legible.
"Default" keeps the standard blue.

## Still to come
Remaining inline-style hex, the legacy token-definition files
(`theme.css`, `tokens.css`), and folding `zIndex.ts` onto the dimension
tokens.

## Testing
`task frontend:check:all` green; light/dark and accent switching
spot-checked.
2026-07-23 09:05:56 +00:00
Anthony Stirling 8de94ff152 Ai customization settings (#7069)
# Description of Changes
AI settings customisation in settings menu, as part of this also tested
and fixed ollama and other 3rd party AI integrations

- Adds an admin AI settings UI for customizing AI behaviour, including
per-provider model and API-key configuration
- Backend pushes AI config changes to the Python engine at runtime via a
config-push bridge, so changes apply without a restart
- Config-push is gated off in SaaS; engine now drains background tasks
on shutdown instead of cancelling them
---

## 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.
2026-07-23 08:29:06 +00:00
Anthony Stirling b3875d3149 Add heuristic classification (#7050)
# Description of Changes

- Adds a non-AI heuristic classification engine that classifies
documents client-side in the browser when AI is disabled
- Classification is billed as a policy run via a fast, non-blocking
meter endpoint; a default Classification policy is seeded per team
- Enables the policy engine by default

---

## 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.
2026-07-23 08:28:26 +00:00
Anthony StirlingandEthanHealy01 357eb77f94 Portal: multiple named personal API keys with per-key usage tracking (#6961)
# Description of Changes

Multiple named **personal** API keys per user, replacing the single
opaque per-user key.

- Create (name + one-time secret), list, and revoke named keys from the
portal Infrastructure → API Keys tab. Works self-hosted and SaaS
(`X-API-KEY`).
- Per-key usage stats (today / trailing 30 days / lifetime);
API-processed documents are attributed to the specific key in the
processor's Documents feed.
- The legacy single per-user key keeps working and is lazily represented
as a named key. Rotating it revokes its migrated shadow row so the old
secret stops authenticating.
- Per-user (not per-key) rate limiting plus a per-user active-key cap,
so minting keys can't multiply the daily quota. Name-length cap;
race-safe migration and usage recording.

Keys are strictly personal: one owner, full access, no sharing.
Team-shared / scoped keys and per-key access levels were intentionally
left out of this PR to keep it small and easy to review; they can follow
as a separate, focused change.

> Note: the screenshots from the original revision showed an earlier
team-scoped design and need refreshing.

---

## 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)
- [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.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-07-23 07:52:57 +00:00
EthanHealy01 718277a934 Portal home: trim to onboarding + processor flow, secondary Set up buttons (#7111)
Simplifies the processor home page:

- Removes everything below the processor flow (processing status strip,
recent activity, quick actions, policy summary), leaving just the
onboarding hero and processor flow.
- Changes the policy "Set up" buttons (Security/Classification) from
primary to secondary variant.

<img width="2056" height="1047" alt="Screenshot 2026-07-20 at 7 27
56 PM"
src="https://github.com/user-attachments/assets/8625b274-b52a-47be-9052-80ac3d32dd93"
/>
2026-07-22 20:08:45 +00:00
Anthony Stirling 3bf0019d7c Webhook policy source (#7051)
# Description of Changes
Create custom webhooks as a source, allows file pushes toa custom made
endpoint with custom auth ID

- Adds webhook as a policy source: external systems push documents to a
receiver endpoint, which stages the files locally and triggers the
policy run
- Requests are authenticated with HMAC signatures; receiver hardened
with bounded body reads and server-minted IDs
- Uses the same team-scoped IntegrationConfig connection model as the S3
source, with matching portal UI (source type, icon, wizard)
- Includes a policies-gated Cucumber feature covering the receiver
end-to-end

---

## 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.
2026-07-22 12:33:53 +00:00
Anthony Stirling 7e76097ac1 Make processor UI mobile friendly BASIC BASIC impl (#7126)
# Description of Changes

Very basic mobile impl, just makes side bar collapsable and minor other
changes "better than nothing"

---

## 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.
2026-07-22 11:10:53 +00:00
Anthony Stirling ce7f74a3c1 Add SaaS OG link-preview cards for app, processor and editor (#7027)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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.
2026-07-22 09:46:51 +00:00
ConnorYohandJames Brunton 50c0f2bcb5 Fix Calendly scheduler: blank on first open + slow load (#7075)
## What was wrong

Opening the procurement **Schedule a call** modal had two problems:

1. **Blank the first time, works the second time.** The first open
showed nothing; closing and reopening eventually loaded Calendly.
2. **Slow to load** even when it did work.

## Why

1. Our script loader treated a script as "ready" the moment its
`<script>` tag was added to the page — not when it had actually finished
downloading. On the first open, two loads overlap (React re-runs the
effect in dev), and the second one returned "ready" too early, before
Calendly's code existed, so nothing rendered. Reopening worked because
by then the script had finished.
2. Nothing was loaded until you clicked, so the first open waited on a
cold download of Calendly's script and then its booking page.

## The fix

- Make the script loader wait for the script to **actually finish
loading**, and have overlapping loads share the same wait. This fixes
the blank-first-open (and helps every other lazy-loaded script too).
- **Warm up Calendly early**: open the connection and start fetching its
script as soon as the "Schedule a call" button appears, so the modal
opens quickly instead of downloading everything on click.
- If Calendly still can't load (e.g. blocked by an extension), show the
existing "open in a new tab" link instead of an empty modal.

## Testing

Added a unit test proving the loader only reports "ready" after the
script truly loads. Type-check, lint, and formatting all pass.

Note: I couldn't click through the live modal here (needs a linked
procurement deal running locally) — happy to do a manual open/close/open
pass before merge if you'd like.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-07-22 09:23:15 +00:00
brios fab00f3fe2 style(sidebar): fix file sidebar button visibility spacing (#7124)
# Description of Changes

<img width="522" height="258" alt="image"
src="https://github.com/user-attachments/assets/f7310ae9-3bdb-460a-a761-d593d6daafe0"
/>


<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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.
2026-07-22 09:22:58 +00:00
dependabot[bot]andLudy b5b9cc443f build(deps): bump com.github.junrar:junrar from 7.5.10 to 7.6.0 in /app/common (#7090)
Bumps [com.github.junrar:junrar](https://github.com/junrar/junrar) from
7.5.10 to 7.6.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/junrar/junrar/releases">com.github.junrar:junrar's
releases</a>.</em></p>
<blockquote>
<h2>Release v7.6.0</h2>
<h2>Changelog</h2>
<h2>🚀 Features</h2>
<ul>
<li>support random access for files in solid RAR4 archives (<a
href="https://github.com/junrar/junrar/commits/e0874d2">e0874d2</a>)</li>
</ul>
<h2>🏎 Perf</h2>
<ul>
<li>replace RarCRC.checkCrc with java.util.zip.CRC32 (<a
href="https://github.com/junrar/junrar/commits/5270d23">5270d23</a>)</li>
</ul>
<h2>🛠  Build</h2>
<p><strong>deps</strong></p>
<ul>
<li>bump gradle-wrapper to 9.5.1 (<a
href="https://github.com/junrar/junrar/commits/cb4b7fd">cb4b7fd</a>)</li>
<li>bump com.fasterxml.jackson.core:jackson-databind (<a
href="https://github.com/junrar/junrar/commits/0bb56b3">0bb56b3</a>)</li>
<li>bump com.fasterxml.jackson.datatype:jackson-datatype-jsr310 (<a
href="https://github.com/junrar/junrar/commits/ca621b2">ca621b2</a>)</li>
<li>bump org.jreleaser from 1.23.0 to 1.24.0 (<a
href="https://github.com/junrar/junrar/commits/90f0548">90f0548</a>)</li>
<li>bump commons-io:commons-io from 2.21.0 to 2.22.0 (<a
href="https://github.com/junrar/junrar/commits/83a5d08">83a5d08</a>)</li>
<li>bump com.github.ben-manes.versions from 0.53.0 to 0.54.0 (<a
href="https://github.com/junrar/junrar/commits/d5abcdb">d5abcdb</a>)</li>
</ul>
<p><strong>unscoped</strong></p>
<ul>
<li>replace deprecated action (<a
href="https://github.com/junrar/junrar/commits/338efcb">338efcb</a>)</li>
</ul>
<h2>Contributors</h2>
<p>We'd like to thank the following people for their contributions:
Gauthier, Gauthier Roebroeck, Robin Schimpf</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/junrar/junrar/blob/master/CHANGELOG.md">com.github.junrar:junrar's
changelog</a>.</em></p>
<blockquote>
<h1><a
href="https://github.com/junrar/junrar/compare/v7.5.10...v7.6.0">7.6.0</a>
(2026-05-13)</h1>
<h2>🚀 Features</h2>
<ul>
<li>support random access for files in solid RAR4 archives (<a
href="https://github.com/junrar/junrar/commits/e0874d2">e0874d2</a>)</li>
</ul>
<h2>🏎 Perf</h2>
<ul>
<li>replace RarCRC.checkCrc with java.util.zip.CRC32 (<a
href="https://github.com/junrar/junrar/commits/5270d23">5270d23</a>)</li>
</ul>
<h2>🛠  Build</h2>
<p><strong>deps</strong></p>
<ul>
<li>bump gradle-wrapper to 9.5.1 (<a
href="https://github.com/junrar/junrar/commits/cb4b7fd">cb4b7fd</a>)</li>
<li>bump com.fasterxml.jackson.core:jackson-databind (<a
href="https://github.com/junrar/junrar/commits/0bb56b3">0bb56b3</a>)</li>
<li>bump com.fasterxml.jackson.datatype:jackson-datatype-jsr310 (<a
href="https://github.com/junrar/junrar/commits/ca621b2">ca621b2</a>)</li>
<li>bump org.jreleaser from 1.23.0 to 1.24.0 (<a
href="https://github.com/junrar/junrar/commits/90f0548">90f0548</a>)</li>
<li>bump commons-io:commons-io from 2.21.0 to 2.22.0 (<a
href="https://github.com/junrar/junrar/commits/83a5d08">83a5d08</a>)</li>
<li>bump com.github.ben-manes.versions from 0.53.0 to 0.54.0 (<a
href="https://github.com/junrar/junrar/commits/d5abcdb">d5abcdb</a>)</li>
</ul>
<p><strong>unscoped</strong></p>
<ul>
<li>replace deprecated action (<a
href="https://github.com/junrar/junrar/commits/338efcb">338efcb</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/junrar/junrar/commit/cb4b7fdb84269e95741fa22120e08ceb15a06152"><code>cb4b7fd</code></a>
build(deps): bump gradle-wrapper to 9.5.1</li>
<li><a
href="https://github.com/junrar/junrar/commit/0bb56b3df4e3b6d14f73de92746a2eb47dd2d7a3"><code>0bb56b3</code></a>
build(deps): bump com.fasterxml.jackson.core:jackson-databind</li>
<li><a
href="https://github.com/junrar/junrar/commit/ca621b22421f9e846df23112b32113230610c8d9"><code>ca621b2</code></a>
build(deps): bump
com.fasterxml.jackson.datatype:jackson-datatype-jsr310</li>
<li><a
href="https://github.com/junrar/junrar/commit/e0874d213832bbeaa8eb265c79479b01b5fa7392"><code>e0874d2</code></a>
feat: support random access for files in solid RAR4 archives</li>
<li><a
href="https://github.com/junrar/junrar/commit/90f0548c728b0bf2e94f6e26f9e6396d9d0a7262"><code>90f0548</code></a>
build(deps): bump org.jreleaser from 1.23.0 to 1.24.0</li>
<li><a
href="https://github.com/junrar/junrar/commit/83a5d085dc84991226229fef1515cc902d129241"><code>83a5d08</code></a>
build(deps): bump commons-io:commons-io from 2.21.0 to 2.22.0</li>
<li><a
href="https://github.com/junrar/junrar/commit/338efcb5472be051989fbafd7179d561c71722eb"><code>338efcb</code></a>
ci: replace deprecated action</li>
<li><a
href="https://github.com/junrar/junrar/commit/5270d235ade54d96dfc9958ab06f495fbbd169e7"><code>5270d23</code></a>
perf: replace RarCRC.checkCrc with java.util.zip.CRC32</li>
<li><a
href="https://github.com/junrar/junrar/commit/d5abcdb9af988ddfa76e98d80787e15525488332"><code>d5abcdb</code></a>
build(deps): bump com.github.ben-manes.versions from 0.53.0 to
0.54.0</li>
<li><a
href="https://github.com/junrar/junrar/commit/edadb28896962fdcf754029bafd0994aaff24530"><code>edadb28</code></a>
chore(release): 7.5.10 [skip ci]</li>
<li>See full diff in <a
href="https://github.com/junrar/junrar/compare/v7.5.10...v7.6.0">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-07-22 07:50:24 +00:00
Ludy a4ac034a18 feat(build): centralize Java toolchain language version configuration (#6894)
# Description of Changes

This change centralizes the Java toolchain language version into a
single `buildJavaLanguageVersion` variable and reuses it across all Java
compilation tasks to ensure consistent toolchain selection.

### What was changed

- Introduced a shared `buildJavaLanguageVersion` variable derived from
the optional `javaVersion` project property, defaulting to Java 25.
- Updated the root project's Java toolchain configuration to use the
shared variable.
- Updated all subproject Java toolchain configurations to reference the
same shared variable instead of a hardcoded language version.
- Explicitly configured the `compileRestartHelper` task to use a
`javaCompiler` resolved from the same shared toolchain version.

### Why the change was made

- Eliminate duplicated Java language version definitions.
- Ensure all compilation tasks use the same Java toolchain
configuration.
- Allow the `javaVersion` project property to consistently affect the
root project, subprojects, and the restart helper compilation task.
- Simplify future Java version upgrades by requiring changes in only one
location.

---

## 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.
2026-07-22 07:10:19 +00:00
brios e24a30828b refactor(api): replace deprecated APIs with their modern equivalents (#6434)
# Description of Changes


This PR resolves deprecation warnings and addresses compiler errors
resulting from the transition to Spring Security 7.x., as well Jackson 3
and general Java.

* Replaced all usages of `asText()`/`isTextual()` with
`asString()`/`isString()` in JSON parsing logic across
`FormPayloadParser.java`, `ApiEndpoint.java`, and
`KeygenLicenseVerifier.java` to ensure consistent and type-safe string
* Updated `CustomSaml2AuthenticatedPrincipal` to implement
`Saml2ResponseAssertionAccessor`, added a `responseValue` field, and
provided additional getter methods and type-safe attribute accessors.
* Switched from constructing `URL` objects directly from strings to
using `URI.create(...).toURL()` in `UIDataTessdataController.java` for
improved URL safety and parsing.

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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
- [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.
2026-07-21 18:49:59 +00:00
briosandAnthony Stirling e2ea720fc8 refactor(api): replace regex literals with compiled patterns for improved performance and readability (#6511)
# Description of Changes


This pull request refactors several utility classes and controllers to
replace inline regular expression usage with precompiled `Pattern`
constants. This change improves performance, consistency, and
maintainability by ensuring that regex patterns are compiled only once
and reused throughout the codebase. Additionally, it enhances code
clarity and security in filename and SQL content sanitization.

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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 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.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-07-21 17:41:44 +00:00
brios a4ffdc7831 fix(viewer): dynamic page number input width based on total page count (#6607)
# Description of Changes


### Before:

<img width="3092" height="468" alt="image"
src="https://github.com/user-attachments/assets/8ed16ac3-9547-4f07-937e-4177c4ed16fd"
/>


### After: 

<img width="1868" height="490" alt="image"
src="https://github.com/user-attachments/assets/8ddf2227-bac4-49f6-973a-90c9f4667dfe"
/>


### Mobile (after):

<img width="842" height="444" alt="image"
src="https://github.com/user-attachments/assets/e601fa94-45a6-46ae-b432-550bb27a98a6"
/>



<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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.
2026-07-21 17:40:16 +00:00
Anthony Stirling 621731bda1 Validate RFC 3161 document timestamps and expose timestamping (#7095)
# Description of Changes

Fixes timestamp issue and adds timestamp to the signing/security policiy
---

## 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.
2026-07-21 17:37:54 +00:00
James Brunton 5e3e89ccb2 Fix existing teams logic (#7070)
# Description of Changes
Paired with https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/320

Fixes the following bugs we found when testing the SaaS release:
- Existing users couldn't join teams - this was because they were the
last leader of their team, so it'd be left orphaned). Users now have a
'home team', which can have no members if they join another team, but
they can then go back to it later.
- Existing leaders didn't have unlimited seats - `saas_teams_extensions`
had no row for them, so the app fell back to `max_seats=1`. The
migration script fixes it.
- Members without Processor access could still access the Processor - It
was just checking "Are you the leader of **any** team", instead of the
user's active team.
2026-07-21 13:57:45 +00:00
ConnorYohandJames Brunton f60a75c253 chore(saas): remove unused Flyway migration system (#7100)
## What this PR does

Removes the **Flyway** migration system from the SaaS build:

- drops `flyway-core` + `flyway-database-postgresql` from
`app/saas/build.gradle`
- deletes all `Vxx__*.sql` files under
`app/saas/src/main/resources/db/migration/`
- removes the `spring.flyway.*` config from
`application-saas.properties`
- clears the now-inert `SPRING_FLYWAY_ENABLED` override and stale Flyway
comments in `testing/compose/docker-compose-saas.yml`

`ddl-auto` is **left on `update`** (unchanged) — that's a separate
decision (see "Not in this PR").

## Why — Flyway never actually ran anywhere

From a full schema-management review
(`notes/FLYWAY_MIGRATION_REVIEW.md`), verified against the live
databases:

- **Prod & dev (v3):** no `flyway_schema_history` table exists in any
schema → Flyway has never executed. Schema is authored by the Supabase
migrations in `Stirling-PDF-SaaS` and applied by that repo's **GitHub
integration** (merge to `main` → prod).
- **Tests:** the saas module has zero `@SpringBootTest`; the only
real-DB integration tests (in `proprietary`) use `ddl-auto=create-drop`
and don't have Flyway on the classpath.
- **The mock-DB harness** (`testing/compose/docker-compose-saas.yml`,
PAYG cucumber) *explicitly disabled* Flyway, because the `Vxx`
migrations can't run against a clean Postgres — they assume Supabase has
already provisioned `users`/`teams` (`V2` ALTERs `users`, `V5`
references `teams`).

So Flyway was dead weight, and its 4 duplicate versions
(`V25/V26/V30/V31`) were a latent trap: re-enabling it would crash boot
on the collision. Everything it contained (schema + seeds like the
default pricing policy) is already mirrored by the Supabase migrations,
and by `saas-seed.sql` for the cucumber stack.

## ⚠️ Required follow-up (item #1) — capture the Flyway-only tables into
Supabase migrations

**This is documentation of the next step, not done in this PR.**

Seven tables were defined in Flyway with **no matching Supabase
migration**. They exist in prod today only because `ddl-auto=update`
created them from their entities. Before `ddl-auto` is ever tightened to
`validate` (see #3 below), and so any fresh Supabase branch is complete,
they must be added as Supabase migrations in
`Stirling-PDF-SaaS/supabase/migrations/`.

**Capture (CREATE) — 6 live tables** (definitions are visible in the
deleted files in this PR's diff):

| Table | Source (deleted here) | Backing entity |
|---|---|---|
| `resource_grants` | `V25__resource_grants.sql` | `ResourceGrant` |
| `integration_configs` | `V26__integration_configs.sql` |
`IntegrationConfig` |
| `policy_sources` | `V22__policy_engine_tables.sql` | `SourceEntity` |
| `policy_source_doc_counts` | `V23__policy_source_doc_counts.sql` |
`SourceDocCountEntity` |
| `policy_source_doc_totals` | `V23__policy_source_doc_counts.sql` |
`SourceDocTotalEntity` |
| `saas_user_extensions` | `V9__saas_user_team_extensions.sql` |
`SaasUserExtensions` |

Write them as `CREATE TABLE IF NOT EXISTS stirling_pdf.<name> (...)`
(idempotent — no-op against the existing prod/v3 tables). Preserve
column types/defaults/constraints from the deleted `Vxx` files.

**Drop (do NOT recreate) — 1 orphaned table:**

- `classification_labels` — created by `V30` and dropped by `V39` within
Flyway; its `ClassificationLabel` is now a plain `record`, not a JPA
entity. It lingers in prod only because Flyway's `V39` drop never ran.
The follow-up should emit a `DROP TABLE IF EXISTS
stirling_pdf.classification_labels` (mirroring `V39`'s intent), after
confirming nothing reads it.

## Not in this PR (deliberately)

- **#3 — flip saas `ddl-auto` `update` → `validate`.** Held pending team
confirmation; it has boot-risk and should be gated by a CI
"validate-boot against a fresh Supabase branch" first. Self-hosted stays
on `update` regardless.
- **#4 — `billing_subscriptions` split-brain** (prod `public` has 23,164
rows, `stirling_pdf` has 0, Java reads the empty one). Tracked
separately.

## Verification

- `:saas:compileJava` succeeds with Flyway removed (no code imports
`org.flywaydb.*`).
- No test or ArchUnit rule references Flyway or the migration files.
- No runtime/data impact: Flyway never ran against any live database.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-07-21 09:32:56 +00:00
Ludy 401894a065 build(deps): bump gradle/actions/setup-gradle from 6.1.0 to 6.2.0 (#7116)
# Description of Changes

Replies PR: #7113, which ensures that the build tests are executed
correctly.

---

## 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.
2026-07-21 08:24:31 +00:00
dependabot[bot] 15423c6479 build(deps): bump com.diffplug.spotless from 8.5.0 to 8.8.0 (#7089)
Bumps com.diffplug.spotless from 8.5.0 to 8.8.0.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=com.diffplug.spotless&package-manager=gradle&previous-version=8.5.0&new-version=8.8.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)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

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 <dependency name> 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)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 08:24:23 +00:00
albanobattistella f4560cccb9 Update Italian translation (#7087) 2026-07-21 08:17:31 +00:00
dependabot[bot] 43a6ff3066 build(deps): bump mcp from 1.26.0 to 1.28.1 in /engine in the uv group across 1 directory (#7114)
Bumps the uv group with 1 update in the /engine directory:
[mcp](https://github.com/modelcontextprotocol/python-sdk).

Updates `mcp` from 1.26.0 to 1.28.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/modelcontextprotocol/python-sdk/releases">mcp's
releases</a>.</em></p>
<blockquote>
<h2>v1.28.0</h2>
<h2>Deprecations</h2>
<p>Two API surfaces now emit <code>DeprecationWarning</code> ahead of
their removal in v2. Nothing is removed in 1.x, and the warnings fire
only when the deprecated API is <em>called</em> - importing the modules
stays silent.</p>
<ul>
<li><strong>WebSocket transport</strong> -
<code>mcp.client.websocket.websocket_client</code> and
<code>mcp.server.websocket.websocket_server</code><code>modelcontextprotocol/typescript-sdk#1783</code></li>
<li><strong>Experimental tasks API</strong> -
<code>ClientSession.experimental</code>,
<code>Server.experimental</code>,
<code>ServerSession.experimental</code>, and the
<code>experimental_task_handlers=</code> kwarg on
<code>ClientSession</code>. Tasks (SEP-1686) were removed from the MCP
specification and are expected to return as a separate MCP
extension.</li>
</ul>
<p>If your test suite runs with <code>filterwarnings =
[&quot;error&quot;]</code> and exercises these paths, add a scoped
ignore such as <code>ignore:The experimental tasks API is
deprecated:DeprecationWarning</code> or <code>ignore:The WebSocket .*
transport is deprecated:DeprecationWarning</code>.</p>
<p>See <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/2828">#2828</a>
for full details.</p>
<h2>What's Changed</h2>
<ul>
<li>[v1.x] Support Python 3.14 by <a
href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2769">modelcontextprotocol/python-sdk#2769</a></li>
<li>fix: omit null optional fields from task result payloads by <a
href="https://github.com/liuzemei"><code>@​liuzemei</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2809">modelcontextprotocol/python-sdk#2809</a></li>
<li>[v1.x] Deprecate the WebSocket transport and the experimental tasks
entry points by <a
href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2828">modelcontextprotocol/python-sdk#2828</a></li>
<li>[v1.x] Add a v2 status banner to the README by <a
href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2835">modelcontextprotocol/python-sdk#2835</a></li>
<li>[v1.x] Deflake the child process cleanup tests by <a
href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2839">modelcontextprotocol/python-sdk#2839</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/liuzemei"><code>@​liuzemei</code></a>
made their first contribution in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2809">modelcontextprotocol/python-sdk#2809</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/modelcontextprotocol/python-sdk/compare/v1.27.2...v1.28.0">https://github.com/modelcontextprotocol/python-sdk/compare/v1.27.2...v1.28.0</a></p>
<h2>v1.27.2</h2>
<h2>What's Changed</h2>
<ul>
<li>[v1.x] ci: deploy docs to py.sdk.modelcontextprotocol.io via Pages
artifact by <a
href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2635">modelcontextprotocol/python-sdk#2635</a></li>
<li>[v1.x] Add subject and claims to AccessToken by <a
href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2690">modelcontextprotocol/python-sdk#2690</a></li>
<li>[v1.x] Bind transport sessions to the authenticated principal by <a
href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2719">modelcontextprotocol/python-sdk#2719</a></li>
<li>[v1.x] Scope experimental tasks to the session that created them by
<a href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2720">modelcontextprotocol/python-sdk#2720</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/modelcontextprotocol/python-sdk/compare/v1.27.1...v1.27.2">https://github.com/modelcontextprotocol/python-sdk/compare/v1.27.1...v1.27.2</a></p>
<h2>v1.27.1</h2>
<h2>What's Changed</h2>
<ul>
<li>[v1.x] fix: catch PydanticUserError when generating output schema
(pydantic 2.13 compat) by <a
href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2435">modelcontextprotocol/python-sdk#2435</a></li>
<li>[v1.x] fix(auth): coerce empty-string optional URL fields to None in
OAuthClientMetadata by <a
href="https://github.com/felixweinberger"><code>@​felixweinberger</code></a>
in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2405">modelcontextprotocol/python-sdk#2405</a></li>
<li>[v1.x] build: restrict httpx to &lt;1.0.0 by <a
href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2559">modelcontextprotocol/python-sdk#2559</a></li>
<li>[v1.x] refactor: import SSEError from httpx_sse public API by <a
href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2561">modelcontextprotocol/python-sdk#2561</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/modelcontextprotocol/python-sdk/compare/v1.27.0...v1.27.1">https://github.com/modelcontextprotocol/python-sdk/compare/v1.27.0...v1.27.1</a></p>
<h2>v1.27.0</h2>
<h2>What's Changed</h2>
<ul>
<li>fix: remove unused <code>requests</code> dependency from
simple-chatbot example by <a
href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/1959">modelcontextprotocol/python-sdk#1959</a></li>
<li>ci: backport conformance tests from main to v1.x by <a
href="https://github.com/felixweinberger"><code>@​felixweinberger</code></a>
in <a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/pull/2068">modelcontextprotocol/python-sdk#2068</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/777b8d06710c140e3606b0d4598e2aa48546c266"><code>777b8d0</code></a>
[v1.x] Support TransportSecuritySettings in the WebSocket server
transport (#...</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/47204674fb26185c2cf45f065831f27b8e5d5c65"><code>4720467</code></a>
[v1.x] Set Development Status classifier to Production/Stable (<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/2976">#2976</a>)</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/6df3d734265eb49bf758a5e5eb420937337184e9"><code>6df3d73</code></a>
[v1.x] Buffer per-request StreamableHTTP streams; store priming event
before ...</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/32d32908feb7b15eddeb46872774bf95869cc5f0"><code>32d3290</code></a>
[v1.x] Pass a list to parametrize in test_docs_examples (pytest 9.1.0
compat)...</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/0dca751056dc87d04893d53af129fd00b56a18da"><code>0dca751</code></a>
[v1.x] Deflake the child process cleanup tests (<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/2839">#2839</a>)</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/52258a95645c66fccbe925289c3382712b9bc68a"><code>52258a9</code></a>
[v1.x] Add a v2 status banner to the README (<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/2835">#2835</a>)</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/b8f491724c45dbf89d6569364b58d6e8d25d7e42"><code>b8f4917</code></a>
[v1.x] Deprecate the WebSocket transport and the experimental tasks
entry poi...</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/2309e5ef974062748e0268c396eba73cfdb6f5e3"><code>2309e5e</code></a>
fix: omit null optional fields from task result payloads (<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/2809">#2809</a>)</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/494eb11d36b4238226cc0551da6015e1c73f7f3b"><code>494eb11</code></a>
[v1.x] Support Python 3.14 (<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/2769">#2769</a>)</li>
<li><a
href="https://github.com/modelcontextprotocol/python-sdk/commit/62137874ff26dd74d2fea80ff528a7fd9ca7a5e7"><code>6213787</code></a>
[v1.x] Scope experimental tasks to the session that created them (<a
href="https://redirect.github.com/modelcontextprotocol/python-sdk/issues/2720">#2720</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/modelcontextprotocol/python-sdk/compare/v1.26.0...v1.28.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=mcp&package-manager=uv&previous-version=1.26.0&new-version=1.28.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)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

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 <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> 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 <dependency name> 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 <dependency name>` 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 <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` 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).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 08:15:15 +00:00
Ludy 14def378bf chore(frontend): update frontend dependencies to latest compatible versions (#6860)
# Description of Changes

This change updates multiple frontend dependencies to their latest
compatible releases by refreshing the `package-lock.json`. The update
includes dependency version bumps across the frontend toolchain and
runtime libraries while removing obsolete transitive dependencies
introduced by newer package versions.

### What was changed

- Updated Babel packages to the latest 7.29.x releases.
- Upgraded Vite from 7.3.2 to 7.3.6.
- Upgraded Vitest packages from 3.2.4 to 3.2.6.
- Updated React Router and React Router DOM from 7.13.2 to 7.18.1.
- Updated Axios from 1.15.0 to 1.18.1.
- Updated PostHog packages to newer releases.
- Updated additional frontend dependencies including Preact, Web Vitals,
FormData, HasOwn, Brace Expansion, and other transitive packages.
- Removed obsolete OpenTelemetry and Protobuf-related transitive
dependencies that are no longer required by the updated dependency
graph.
- Refreshed the lockfile to reflect the new dependency tree.

### Why the change was made

- Keep frontend dependencies up to date.
- Incorporate upstream bug fixes, performance improvements, and security
updates.
- Reduce unnecessary transitive dependencies where newer package
versions no longer require them.
- Maintain compatibility with the current frontend toolchain.

---

## 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.
2026-07-21 07:05:25 +00:00
dependabot[bot] 49cc3f88f9 build(deps): bump ubuntu from c4a8d55 to 4fbb8e6 in /docker/base (#6554)
Bumps ubuntu from `c4a8d55` to `4fbb8e6`.


> **Note**
> Automatic rebases have been disabled on this pull request as it has
been open for over 30 days.

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 21:05:10 +00:00
Anthony Stirling 79dc7d5615 Multi node cluster fixes (#7025)
- Exclude DataRedisRepositoriesAutoConfiguration (cluster crash-loop
fix)
- Share JWT signing keys via the DB + require a shared credential key in
cluster mode
- Make policy run status/listing visible across nodes


---

## 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.
2026-07-20 20:51:30 +00:00
EthanHealy01 bda9cebc5c Portal ProcessorFlow: proportional particle emission + Storybook playground (#7105)
## Summary
Follow-up polish to the home **PDF Processor** flow visualiser (base
landed in #7014). Tunes the particle animation to react to real volume
and adds a Storybook playground to tune it live.

## Changes
### Particle emission — `useFlowParticles.ts`, `flowTypes.ts`
- Emission rate now scales ~linearly with a source's 24h volume (**2×
volume ≈ 2× dots**) instead of the flat `rate / 86400 × SPEED`, capped
at **one dot / 250ms** (`MAX_EMIT_PER_SEC`) for busy sources (~≥
800/24h).
- Bounded the spread so the busiest source emits at most **5×** the
quietest (`EMIT_SPREAD_CAP`) — a dominant source can't starve the
others.
- Wider departure jitter (`[0.4×–1.7×]` the mean, still floored at the
per-source min-gap) and ~**2× faster travel** so the flow reads
livelier.
- Replaced the single `SPEED` constant with `EMIT_DIVISOR` /
`MAX_EMIT_PER_SEC` / `EMIT_SPREAD_CAP`. Weighted round-robin outcome
split is unchanged (e.g. 3 failed / 30 delivered → ~1 red dot in 11).

### Storybook Playground — `ProcessorFlow.stories.tsx`,
`ProcessorFlow.tsx`
- New **Playground** story with live controls: per-input rate sliders,
the delivered/failed split (drives the red-dot ratio), and a
Classification-active toggle.
- Added an optional `dataOverride` prop (prod-inert testing seam) so the
story renders a supplied flow model instead of fetching — changes apply
instantly.

### Housekeeping
- Condensed authored comments across the feature to ≤ 2 lines.

## Testing
- `task frontend:check` green — lint, typecheck, 1353 tests.
- Verified emission numerically (proportionality, 250ms ceiling, 5×
spread cap) and confirmed live animation in a focused Storybook tab.
2026-07-20 15:29:46 +00:00
James BruntonandEthanHealy01 0fc2958daa Add explicit length to member column to avoid ddl resizing (#7109)
# Description of Changes
There's currently a column size inconsistency between the SaaS v3 DB and
the main Java code which causes the backend to fail to start up when
connected to a fresh DB. This is because the column previously was width
255, but now it's officially width 50, but the Java type is still
implicitly `varchar(255)` because there's no length attribute. If it's a
fresh DB, Postgres throws an error that it can't expand the column (this
doesn't error on an existing DB because the column is already wide
enough behind the scenes).

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-07-20 15:23:11 +00:00
ConnorYoh c19d56de22 PAYG usage card: review follow-ups (avg/PDF, empty-state, unique wording) (#6967)
Small follow-up to #6957 addressing the three non-blocker findings from
its review. **Draft / stacked on #6957** — the diff shows #6957's
changes until it merges, then auto-narrows to just these 5 files. Mark
ready + rebase onto `main` once #6957 lands.

### 1. avg-per-PDF no longer blends unsynced units over synced-only docs
`avgCostMinor` now divides **synced** units (`spendUnitsThisPeriod`) by
synced docs, so numerator and denominator cover the same population.
Combined-billing `pendingUnits` (units-only, no doc count) previously
inflated the average for linked-instance teams. The "meter units" figure
still shows synced+pending (total current usage) — only the *average* is
synced-only.

### 2. Empty-state: unsynced-only reads cleanly
When `docs == 0` but there are pending meter units (combined-billing,
nothing synced yet), the card showed a bare **"0 PDFs"** headline with a
count-less summary and no split. It now shows a **"{n} meter units
pending sync from linked instances"** note instead. New `unitsPending`
i18n key + a `UnsyncedOnly` story. (Only reachable on the
combined-billing path; pure-SaaS teams are unaffected.)

### 3. uniquePdfs wording is now accurate
`document_fingerprint` is a hash of a charge's whole **input set**, so
the same file reused across *different* groupings (standalone, then
later in a merge `{A,B}`) counts per grouping — a close approximation of
"unique PDFs", exact for the single-input common case. Softened the FE
type doc + the `WalletLedgerEntry.document_fingerprint` javadoc to say
so (no behaviour change; counting model unchanged).

### Verification
FE typecheck / test / lint / format all clean; `:saas:compileJava`
green. No behaviour change beyond #1 (avg) and #2 (empty-state copy); #3
is doc-only.
2026-07-20 13:51:55 +00:00
Ludy 1954d20910 refactor(frontend): decouple signature status logic from PDF color palette (#6992)
# Description of Changes

- Moved the signature status-to-color mapping from `signatureStatus.ts`
to `pdfPalette.ts`.
- Removed the PDF palette dependency from the pure signature status
calculation module.
- Updated the PDF signature report to import the color mapping from the
palette module.
- Prevented signature status unit tests from initializing
browser-dependent CSS colors unnecessarily.
- Eliminated fallback warnings caused by unavailable theme CSS variables
in the Vitest environment.
- Preserved the existing signature status calculation and PDF report
color behavior.

```sh
[frontend:test:editor] stderr | src/core/hooks/tools/validateSignature/utils/signatureStatus.test.ts
[frontend:test:editor] CSS variable --pdf-light-header-bg not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-accent not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-text-primary not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-text-muted not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-box-bg not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-box-border not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-warning not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-danger not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-success not found, using fallback
[frontend:test:editor] CSS variable --pdf-light-neutral not found, using fallback
```

---

## 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.
2026-07-20 12:11:51 +00:00
Ludy b6b5077467 deps: update vulnerable dependencies to address GitHub Security Advisories (#7064)
# Description of Changes

This pull request updates project dependencies to versions that resolve
the following GitHub Security Advisories:

- GHSA-wf93-45jw-7689
- GHSA-58qw-9mgm-455v
- GHSA-jp4c-xjxw-mgf9

### What was changed

- Updated affected dependencies to patched versions.
- Removed known vulnerable dependency versions reported by GitHub
Security Advisories.
- Ensured dependency versions remain compatible with the existing
project configuration.

---

## 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.
2026-07-20 12:10:38 +00:00
Ludy a63ab2db7b Add PR conflict labeler workflow (#7061)
### Motivation
- Automatically detect and mark pull requests that have merge conflicts
so maintainers can triage them quickly.
- Ensure both new and existing open PRs are covered by running on PR
events, a schedule, and manual dispatch.

### Description
- Add workflow `: .github/workflows/pr-conflict-labeler.yml` that
triggers on `pull_request_target`, a recurring `schedule`, and
`workflow_dispatch` for manual runs.
- The job uses the repository `stirling-bot`
(`.github/actions/setup-bot`) and `actions/github-script` to poll
`pull.mergeable` until GitHub computes mergeability and then add or
remove the `has conflicts` label when `mergeable === false &&
mergeable_state === 'dirty'`.
- The workflow idempotently ensures the `has conflicts` label exists
(creates it if missing) and the repository label config `
.github/labels.yml` is updated to include `has conflicts` with an
appropriate color and description.

### Testing
- Parsed both ` .github/workflows/pr-conflict-labeler.yml` and `
.github/labels.yml` with Ruby `YAML.load_file`, which succeeded.
- Installed and ran `actionlint` via `go install
github.com/rhysd/actionlint/cmd/actionlint@latest` and validated the new
workflow file with `actionlint`, which succeeded.

------
[Codex
Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a58b7061c2c8325b024980a4af8f632)
2026-07-20 12:10:07 +00:00
Ludy 1ba9092999 chore: upgrade Gradle to 9.6.1 across CI, Docker, and wrapper (#6892)
# Description of Changes

This change upgrades the project from Gradle **9.6.0** to **9.6.1**
across all build environments to keep the toolchain consistent and
aligned.

### What was changed

- Updated the Gradle Wrapper to **9.6.1**.
- Updated all GitHub Actions workflows using
`gradle/actions/setup-gradle` to install Gradle **9.6.1**.
- Updated all Docker build stages to use the `gradle:9.6.1-jdk25` image
with the corresponding pinned image digest.
- Regenerated the Windows Gradle wrapper script, resulting in minor
comment updates (`Gradle` → `gradlew`).

### Why the change was made

- Keep the project up to date with the latest Gradle patch release.
- Ensure all local, CI, and Docker build environments use the same
Gradle version.
- Benefit from the latest bug fixes and maintenance improvements
included in Gradle 9.6.1.

---

## 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.
2026-07-20 12:08:27 +00:00
LudyandAnthony Stirling 99887f77a6 deps(frontend): upgrade @embedpdf packages to v2.14.4 (#6989)
# Description of Changes

- Updated all `@embedpdf/*` packages from earlier releases to `v2.14.4`.
- Upgraded `@embedpdf/engines` from `2.8.0` to `2.14.4`.
- Upgraded `@embedpdf/plugin-selection` from `2.8.0` to `2.14.4`.
- Aligned every EmbedPDF package to the same version to avoid version
mismatches.

---

## 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.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-07-20 12:07:51 +00:00
Ludy 1b3e757686 build(docker): use Node.js 22 for embedded frontend builds (#7076)
# Description of Changes

Updated the NodeSource setup from Node.js 20 to Node.js 22 in all
embedded frontend Docker build variants:

- Standard image
- Fat image
- Ultra-lite image

This aligns the Docker builds with the Node.js version used by the
frontend CI workflows and satisfies the `node >=22` engine requirement
of `rollup-plugin-visualizer@7.0.1`
https://github.com/btd/rollup-plugin-visualizer/commit/2caae97a061c752b9ec0fc5b820d10f0777b4c01
. It removes the `EBADENGINE` warning emitted during `frontend:install`
in the Docker Compose test workflow.

---

## 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.
2026-07-20 12:06:23 +00:00
LudyandCopilot bb830e5711 build: upgrade google-java-format and restore strict Spotless validation (#7091)
# Description of Changes

- Upgraded google-java-format from 1.28.0 to 1.35.0.
- Removed the broad `suppressLintsFor` workaround for the
`google-java-format` step.
- Ensured the shared `gradle/spotless.gradle` configuration is
recognized by the relevant CI path filters and repository automation.
- Kept the shared formatter configuration available to all backend
modules.
- Verified that google-java-format 1.35.0 runs successfully on JDK 25
for the Common, Core, and SaaS modules.
- Confirmed that the previous claim about a general Guava 32.x crash on
JDK 24/25 no longer justifies suppressing all formatter lint failures.

### Verification

Verified with Temurin JDK 25.0.3 and google-java-format 1.35.0. The
formatter still depends on Guava 32.1.3-jre, and no `suppressLintsFor`
configuration is present.

```bash
./gradlew \
  :common:spotlessJavaCheck \
  :stirling-pdf:spotlessJavaCheck \
  --rerun-tasks
```

Result:

```text
> Task :common:spotlessJava
> Task :common:spotlessJavaCheck
> Task :stirling-pdf:spotlessJava
> Task :stirling-pdf:spotlessJavaCheck

BUILD SUCCESSFUL in 26s
4 actionable tasks: 4 executed
```

Using `--rerun-tasks` ensured that the formatter was executed and that
the result did not come from the Gradle task cache.

---

## 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.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-20 12:05:53 +00:00
Ludy f4d760b139 fix(sign): preserve PNG signature placement and page content (#7093)
# Description of Changes

This PR fixes PNG signature application issues in the PDF signing
workflow.

## What was changed

- Reworked signature application to create locked and printable PDFium
stamp annotations with dedicated appearance streams.
- Removed the use of `FPDFPage_GenerateContent()` from the signature
workflow.
- Preserved the signature's original position and dimensions when
converting from the viewer's top-left coordinate system to PDF
coordinates.
- Added CropBox-aware coordinate conversion for PDFs whose visible page
origin differs from the MediaBox origin.
- Improved signature image extraction to handle internal EmbedPDF asset
references and nested image data.
- Refactored PDFium bitmap creation so image objects can safely be
transferred to annotations.
- Corrected PDFium bitmap ownership and cleanup to prevent duplicate
destruction.
- Added a PDFium WASM integration test covering:
  - Existing page-content preservation
  - Stamp appearance generation
  - Signature coordinates and dimensions
  - Printable, read-only, and locked annotation flags
- Persisted image data taking precedence over internal asset references

## Why the change was made

Applying a PNG signature previously regenerated the complete page
content through PDFium. This could corrupt existing vector or font-based
page elements, including the university logo reported in the linked
issue.

The previous coordinate conversion also relied only on the page height
and did not account for CropBox offsets, allowing the applied signature
to move from its preview position.

Creating a PDFium stamp annotation with its own appearance stream avoids
regenerating existing page content while retaining the selected
signature position and size.


Closes #7083

---

## 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.
2026-07-20 12:05:01 +00:00
Ludy 4cfe29139a chore(labels): add missing label metadata and remove duplicate entry (#7096)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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.
2026-07-20 12:04:35 +00:00
Ludy 0be0c0839b build(docker): pin container base images by digest for reproducible builds (#6797)
# Description of Changes

### What was changed

- Updated `BASE_VERSION` references in:
  - `docker/backend/Dockerfile`
  - `docker/embedded/Dockerfile`
  - `docker/embedded/Dockerfile.fat`
- Pinned `stirlingtools/stirling-pdf-base:1.0.2` to a specific SHA256
digest.
- Pinned the `eclipse-temurin:25-jre-noble` image used in the
`jar-extract` stage to a specific SHA256 digest.
- Pinned the `ghcr.io/astral-sh/uv:python3.13-bookworm-slim` image in
`engine/Dockerfile.dev` to a specific SHA256 digest.
- Removed reliance on mutable image tags alone for these build stages.

### Why the change was made

- Ensure deterministic and reproducible Docker builds.
- Prevent unexpected changes caused by upstream image tag updates.
- Improve supply chain integrity by explicitly defining the exact image
artifacts used during builds.
- Align container build practices with security and compliance
recommendations.

---

## 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.
2026-07-20 12:02:16 +00:00
Ludy c6e84a2124 fix(admin-settings): correctly mask Telegram bot tokens in settings output (#6822)
# Description of Changes

- What was changed
- Fixed the sensitive-field detection logic in `AdminSettingsController`
so `botToken` is matched correctly after lowercasing the field name.
- This ensures Telegram bot tokens are masked consistently in admin
settings responses.
- Why the change was made
- The previous check used `lowerField.contains("botToken")`, which could
never match after converting the field name to lowercase.
- As a result, `botToken` values could remain visible in masked settings
output.

---

## 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.
2026-07-20 12:01:59 +00:00
Ludy 72e8ba2951 fix(admin-settings): prevent hydration error in the Admin General section (#6823)
# Description of Changes

- What was changed
- Fixed an invalid HTML nesting issue in `AdminGeneralSection` by
changing the affected Mantine `Text` wrapper from the default `<p>`
element to `component="div"`.
- This prevents a `<div>` from being rendered inside a `<p>` when the
`Group` for the "Logo Style" label is displayed.
- Why the change was made
- Firefox reported a hydration error in the settings modal because the
rendered DOM was invalid.
- The warning was triggered in the Admin General settings section and
affected the settings modal experience.


Firefox 152.0.3 (64-Bit)

```
In HTML, <div> cannot be a descendant of <p>.
This will cause a hydration error.

  ...
    <AdminGeneralSection>
      <div className="settings-s...">
        <@mantine/core/Stack gap="lg" className="settings-s...">
          <@mantine/core/Box ref={null} className="settings-s..." style={{...}} variant={undefined}>
            <div ref={null} style={{...}} className="settings-s..." data-variant={undefined} data-size={undefined} ...>
              <LoginRequiredBanner>
              <div>
              <@mantine/core/Paper withBorder={true} p="md" radius="md">
                <@mantine/core/Box ref={null} mod={[...]} className="m_1b7284a3..." style={{...}} variant={undefined} ...>
                  <div ref={null} style={{...}} className="m_1b7284a3..." data-variant={undefined} data-size={undefined} ...>
                    <@mantine/core/Stack gap="md">
                      <@mantine/core/Box ref={null} className="m_6d731127..." style={{...}} variant={undefined}>
                        <div ref={null} style={{...}} className="m_6d731127..." data-variant={undefined} ...>
                          <@mantine/core/Text>
                          <div>
                          <div>
                            <@mantine/core/Text size="sm" fw={500} mb={4}>
                              <@mantine/core/Box className="mantine-fo..." style={{...}} ref={null} component="p" ...>
>                               <p
>                                 ref={null}
>                                 style={{--text-fz:"var(--mant...",--text-lh:"var(--mant...",marginBottom:"calc(0.25r...", ...}}
>                                 className="mantine-focus-auto m_b6d8b162 mantine-Text-root"
>                                 data-variant={undefined}
>                                 data-size="sm"
>                                 size={undefined}
>                               >
                                  <@mantine/core/Group gap="xs">
                                    <@mantine/core/Box className="m_4081bf90..." style={{...}} ref={null} ...>
>                                     <div
>                                       ref={null}
>                                       style={{--group-gap:"var(--mant...",--group-align:"center",--group-justify:"flex-start", ...}}
>                                       className="m_4081bf90 mantine-Group-root"
>                                       data-variant={undefined}
>                                       data-size={undefined}
>                                       size={undefined}
>                                     >
                            ...
                          ...
              ...
        ...
react-dom-client.development.js:2605:19
```


---

## 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.
2026-07-20 12:01:40 +00:00
Ludy 4230d2902b fix(editor): respect no-login settings visibility in config navigation (#6807)
# Description of Changes

This change fixes the editor configuration navigation for issue #6800 by
passing the `showSettingsWhenNoLogin` configuration flag through all
config navigation section hooks.

- Added `config?.showSettingsWhenNoLogin ?? true` when building the app
config modal navigation.
- Extended shared, desktop, and proprietary `useConfigNavSections`
signatures to accept the `showSettingsWhenNoLogin` flag.
- Forwarded the flag from desktop and proprietary config navigation
wrappers into the shared navigation logic.
- Updated proprietary admin section visibility so read-only admin
previews are only shown when login is disabled and
`system.showSettingsWhenNoLogin` allows it.

The change was made to ensure deployments with login disabled can still
control whether settings/admin configuration entries are visible,
matching the existing `showSettingsWhenNoLogin` behavior.

Closes #6800

<img width="1920" height="1032" alt="image"
src="https://github.com/user-attachments/assets/193f083b-3c87-4466-8e91-81a13a27cec3"
/>

---

## 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

- [x] 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)
- [x] 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.
2026-07-20 12:01:15 +00:00
Ludy 2c1a666889 fix(frontend): make development label detection cross-platform (#6988)
# Description of Changes

This PR makes the frontend development label detection compatible with
both Windows and Unix-based environments.

- Added platform-specific handling to the `STIRLING_DEV_LABEL` Taskfile
variable.
- Uses PowerShell and `Split-Path` on Windows.
- Retains the existing `basename` implementation on Linux and macOS.
- Preserves the fallback to the current working directory when the Git
repository root cannot be determined.
- Fixes `task frontend:dev` and `task dev:all` failing on Windows
because `basename` was unavailable.
- Handles shell quoting explicitly so PowerShell variables are not
expanded by the intermediate command shell.

---

## 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.
2026-07-20 11:59:55 +00:00
Ludy 47fe4a1d06 fix(licenses): fall back to license URL for backend dependency links (#7046)
# Description of Changes

This change improves link handling in the third-party licenses section.

- Added a shared `getModuleUrl` helper that returns `moduleUrl` when
available and falls back to `moduleLicenseUrl`.
- Updated backend license entries to render as clickable links when only
a license URL is provided.
- Preserved the existing link behavior, including opening URLs in a new
tab with appropriate security attributes.
- Fixed inconsistent rendering where some dependency names appeared as
plain text despite having an available license URL.

The change was made to ensure backend dependency entries consistently
provide a usable external link, even when dependency metadata does not
include a dedicated module URL.


before:
<img width="1920" height="1080" alt="image"
src="https://github.com/user-attachments/assets/aafee561-43e0-4924-923f-eb4ecf00c873"
/>

after:
<img width="1920" height="1080" alt="image"
src="https://github.com/user-attachments/assets/10a6f76a-6852-49af-a987-e0a864ec0b91"
/>


---

## 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.
2026-07-20 11:59:27 +00:00
Anthony Stirling b509fae0c8 Split processor sidebar into PDF Processor and PDF Platform sections (#7072) 2026-07-19 18:42:43 +01:00
Reece BrowneandJames Brunton 8b179fbc55 Support translations in Storybook: locale toolbar (#6843)
## What

Adds a globe toolbar to Storybook so any story can be previewed in all
42 supported languages (the i18n init already on `main` was
English-only).

## How

Bundles every locale's `translation.toml` via a `?raw` glob into i18next
resources, and switches language on toolbar change. RTL locales (`ar`,
`fa`) flip `document.dir`.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-07-17 15:01:58 +00:00
James Brunton ecddce9944 Remove agent builder from processor (#7063)
# Description of Changes
Remove agent builder from processor because we don't intend to implement
it any time soon.
2026-07-17 13:15:35 +00:00
Ludy 398f473316 Clarify portal SaaS licensing (#7074)
### Motivation
* The repository uses per-layer licensing and the
`frontend/editor/src/portal-saas/` layer existed without an explicit
license entry in the root `LICENSE`, so the layered licensing reference
needed to be added for clarity.

### Description
* Add a new `frontend/editor/src/portal-saas/LICENSE` containing the
same "Stirling PDF User License" used by adjacent non-MIT layers and
update the top-level `LICENSE` to list
`frontend/editor/src/portal-saas/` as covered by that file.

### Testing
* Verified with `diff -u frontend/editor/src/portal/LICENSE
frontend/editor/src/portal-saas/LICENSE` and `test -f
frontend/editor/src/portal-saas/LICENSE`, and attempted `task
frontend:check` which could not run in this environment because `task`
is not installed.

------
[Codex
Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a59f78f2f8483258cbe914b84126346)
2026-07-17 10:16:06 +00:00
Anthony Stirling bc96eed040 Portal Policies: table layout with colour-matched icons (#6994)
# Description of Changes

Reworks the Policies catalogue from blocky cards into a clean table with
tinted category icons and tone-matched "enforces" chips, so it matches
the styling used elsewhere in the portal.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<img width="2816" height="1318" alt="after-policies-light"
src="https://github.com/user-attachments/assets/af7586f7-8d23-47eb-ae2e-90630d09929b"
/>
<img width="2816" height="1438" alt="before-policies-dark"
src="https://github.com/user-attachments/assets/efcc63d9-5f2d-4dca-944d-d470aa689304"
/>
<img width="2816" height="1438" alt="before-policies-light"
src="https://github.com/user-attachments/assets/2398df15-0632-439f-9f23-0a6ddc496083"
/>
<img width="2816" height="1318" alt="after-policies-dark"
src="https://github.com/user-attachments/assets/9a3726ac-1637-4c7c-ad7a-dcdaf141d89d"
/>


---

## 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)

- [x] 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.
2026-07-17 07:07:09 +00:00
EthanHealy01 d271b8f357 Slim down login/signup to a single centered form (#7033)
Replaces the two-column carousel login/signup with a single clean
centered form, uniform across proprietary, saas, desktop, and the portal
(all now route through the shared single-column `AuthShell`). Adds a
Storybook playground under **Auth → Auth Screens** (provider /
login-method controls) to iterate on it.

## Before / After

<img width="1600" height="920" alt="image"
src="https://github.com/user-attachments/assets/6e3f166f-40df-468e-825a-bc4c7880406c"
/>

<img width="2056" height="1000" alt="Screenshot 2026-07-14 at 6 11
11 PM"
src="https://github.com/user-attachments/assets/e015efc9-71e5-4b9b-b64a-5ba1dbf2fcec"
/>
2026-07-16 23:02:49 +00:00
EthanHealy01 6b2ab5a743 The flow chart for policies running (#7014) 2026-07-16 15:32:08 +00:00
Anthony Stirling 275d70242e Portal Editor admin: replace glyph icons with SVGs (#6999)
# Description of Changes

Swaps the emoji and box glyphs in the editor deployment, pairing and
storage panels for tinted icons consistent with the rest of the portal.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.


---

## 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)

- [x] 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.
2026-07-16 14:01:11 +00:00
Ludy 1835e6fe6b Avoid uv cache reservation collisions in CI by adding per-workflow cache-suffix (#7060)
### Motivation
- Prevent concurrent GitHub Actions jobs from attempting to reserve the
same `setup-uv` cache key and failing with "Unable to reserve cache ...
another job may be creating this cache" when multiple workflows run at
once.
- Target workflows that enable `setup-uv` caching and have run
concurrently in CI: pre-commit, check-generated-models, ai-engine, and
sync_files_v2.

### Description
- Added a `cache-suffix` value to the `astral-sh/setup-uv` step in
`.github/workflows/pre_commit.yml`,
`.github/workflows/check-generated-models.yml`,
`.github/workflows/ai-engine.yml`, and
`.github/workflows/sync_files_v2.yml` to create unique cache keys
(`pre-commit`, `generated-models`, `ai-engine`, `sync-files`).
- No behavior changes beyond isolating the uv cache keys per-workflow
and no other workflow steps were modified.

### Testing
- Ran `git diff --check` which completed with no reported whitespace or
index issues.
- Verified each modified workflow is valid YAML by loading them with a
Ruby YAML parser which succeeded for all four files.
- Attempted to run `task --list` to exercise the Taskfile locally but
`task` is not installed in this environment so that check could not be
executed.

------
[Codex
Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a58b399a33083259d65e0614fcc35d2)
2026-07-16 12:43:11 +00:00
Anthony Stirling 939afef14f perf(portal): collapse admin-roster N+1 + session indexes (#7008)
# Description of Changes

Collapses the portal admin-roster endpoint (`getAdminSettingsData`,
`/api/v1/proprietary/ui-data/admin-settings`) from a per-user N+1 into a
constant set of queries, and adds the missing
session/user/team-membership indexes.

**Verified on H2 and real Postgres 16, 2,000-user roster:** 10,601 → 7
SQL statements, 600 → 0 writes-during-a-GET, O(N) → O(1). Portal-access
resolution is proven equivalent to the per-user check (parity test), and
a scaling guard fails the build if the endpoint ever regresses.

Also in scope (same controller / session subsystem): `getLoginData`
counts instead of loading the whole user table; `getTeamDetailsData`
fetch-joins authorities; `SessionScheduled` uses one bulk expire + a
bounded purge.

Behaviour note: the roster "active" flag now reflects *any* live session
(a strict superset of the old "newest session only") — no user who was
active is ever shown inactive.

---

## Checklist

### General

- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Testing

- [x] I have run backend `task check` (spotless + full backend test
suite) — all green
- [x] I have tested my changes locally (before/after benchmark on H2 +
Postgres)
2026-07-16 12:00:02 +00:00
ConnorYoh 2118de0bb7 Open processor "Open in browser" CTAs in a new tab (#7045)
## Description

The processor home-hero and download-editor modal "Open in browser"
buttons were meant to open the editor in a new tab, but they used
`window.location.href = EDITOR_URL`, which replaces the current tab.

This switches both to `window.open(EDITOR_URL, "_blank",
"noopener,noreferrer")`, matching the existing behaviour of the "Open"
button in `EditorStatusCard.tsx`. The `noopener,noreferrer` flags mirror
that same call and prevent the new tab from getting a `window.opener`
reference.

## Changes

- `WelcomeBanner.tsx` — home-hero "Open in browser" CTA
- `DownloadEditorModal.tsx` — download modal "Open in browser" CTA

## Notes

`EDITOR_URL` can resolve to a same-origin path when the editor is the
same SPA, so opening in a new tab triggers a full page load of the
editor app. This is the expected behaviour for "Open in browser".
2026-07-16 10:20:49 +00:00
Anthony Stirling 963fe6c0cf Portal: add docs + auto-synced with full-text search (#6985)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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.
2026-07-16 10:12:36 +00:00
Anthony Stirling bef83d80a1 Patch CVEs in engine Python dependencies (#6804)
# Description of Changes

- Patch CVEs in engine Python dependencies (43 alerts): `starlette`
1.3.1, `cryptography` 49.0.0, `pyjwt` 2.13.0, `urllib3` 2.7.0, `aiohttp`
3.14.1, `python-multipart` 0.0.32, `langchain-core` 1.4.8, `langsmith`
0.9.1, `authlib` 1.7.2, `requests` 2.34.2, `idna` 3.18, `pytest` 9.1.1,
`pygments` 2.20.0, `pydantic-settings` 2.14.2
- Cap `pydantic-ai` `<2.0.0` and bump to 1.107.0 (1.99.0 patches
CVE-2026-46678; 2.0 is a separate major migration)

---

## 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 (if functionality has heavily
changed)
- [ ] I have read the section Add New Translation Tags (for new
translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally
2026-07-15 14:49:33 +00:00
Anthony Stirling 8ed1ff152b Hide processor pipelines stat boxes during loading/empty state (#6983)
# Description of Changes

Gate the processor Pipelines KPI strip on having real pipelines, so the
loading and empty states no longer flash a row of empty `—` stat boxes.
Matches the Policies view; Sources is intentionally left as-is.

---

## 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)

- [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.
2026-07-15 14:49:25 +00:00
Anthony Stirling d2c8bbfcb2 Portal Sources: consistent SVG source icons (#6995)
# Description of Changes

Replaces the odd glyph icons in the Sources connect flow with centred
stroke SVGs, and fixes the connect-modal type-tile alignment and the
inconsistent Folder chip width.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<img width="2816" height="1224" alt="before-sources-light"
src="https://github.com/user-attachments/assets/22814817-8123-4bc7-b40b-5c2ba2881415"
/>
<img width="2880" height="2000" alt="after-connect-modal"
src="https://github.com/user-attachments/assets/0e51ca78-cda0-4909-bff3-912ecd7925a3"
/>
<img width="2816" height="1224" alt="after-sources-light"
src="https://github.com/user-attachments/assets/b9fd4b8a-c45a-4518-9676-e871bbabd9b8"
/>
<img width="2880" height="2000" alt="before-connect-modal"
src="https://github.com/user-attachments/assets/55058c40-d152-4c5e-a8a0-1ca5549a121c"
/>


---

## 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)

- [x] 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.
2026-07-15 14:49:21 +00:00
Anthony Stirling da1be650ea Portal Billing: use design tokens for colours (#6996)
# Description of Changes

Swaps off-palette hard-coded blues/cyans on the usage & billing page for
shared design tokens, so button and meter colours match the rest of the
portal in both themes.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after
<img width="2816" height="1000" alt="after-billing-free"
src="https://github.com/user-attachments/assets/1f6acad3-56a1-47a8-b5f5-8cc25565492b"
/>
<img width="2816" height="2048" alt="after-billing-subscribed-dark"
src="https://github.com/user-attachments/assets/35a75057-bdf5-4143-bdfa-4dbe94b5429e"
/>
<img width="2816" height="2048" alt="after-billing-subscribed-light"
src="https://github.com/user-attachments/assets/f9e7eab8-7d01-4d29-b842-b5d38adf7780"
/>
<img width="2816" height="1000" alt="before-billing-free"
src="https://github.com/user-attachments/assets/c7e5eca6-a57a-4959-9781-fd664dbca576"
/>
<img width="2816" height="2004" alt="before-billing-subscribed-dark"
src="https://github.com/user-attachments/assets/d7fc08be-7b91-4803-bd50-3301b886e1c0"
/>
<img width="2816" height="2004" alt="before-billing-subscribed-light"
src="https://github.com/user-attachments/assets/ff430ec1-c3bb-4517-b6b5-2150c566eca4"
/>


---

## 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)

- [x] 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.
2026-07-15 14:49:15 +00:00
Anthony Stirling 5cb5a866ca Portal: remove unused Components section (#7005)
# Description of Changes

Deletes the unused Components catalogue section (view, cards, SDK mocks)
along with its sidebar entry and route.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<!-- paste before / after screenshots here -->

---

## 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)

- [x] 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.
2026-07-15 14:49:13 +00:00
Anthony Stirling 83e5319661 Reduce CI cost: disable Depot, gate arm64/Tauri PR builds, self-testing CI routing (#7028)
## What

CI cost/routing cleanup. Four changes, each reversible with no code
deleted.

### 1. Disable Depot repo-wide (reversible)
Depot ran on trusted (non-fork) triggers via the `is_fork` output of
`_runner-pick.yml`, driving both the `depot-*` runner selection and the
Depot docker build actions. It's now disabled everywhere behind a single
kill-switch:

- `_runner-pick.yml` gains a dedicated `use_depot` output, forced
`false` via `DEPOT_ENABLED=false`. `is_fork` stays truthful for trust
gating (e.g. `build-enterprise` skipping on forks).
- All `runs-on:` and `USE_DEPOT:` expressions now key off `use_depot`,
so every job falls back to `ubuntu-latest` + buildx.
- `settings.gradle` Depot remote build cache (`cache.depot.dev`) gated
behind `depotCacheEnabled = false`.

**Switch back on:** set `DEPOT_ENABLED=true` in `_runner-pick.yml` (and
`depotCacheEnabled = true` in `settings.gradle`). Depot then reactivates
on trusted triggers exactly as before.

### 2. arm64 PR docker build only on Dockerfile changes
`test-build-docker.yml` was building `linux/amd64,linux/arm64/v8` on
every PR matching the broad `project` filter. With Depot off, the arm64
leg runs under slow QEMU emulation on every code PR. New `dockerfiles`
path filter (`docker/**/Dockerfile*`) gates the arm64 leg: normal code
PRs build amd64 only; PRs that touch a Dockerfile still build amd64 +
arm64. arm64 is still fully exercised on the base-image publish and on
release.

### 3. Tauri PR build -> Linux only, unsigned, deb-only
The PR path built the full 3-OS matrix (Windows + macOS-universal +
Linux), plus the flaky Linux AppImage pass (#6127). PRs now build Linux
only (fastest + cheapest to compile) via a new `minimal` input on
`tauri-build.yml`: Linux deb only, no rpm, no AppImage. The full signed
multi-OS matrix still runs on release, and nightly still warms the Rust
cache with all-OS defaults (unchanged).

Tradeoff: Windows/macOS desktop build breaks are caught by nightly
(all-OS) rather than the introducing PR.

### 4. CI self-testing routing
Editing `build.yml` only matched the `project` filter, so a change to
how e2e / enterprise / tauri / engine jobs are dispatched didn't
actually run those jobs. Added a `ci` anchor (`build.yml` +
`.github/config/.files.yaml`) that every job-gating area filter now
includes, so editing the router or the filter config runs every job.
Also added the orphaned reusable workflows (`e2e-*`,
`frontend-validation`, `docker-compose-tests`, `test-build-docker`,
`check-openapi`, `check-licence`) to their area filters so editing a
reusable workflow self-tests.

## Validation
- All workflow YAML + `.files.yaml` parse; anchor resolution verified
(every job-gating filter resolves to include the `ci` paths).
- Gradle evaluates `settings.gradle` cleanly; `spotlessGradleCheck`
passes.
2026-07-15 14:49:06 +00:00
James Brunton ed58d90ab8 Remove policies feature flag (#7031)
# Description of Changes
Removes the feature flags for enabling policies on both the backend and
frontend. We shouldn't be releasing another self-hosted release that
doesn't include policies, so it makes sense to do this now. Builds that
don't have the Processor will just not run policies because they won't
have any. Beyond that, the API should always be available, but checks
whether the user actually has the entitlements to run policies (whether
they have credits/a payment method available)
2026-07-15 14:25:25 +00:00
James Brunton 4ee54243f3 Remove encryption from stored policies JSON (#7035)
# Description of Changes
The policies stored in the DB are currently encrypted at rest, because
one version in the past included S3 keys. These are now stored properly
in the credentials system and I've manually removed the only policy that
used S3 (it was very recently released). Since there's no S3 (or other)
credentials in the policies stored JSON now, we might as well just
decrypt them. This PR pairs with #7034 to fix the issues - #7034 makes
it resilient to crashing when attempting to load encrypted JSON that's
been encrypted with the wrong key, and this makes it so if it does load
any encrypted policies, they'll be re-saved decrypted, so we should have
a vanishingly small number of encrypted policies over time.
2026-07-15 12:28:29 +00:00
James Brunton 350c0b796e Upgrade to TS7 official release (#6958)
# Description of Changes
Convert from the TS7 release candidate to the TS7 official release,
keeping TS6 around in [the compatibility mode suggested by
Microsoft](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-side-by-side-with-typescript-6.0)
so ESLint and our scripts which rely on the TS API still work.
2026-07-15 12:21:34 +00:00
Anthony Stirling 663bb32b2c Skip unreadable policy source/policy rows instead of crashing :) (#7034)
# Description of Changes

Thanks james for the prod issue  :) 

---

## 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.
2026-07-15 12:19:06 +00:00
EthanHealy01 b86e963c9d initial colors and theme improvements (#7009)
## What this does

Consolidates the frontend's colour/theme system into a small,
well-defined token layer and reworks the theme picker. The goal was a
minimal, scalable set of semantic tokens that the editor **and** the
Processor/portal (and Storybook) all share, plus a theme model that's
easy to reason about.

## Token architecture (`core/theme/`)

A four-file layer, imported once via `index.css`:

| File | Role |
|---|---|
| `primitives.css` | The raw palette — the **only** place literal
colours live (neutral ramps `--p-gray-*`/`--p-zinc-*` + status hues). |
| `colors.css` | ~21 semantic `--c-*` tokens (surfaces, text, borders,
primary, status) mapped from primitives per theme. **Reference these.**
|
| `compat.css` | Legacy names (`--bg-*`, `--text-*`, `--color-*`)
aliased onto `--c-*` via `:root:root` so ~200 existing files keep
working. |
| `dimensions.css` | All non-colour tokens (spacing, radius, z-index,
type, motion) — single source, resolving prior collisions. |

A blocking linter (`scripts/lint/theme-lint.mjs`, run in
`frontend:lint`) enforces "literals only in `primitives.css`" within
`core/theme/`, and has a non-blocking WCAG contrast report. See
`core/theme/README.md`.

## Theme model

- **Mode** (`light` / `dark` / `system`) and **accent** are independent.
Each mode has its own accent (`lightPrimary` / `darkPrimary`).
- The editor is always `data-app-theme="custom"`; `ThemeProvider`
injects the accent as `--user-primary` and sets `data-accent`.
- **Two accent states:**
- A **colour** (preset or custom hex) → tints every surface that hue
(whole-app theming).
- The **`default`** sentinel → neutral surfaces (white/grey light, zinc
black/grey dark) with blue buttons, no tint. (`data-accent="default"`
opts surfaces out of the tint.)
- Accent contrast guardrails (`utils/customPrimary.ts`): lightness
clamps so an accent can't collapse into the base, a contrast-picked
on-primary foreground, and an accent-as-foreground variant so accent
text is never dark-on-dark.

## Theme picker (Settings → General)

- 3×5 grid: a distinct **Default** icon chip (not a colour) + 14 curated
accents, in a dropdown per mode.
- **Custom** colour via the shared `ColorInput`, with a live gamut clamp
(`clampValue`) that refuses white/grey/black — the picker handle sticks
at the boundary and preserves the working hue at achromatic extremes.
- "Restore theme to default" resets both modes.

## Other

- Dark mode is a true neutral zinc (no navy "midnight" tint); the
Mantine dark ramp and Tailwind dark channels were neutralised to match.
- Pre-paint inline script in `index.html` applies theme + accent before
first paint (no FOUC); portal and editor now share the same
`preferences.theme` source of truth.
- High-visibility surfaces migrated to tokens (FAB, landing upload
buttons, portal hero banners); scattered per-component colour swaps were
intentionally **left for a follow-up** to keep this PR focused.

## Testing

- `task frontend:check:all` (typecheck all variants + eslint + prettier
+ colour-lint) green.
- Verified light/dark, default vs tinted accents, and the custom clamp
via computed styles in the dev preview.

> Note: the `prerender-og` build step failing in the e2e/deploy jobs is
unrelated to this diff — it's in `vite.config.ts` (untouched here) and
builds cleanly locally.
2026-07-15 12:11:00 +00:00
EthanHealy01 7f01bcdc44 Classifier setup as a processor policy (#7012)
## Overview

Adds a **Classification policy** to the processor's policy catalogue,
set up the same way as the Security policy. This moves classifier
configuration out of the editor (where the labels UI landed in #6898 and
was then removed with the rest of the editor's policy-management surface
in #6932) and into the processor, which is now the single place policies
are configured.

## What it does

- **Classification card** in the processor policy catalogue. Always
shown, but **setup is locked until the backend reports the AI engine is
on** — so admins can see the capability they're missing rather than it
being hidden entirely.
- **Setup wizard** mirrors Security: the workflow step shows the team's
**classification label editor** (reused
`LabelsEditor`/`LabelsEditorModal` — add box, chip grid, per-label icon
picker, import/export, reset) instead of tool toggles, since classify is
a single non-configurable step.
- On enable, the team's label vocabulary is **seeded with the 268
built-in defaults** (clobber-safe: only when the team has none). On
upload the document is classified against the team's labels and tagged;
on SaaS with the engine on, files group by category in the editor
sidebar.

## Reuse & consolidation

- Reuses the existing labels table, `labelsFile` helpers, and default
vocabulary. Labels read/write through the processor's own
`apiClient.local` (not the editor's axios client) so auth/base routing
stays explicit; the wire shape is shared.
- Consolidates policy-category icons into a shared, **id-keyed**
`policyCategoryIcon` util (outline glyphs) used by both the editor and
the processor, replacing the processor's emoji-glyph map (and the stray
`schedule` key that rendered a bare dot).

## Testing

- `task frontend:typecheck:{core,proprietary,portal}`,
`frontend:lint:eslint`, `frontend:test` (156 files / 1305 tests) — all
green.
- Verified in Storybook: the Classification card renders, the setup
wizard shows the label editor (268 defaults), and the full labels editor
opens with icons/import/export/reset. Added an MSW handler for the
app-config + labels endpoints and a `Classification` wizard story.

## Notes for reviewers

- The AI-engine gate reads the public `/api/v1/config/app-config`;
classification labels use `/api/v1/classification/labels` (team-scoped,
team-lead/admin-gated, `policies.enabled`); the classify step hits
`/api/v1/ai/tools/classify-and-label` — all pre-existing backend from
#6898.
- Known parity behavior (matches the editor hook): a transient failure
loading team labels falls back to showing the defaults; not changed here
to avoid diverging the two hooks.
2026-07-15 11:04:30 +00:00
EthanHealy01 0570c4c4d9 Create-PDF engine: render from a structured document (#7018) 2026-07-14 12:30:33 +00:00
James Brunton 776749277c Redesign policies to use typed mappings properly (#7017)
# Description of Changes
The Policies page and all the frontend logic for running Policies is not
making use of the bidirectional type mappings that we now have to safely
convert from frontend to backend param models and vice versa. This
changes the way we track the types throughout so we use the mappings
properly.

Because of this, the Add Watermark settings in Policies now actually
pre-populate with the defaults instead of with nothing like they
previously did.

<img width="791" height="725" alt="image"
src="https://github.com/user-attachments/assets/cbdf4ae0-35af-4792-bf64-89216e48d304"
/>
2026-07-14 09:58:04 +00:00
James Brunton 41b1b89fcb Fix Policies page showing the Editor as a source twice (#7022)
# Description of Changes
The Policies page currently hard-codes the Editor to be available as a
source, but we now also have a virtual Editor source on the backend,
which the Policies page also renders. This removes the now-unnecessary
hard-coded Editor source.

## Before

<img width="842" height="640" alt="image"
src="https://github.com/user-attachments/assets/d78b33a3-fed4-4bb0-a02f-489ca2ae0614"
/>

## After

<img width="785" height="586" alt="image"
src="https://github.com/user-attachments/assets/37aa664f-74f2-41c7-b89a-9b483bffc3a2"
/>
2026-07-14 09:43:07 +00:00
James Brunton 4d4e994562 Fix crash in Processor when loading tool settings with tooltips (#7015)
# Description of Changes
Some of the tool settings make use of editor preferences indirectly, but
the Processor never gets that provider, so it crashes when trying to
load them.
2026-07-14 09:09:23 +00:00
2b118556f3 Merge hotfix/v2.14.2 into main (#7023)
Merges the `hotfix/v2.14.2` branch into `main`.
on the hotfix branch:

### What this actually changes on `main`
- **Version bump 2.14.1 → 2.14.2** `build.gradle`, `tauri.conf.json`,
both AUR `PKGBUILD`s, and the two `serverExperienceSimulations.ts`
test-config files.
- **Fix Postgres user settings for some users** removes `@Lob` from
`User.java that broke settings for some Postgres users.
- **Release workflow: stop msiexec hang in Windows signature verify**

---------

Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: LFdev <146497073+LFd3v@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-07-13 23:45:19 +01:00
Anthony Stirling fbaff56d1c Merge hotfix/v2.14.2 into main (v2.14.2 bump, Postgres user settings fix, msiexec release fix) 2026-07-13 20:23:14 +01:00
Anthony Stirling a1b15e0570 Portal: dark disabled buttons and role column width (#7004)
# Description of Changes

Fixes disabled buttons rendering as plain grey in dark mode, and widens
the Users role column so "Organisation Owner" no longer clips.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<!-- paste before / after screenshots here -->

---

## 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)

- [x] 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.
2026-07-13 15:00:01 +00:00
James Brunton 76549288a9 Redesign S3 connections to use connection resolver (#6965)
# Description of Changes
Redesign S3 connections based on feedback from #6948. Also redesigns the
UI for Sources to make them more like the Pipelines page which improves
UX quite a bit. There's still plenty more UI/UX work for Sources and S3
but moving in the right direction.
2026-07-13 14:44:41 +00:00
Anthony Stirling b5d0c4a5ed Portal Home: SVG quick-action icons (#6998)
# Description of Changes

Replaces the ASCII quick-action glyphs on the Home hero with crisp
stroke SVG icons.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after
<img width="2880" height="2726" alt="after-home-dark"
src="https://github.com/user-attachments/assets/ab4c3611-25a6-4b2c-a993-99ce2f7b7558"
/>
<img width="2880" height="2726" alt="after-home-light"
src="https://github.com/user-attachments/assets/b0b342b9-4436-4791-b0a6-92837c3ec355"
/>
<img width="2880" height="2726" alt="before-home-dark"
src="https://github.com/user-attachments/assets/93d73393-5a81-4f34-a483-90671a6ae79e"
/>
<img width="2880" height="2726" alt="before-home-light"
src="https://github.com/user-attachments/assets/119389cc-78ef-4a43-9f9a-d551b03c9733"
/>


---

## 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)

- [x] 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.
2026-07-13 13:26:07 +00:00
Anthony Stirling 8bfcf6eb7e Portal: theme-aware code blocks and hero-navy token (#7003)
# Description of Changes

Makes the code-snippet boxes theme-aware (a light palette in light mode)
and moves the hero navy into a design token without changing the colour
itself.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<img width="2136" height="272" alt="after-codeblock-light"
src="https://github.com/user-attachments/assets/ebdd4a7d-2d1a-429a-971b-0b04e93854fe"
/>
<img width="1800" height="740" alt="before-codeblock-light"
src="https://github.com/user-attachments/assets/6819231a-10d5-4730-9b7d-3c36dc1c8170"
/>

---

## 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)

- [x] 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.
2026-07-13 13:26:00 +00:00
James Brunton b019f9b570 Fix missing and broken translations in Processor (#7016)
# Description of Changes
<img width="385" height="92" alt="image"
src="https://github.com/user-attachments/assets/7f4b1921-72e8-4c18-a4de-4b9736a5a2f1"
/>

Started from trying to fix this, but became a larger piece of work to
find missing/broken translations in the Processor and fix as many as I
could.
2026-07-13 12:55:36 +00:00
Anthony Stirling a84b375f5d Portal Pipelines: SVG pipeline icon (#7002)
# Description of Changes

Replaces the chain glyph in the pipelines table with a proper pipeline
icon.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<!-- paste before / after screenshots here -->

---

## 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)

- [x] 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.
2026-07-12 23:56:07 +00:00
Anthony Stirling 52358c5bf9 Portal Agent Builder: SVG upload icon (#7001)
# Description of Changes

Replaces the upload glyph in the agent bootstrap dialog with a proper
SVG icon.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<!-- paste before / after screenshots here -->

---

## 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)

- [x] 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.
2026-07-12 23:55:54 +00:00
Anthony Stirling c1e68c27c5 Portal Documents: SVG lock and timer icons (#7000)
# Description of Changes

Replaces the emoji lock and timer icons in the document queue and
extraction views with stroke SVG icons.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<!-- paste before / after screenshots here -->

---

## 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)

- [x] 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.
2026-07-12 23:55:43 +00:00
Ludy d4edff9059 fix(temp-files): prevent cleanup of active registered directories (#7006) 2026-07-12 22:33:09 +01:00
Ludy 80febc9993 fix(i18n): localize hardcoded frontend text in English and German (#6993) 2026-07-12 10:16:55 +01:00
Ludy c500c2fae7 fix(desktop): preserve RGBA format for Tauri app icon (#6990)
# Description of Changes

- Replaced the Tauri application icon with an RGBA-formatted PNG.
- Added a root `.imgbotconfig` that excludes the Tauri icon from
automatic image optimization.
- Fixed the `desktop:test` compilation failure caused by
`tauri::generate_context!()` rejecting the previous non-RGBA icon.
- Prevented ImgBot from potentially converting the icon back to an
unsupported indexed PNG while optimizing its file size.
- Verified that the current icon uses PNG Color Type 6 (`Truecolour with
alpha`).

```sh

[desktop:test] error: proc macro panicked
[desktop:test]    --> src/lib.rs:202:12
[desktop:test]     |
[desktop:test] 202 |     .build(tauri::generate_context!())
[desktop:test]     |            ^^^^^^^^^^^^^^^^^^^^^^^^^^
[desktop:test]     |
[desktop:test]     = help: message: icon /Users/runner/work/Stirling-PDF/Stirling-PDF/frontend/editor/src-tauri/icons/icon.png is not RGBA
[desktop:test] 
[desktop:test] error: could not compile `***-pdf` (lib) due to 1 previous error
[desktop:test] warning: build failed, waiting for other jobs to finish...
[desktop:test] error: could not compile `***-pdf` (lib test) due to 1 previous error
task: Failed to run task "desktop:test": exit status 101
Error: exit status 101

```

---

## 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.
2026-07-12 06:33:09 +01:00
Reece BrowneandAnthony Stirling 0a1b4ec173 Tidy policy/portal translation keys (#6962)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-07-11 14:10:51 +01:00
Ludy b8d8f028c9 fix: align portal icons with supported Material Symbols names (#6884) 2026-07-11 13:08:34 +01:00
dependabot[bot] cd56367295 build(deps): bump actions/cache from 5.0.5 to 6.1.0 (#6968)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 13:04:22 +01:00
ConnorYoh 40a2d2844f Portal: honour RUN_SUBPATH in editor + login redirects (#6975) 2026-07-11 13:04:09 +01:00
dependabot[bot] f79968f336 build(deps): bump docker/build-push-action from 7.1.0 to 7.3.0 (#6969)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 12:55:37 +01:00
dependabot[bot]andLudy df43e09eca build(deps): bump form-data from 4.0.5 to 4.0.6 in /frontend (#6676)
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 12:55:08 +01:00
imgbot[bot]andImgBotApp cb0f335e8a [ImgBot] Optimize images (#6588)
Co-authored-by: ImgBotApp <ImgBotHelp@gmail.com>
Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2026-07-11 12:48:53 +01:00
James Brunton 38d06d3104 Make sqlite backend more resilient when using multiple runners (#6971) 2026-07-11 12:47:29 +01:00
ConnorYoh fe33378333 feat(portal): set a spend cap during PAYG checkout (two-step modal) (#6970) 2026-07-11 12:47:04 +01:00
Anthony Stirling 5944cd106b Portal audit: label policy runs by their policy, flag automation sub-steps (#6937) 2026-07-11 12:46:44 +01:00
EthanHealy01 fd81bf4cf8 Tighten whitespace between search bar and tool list (#6977) 2026-07-11 12:44:19 +01:00
EthanHealy01 d23318cfa6 Feature/onboarding updates for policies and portal (#6926) 2026-07-11 12:41:48 +01:00
Anthony Stirling 142544c9af Replace portal sidebar brand text with Stirling Processor wordmark (#6978) 2026-07-11 12:40:55 +01:00
Ludy 99a5f2a1bc chore(ci): include saas module in GitHub file path configuration (#6980) 2026-07-11 12:39:05 +01:00
James Brunton 863cad22bd Fix policy running of Redact (#6972)
# Description of Changes
Policies can currently throw when calling redact:

<img width="1186" height="824" alt="image"
src="https://github.com/user-attachments/assets/bdcc09fe-5bf4-4b0a-b119-bcc33c98c7f2"
/>

Policies really need to be updated to properly make use of the new
bidirectional mappings for this, but this will hopefully fix it for now.
2026-07-10 16:24:25 +00:00
EthanHealy01 c06657c8f9 Match external-link tool buttons to normal tool button size (#6974)
The external-link "Developer Tools" buttons (API, Automated Folder
Scanning, SSO Guide, Air-gapped Setup) used `p="sm"` while normal tool
buttons use `p="none"`, making them render larger; this aligns their
padding so they match the size of every other tool button.

<img width="308" height="196" alt="Screenshot 2026-07-10 at 5 01 40 PM"
src="https://github.com/user-attachments/assets/fb125500-28fb-4b83-85ed-2edc12e66fc0"
/>
2026-07-10 16:24:16 +00:00
EthanHealy01 532a80211f Test: pin ADMINS_AND_TEAM_LEADS default scoping to the owning team (#6966)
## What this does

Adds one test to `ResourceAccessServiceTest`: a foreign team's lead is
**denied** on a team-owned resource under the `ADMINS_AND_TEAM_LEADS`
default policy, even when an unscoped `isAnyTeamLeader` check would
admit them (stubbed `lenient()` to `true` precisely so the test fails if
the scoped path ever consults it again).

## Why

Main is already correct here — no behaviour changes in this PR. #6913
landed the scoped implementation (`matchesTeamLeadDefault`: ownerless
portal → `isAnyTeamLeader`, team-owned → `isLeaderOfTeam`), which
superseded #6893. The only piece not carried over was #6893's boundary
test, so the cross-team scoping isn't currently pinned by any test. This
adds that pin as cheap insurance for future refactors.

Verified the test does its job: it passes on main as-is, and fails if
the scoped check is swapped back to the unscoped one.

## Test plan
- `:proprietary:test --tests
"stirling.software.proprietary.access.service.ResourceAccessServiceTest"`
— green
- Spotless applied

Closes the loop on #6893.
2026-07-10 16:06:48 +00:00
Anthony Stirling d06a367b87 SaaS role-based login landing (team leads → Processor) (#6960) 2026-07-10 15:23:00 +01:00
ConnorYoh ce6abe6e23 PAYG: size-scaled units + per-input-file PDF count + run-id grouping (#6957)
Reworks the Processor (PAYG) meter to **size-scaled units** while
keeping a true **PDF count** visible and distinct from units, and
replaces the fragile content+time lineage grouping with **explicit
per-run grouping**. Built as one PR across three slices.

> Status: **all three slices committed + verified.** `:saas` payg suite
green (418 tests, 0 failures); FE green (typecheck 0, 1260 tests, lint
0, format 0). Remaining before it takes effect in prod: run the
size-scaled default-policy SQL (below) in the Supabase SQL editor +
attach the $0.01/unit Stripe price.

## Model (what we're implementing)
- **Size scaling**: 1 unit per 50 MiB (bytes only, no page charge, no
cap). *(policy-row config, applied separately via SQL.)*
- **Charge = number of input files**: split (1→N outputs) = 1 charge;
merge (N→1) = N charges. `doc_count` = input files, fixed at open;
joined steps add 0.
- **Grouping by run id, not time**: a pipeline/policy/AI run = one
`run_id`; its tool sub-steps group into one charge (content-lineage
still maps split/merge journeys *within* the run). Two separate runs on
identical bytes = two charges. The 5-min window survives only as a
stale-job janitor.
- **10-tool split kept**: within a run's single-file lineage, an 11th
tool run opens a 2nd charge (step limit 10).
- **Count vs units surfaced**: usage page shows unique PDFs,
per-category (automation/AI/API) counts + units, and how many PDFs hit a
size multiplier with avg units/PDF.

## Slice 1 — run-id grouping (behavioural core)
- `AutomationRunContext` (common) — thread-scoped run id.
- `InternalApiClient` — stamps `X-Stirling-Run-Id`.
- Orchestrators open a run scope **on the worker thread that
dispatches** (async-safe): `PipelineProcessor.runPipelineAgainstFiles`,
`PolicyEngine.runToCompletion` (uses `run.getRunId()`),
`AiWorkflowService.orchestrate`.
- `ChargeContext` + `JobContext`: add `runId`; the charge interceptor
reads the header.
- `JobService.joinOrOpen`: `runId == null` → always open fresh
(standalone never joins); non-null → match scoped to the same `run_id`.
`JpaJobLineageStore`/`JobArtifactHashRepository`: add `run_id` filter to
the match query. Step-limit 10 unchanged.

## Slice 2 — doc_count + document_fingerprint
- V33 migration + entity fields.
- `JobService.openFresh`: set `docCount = inputs.size()`, compute
`document_fingerprint` from input signatures, and denormalise both onto
the DEBIT row in `JobChargeService.recordLedgerDebit`.

## Slice 3 — usage analytics API + FE
- `WalletLedgerRepository`: per-category `SUM(units)` +
`SUM(doc_count)`, `COUNT(DISTINCT document_fingerprint)`, and count of
rows whose units exceed their doc_count (a size multiplier fired), over
the period.
- `WalletSnapshotResponse` + `PaygWalletController`: add `categoryDocs`,
`docsProcessedThisPeriod`, `uniquePdfsThisPeriod`,
`sizeMultiplierPdfsThisPeriod`.
- FE `types.ts` + `PdfsProcessedCard` + `useWallet` + `walletFixtures` +
i18n: headline is the **PDF count**; a summary line shows "{unique}
unique · {units} meter units · {avg} avg units/PDF"; the split bar is
per-category PDF counts; a size-multiplier line shows how many PDFs
scaled. Count is separated from meter units so a 5-unit large PDF reads
as "1 PDF, 5 units".

## Config (out of PR — run in the Supabase SQL editor)
Wrap in one transaction. The partial-unique `is_default` index only
allows one default, so the old default is flipped off **before** the new
one is inserted. The new policy carries the prior default's
`free_tier_units` forward (change the literal if the launch grant should
differ).
```sql
BEGIN;

-- 1) flip default off the current policy + close its effective window
UPDATE stirling_pdf.pricing_policy
SET is_default = FALSE, effective_to = now()
WHERE is_default = TRUE;

-- 2) new default: 1 unit / 5 MiB, no page charge, no scaling cap.
--    free_tier_units carried from whatever the last policy granted (COALESCE→0).
INSERT INTO stirling_pdf.pricing_policy
  (version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
   min_charge_units, file_unit_cap, free_tier_units, is_default, notes, created_by)
VALUES
  ('v2-size-scaled-2026-07', now(),
   2147483647,        -- doc_pages_per_unit = INT_MAX → pages never drive units
   52428800,          -- doc_bytes_per_unit = 50 MiB → +1 unit per 50 MiB
   1,                 -- min_charge_units
   2147483647,        -- file_unit_cap = INT_MAX → no cap on size scaling
   COALESCE((SELECT free_tier_units FROM stirling_pdf.pricing_policy
             ORDER BY effective_from DESC LIMIT 1), 0),
   TRUE, 'Size-scaled: 1 unit/5MiB, bytes only, no cap', 'connor');

-- 3) per-source step limits: standalone ops = own charge; pipelines split at 10
INSERT INTO stirling_pdf.pricing_policy_step_limit (policy_id, job_source, step_limit)
SELECT p.policy_id, s.src, s.lim
FROM stirling_pdf.pricing_policy p
CROSS JOIN (VALUES
  ('WEB',1),('API',1),('DESKTOP_APP',1),('LINKED_INSTANCE',1),('PIPELINE',10)
) AS s(src, lim)
WHERE p.version = 'v2-size-scaled-2026-07';

-- 4) attach the $0.01/unit Stripe price (you handle the real price id)
INSERT INTO stirling_pdf.pricing_policy_stripe_price (policy_id, stripe_price_id)
SELECT policy_id, 'price_XXXXXXXX'
FROM stirling_pdf.pricing_policy WHERE version = 'v2-size-scaled-2026-07';

COMMIT;
```
Note: the `free_tier_units` subquery reads the most-recent policy
*before* the insert — run it as written (the new row doesn't exist yet
at step 2's SELECT).

## Self-hosted parity — tracked follow-up (not in this PR)
Combined-billing (`stirling.billing.account-link.enabled`) is a
**separate metering engine** (`app/proprietary/accountlink` —
`UsageMeterService`/`LocalUsageService`/`UsageSyncService`). The unit
*math* is shared (`DocumentUnitCalculator`), so size scaling matches
once the policy is pushed. But run-id grouping, `doc_count`, and
fingerprints must be mirrored there, and the usage-sync protocol
extended to report counts/fingerprints, before the self-hosted usage
page shows the same breakdown. Frozen/deferred, so this PR does SaaS;
self-hosted mirrors when it ships.
2026-07-10 13:38:22 +00:00
ConnorYoh ece3562dc9 Portal: team-scoped Free PDF Editors usage card for SaaS (#6924)
## What

Phase 2 of the Free PDF Editors usage card (self-hosted shipped in
#6919): make it work on **SaaS**, where one backend serves many teams so
every figure must be scoped to the **caller's team**.

| Metric | SaaS (per team) |
|---|---|
| **Editors deployed** | team member count (`team_memberships`) |
| **Active this month** | distinct members with a free-UI
(`source='WEB'`, non-`UI_DATA`) audit event in 30d, clamped ≤ deployed |
| **PDFs edited** | the team's cumulative free-UI
`PDF_PROCESS`+`FILE_OPERATION` events |

Cost stays `$0`; uncomputable figures render **N/A**.

## Backend

- **Gate the self-hosted controller** `@Profile("!saas")` — its counts
are server-wide, which would leak across tenants on SaaS. New
team-scoped `SaasFleetUsageController` `@Profile("saas")` owns the same
`/api/v1/usage/fleet-stats` path (mutually exclusive profiles → no
mapping conflict).
- **Team resolution** mirrors `PaygWalletController`:
`AuthenticationUtils.getCurrentUser(auth, userRepo)` →
`TeamMembershipRepository.findPrimaryMembership` → members via
`findByTeamId`. `@PreAuthorize("isAuthenticated()")` (team leaders
aren't global admins; any member sees their own team's totals).
- **Audit → team join**: on SaaS the audit `principal` is the user's
email and `User.username == email`, so principals join cleanly to a
team's member usernames (no hashing — only raw-JWT/over-long principals
get hashed). Two new `principal IN` count queries do the filtering,
served by the `(source, timestamp, principal)` index from #6919.
- Billing/ledger is deliberately **not** used — it only records billable
ops; free-editor activity comes from audit (same `source='WEB'` signal
as self-hosted).
- `null`→N/A when EE auditing < STANDARD; 401 on no-auth; empty-fleet
guard for the (post-migration-shouldn't-happen) teamless caller.

## Frontend

- New `src/portal-saas/api/fleetStats.ts` (rides the `@portal/*` cascade
from #6900) reads via **`apiClient.saas`** — the Supabase JWT the SaaS
backend uses to resolve the team. Re-exports `FleetStats` via
`@portal-proprietary`. **The card and `useAsync` hook are untouched.**

## Tests

`STIRLING_FLAVOR=saas` build green — `:proprietary` + `:saas` compile,
`SaasFleetUsageControllerTest` (team scoping, audit-off→null, clamp,
no-team→empty, unauth→401) and the existing suites pass; spotless clean.

## Notes

- Requires SaaS auditing at STANDARD (it is) — else N/A.
- Depends on #6900 (merged) for the portal-saas override layer and #6919
(merged) for the audit `source` column + DTO.
2026-07-10 13:28:33 +00:00
James Brunton 84d4455682 Add virtual Editor source (#6959)
# Description of Changes
Adds Editor source permanently available in the Sources list. Excludes
it from the Pipelines list of available sources currently because it's
not a real source on the backend, so attempting to connect to it causes
an error. It'd be nice to extend in the future to be able to set up
policies in the editor from the pipelines page, but this'll do for now.
2026-07-10 13:27:40 +00:00
ConnorYoh b9a7f2083b Portal: realign home hero to the simplified marketing card (#6956)
## What

Reworks the free-tier home hero (`WelcomeBanner` + `SetupChecklist`) to
match marketing's reworked top card: a compact product header over
numbered getting-started steps, dropping the marketing chrome.

## aim
attachments/assets/75a80e5f-119e-46bb-80e7-fc4b9a62e5b6" />
<img width="1098" height="646" alt="01-aim-marketing-demo"
src="https://github.com/user-attachments/assets/681ca1b8-219e-4afe-9748-89435aafd440"
/>

## old hero
<img width="1800" height="1338" alt="02-before-old-hero"
src="https://github.com/user-attachments/assets/a396cec0-4752-4ac0-9951-9a49f9d50ea7"
/>


## new screenshots

<img width="1800" height="626" alt="03-after-onboarding-card"
src="https://github.com/user-attachments/assets/59332111-6c49-438d-af6d-200d99bf0f8f"
/>
<img width="1800" height="180" alt="04-after-deployed-header"
src="https://github.com/user-attachments/assets/ea631e6c-7878-4159-aaf7-1f0c2bd795ce"
/>
<img width="1024" height="1396" alt="05-after-install-modal-list"
src="https://github.com/user-attachments/assets/125d0a11-d799-40f0-bd8b-42f5dedcfe6d"
/>
<img width="1024" height="858" alt="06-after-install-modal-docker"
src="https://github.com/user-

## Changes

- **Compact dark header:** brand mark + "PDF Editor" + social-proof
stats (`30M downloads · 60+ PDF operations · Free forever`) + a single
**Open in browser** CTA (→ `EDITOR_URL`).
- **Dropped** the decorative editor mock, marketing
title/subtitle/"Open-source" badge/perks, the two extra banner buttons,
and the checklist's dismiss/progress/done tracking.
- **Numbered nav steps** (①②③) — each opens its in-app surface:

| # | Step | Goes to | Change |
|---|------|---------|--------|
| ① | Download the editor | `editor` view | was an external
`stirling.com/download` link → now in-app |
| ② | Confirm your policies | `policies` view | live active/recommended
counts retained |
| ③ | Invite teammates | `users` view | **replaces** "Connect your
sources" (sources dropped to match the demo) |

- **Enterprise rung** unchanged (Start Trial / Get Quote → procurement).

## Notes

- **Shared hero** — self-hosted sees it too (per decision).
- **One deliberate deviation from the demo:** the header CTA is blue
(brand primary) rather than the demo's white button. Trivial to flip —
say the word.
- Behaviour change: the hero is now a quick-start (navigational) rather
than a completion checklist — the dismiss control + per-step done chips
are gone to match the demo.
- Supersedes the incremental #6944 ("add Open in browser" 3-button
version) — that can be closed in favour of this.
- Portal `tsc` clean; `unusedTranslations` green (removed orphaned
welcome/onboarding keys, added the new ones).
2026-07-10 13:20:18 +00:00
Reece Browne b36f3e0875 Remove unused portal UI (#6949)
Removes some cluttered/unused UI from the portal:

- Search bar in the header
- The top bar entirely (breadcrumb, notification bell, plan switcher,
user menu)
- The plan/usage indicator in the sidebar footer
- The floating assistant badge

UI only. Where a component isn't deleted it's just no longer rendered,
so anything here is easy to restore.
2026-07-10 13:16:57 +00:00
ConnorYoh e4379184b5 fix(portal): translate policy category labels in PolicySummary (#6964)
## What

The portal's **"What runs on your PDFs"** table (`PolicySummary`)
rendered raw i18n keys instead of text:

- `portal.policies.categories.ingestion.label` / `.desc`
- `portal.policies.categories.security.label` / `.desc`
- …and the other three categories (compliance, routing, retention)

## Why it broke

[#6910 "Remove in-app portal
mocks"](https://github.com/Stirling-Tools/Stirling-PDF/pull/6910) moved
the policy catalogue to real data and converted each category's
`label`/`desc` (and each config's `summary`) into **i18n keys** — see
the `// values are i18n keys — render with t()` note in
`api/policies.ts`. Every consumer was updated to call `t()`
(`PolicyCategoryCard`, `PolicyDetailPanel`, `PolicySetupWizard`)… except
`PolicySummary`, which was not part of that PR and kept rendering the
fields verbatim.

The translation keys themselves already exist in
`en-US/translation.toml` (`[portal.policies.categories.*]`) — nothing
was missing, they just weren't being looked up.

## Fix

Wrap the values in `t()` in `PolicySummary.tsx` (the `t` from
`useTranslation` was already in scope):
- category `label` / `desc` in the Policy column
- `config.summary` in the Active-rule column (same keyed-value
treatment, latent until a policy is active)

## Test plan

- [ ] Open the portal Home / policies summary → each row shows the
translated category name + description (e.g. "Ingestion" / "Classify
documents…") instead of a dotted key.
- [ ] A row with an active policy shows its translated rule summary in
the Active rule column.
2026-07-10 13:05:46 +00:00
James Brunton 5ccb56da2d Add S3 policy source (#6948)
# Description of Changes
* Adds an Amazon S3 Source & Output
* Removes folder source from SaaS
* Some miscellaneous UX fixes around pipelines
2026-07-10 12:19:41 +00:00
Reece Browne 16f589448d Remove the policies management surface from the editor sidebar (#6932)
## What

Removes the policy **management** surface from the editor's right rail —
the Policies list above Tools, the open-policy detail takeover, and the
collapsed-rail policy icons — along with the whole UI tree only they
used: the setup wizard and its tool-config steps (PII / redact /
watermark), the detail panel, delete modal, selection store,
enforcement-queue status chip, activity/stats derivation, the catalog
hook, their i18n keys, dead types, and the admin-gate spec that tested
the wizard flow.

**Enforcement is untouched.** Auto-run on upload, the viewer blocking
overlay, exit-point blocking, file badges, and export-time enforcement
all stay. `usePoliciesEnabled` moves to its own module (core stub /
proprietary / desktop shadow with the SaaS-connection check) since it
still gates mounting the headless `PolicyAutoRunController` from the
rail.

## Why

Policies are configured in the admin portal now
(`src/portal/views/Policies.tsx`). Keeping a second management UI in the
editor rail meant two surfaces to maintain for one feature; the editor
only needs to *enforce*.

## Notes for review

- The rail UI lived in the shared `core` `RightSidebar`, so this removes
it from every build flavour at once; the deleted `PoliciesSidebar`
module existed at the core (stub) / proprietary / desktop alias layers
and all three are gone.
- Every deleted module was verified to have zero remaining importers;
near-misses that stay: `enforcementQueue` (used by export enforcement),
`poll` (test-imported), `usePolicies` (used by auto-run).
- Net −3,900 lines.

## Testing

- `task frontend:check` green: typecheck, ESLint + dpdm, Prettier, all
1,196 tests.
- All build-variant typechecks pass (core / proprietary / saas /
desktop).
2026-07-10 11:55:59 +00:00
ConnorYoh 75ea3c9a1f Portal procurement: pricing realignment, combined accept flow, licence & invoice fixes (#6946)
## What this PR does

Brings the enterprise procurement flow in line with the new D71 pricing,
tidies up the buyer journey, and fixes a handful of things we found
testing it end to end.

### Pricing
- Priced on the new run-based model (per PDF, per policy), USD only.
Dropped the old currency picker.
- Added the policy posture choice (Essentials / Governed / Regulated)
and show roughly how many policies each covers (~2 / ~4 / ~7).
- The live estimate in the quote builder now matches the real quote the
backend produces.
- Contracts renew each year with a fixed 3% increase. The agreement
shows this plus the first renewal figure, and we save that figure on the
quote so it can't drift later.

### Trial and journey
- Starting a trial now asks for your deployment (Cloud / Self-hosted /
Air-gapped) and team size up front, and that seeds the quote.
- Quote and agreement are now one step: you review the quote and the
agreement together and click "Accept & subscribe" once. No more
accepting a quote and then separately signing.
- "Start a trial" on the home page opens the setup popup right there
instead of sending you off to another page.
- The calculator asks for number of users again and works the volume out
from that.
- Removed the demo-only buttons (reset, simulate payment) and the "Key
documents" button (it wasn't real).
- The licence key now lives behind its own "Licence key" button instead
of being shown inside every popup.

### Air-gapped licence file
- Air-gapped teams can download their licence file (.lic) during the
trial, not only after they pay.
- The popup warns that a trial file needs re-downloading once the
agreement is done, because the file is a snapshot and doesn't refresh
itself the way the online key does.

### Fixes found while testing
- Accepting a quote now upgrades the licence from trial to full straight
away (it wasn't before).
- The "Download invoice" button keeps working after a page refresh (we
now save the invoice PDF link).
- Invoice line items read differently from each other instead of all
showing the same name.

### Notes for reviewers
- The matching backend changes (Stripe quote/accept functions, database
migrations) live in the Stirling-PDF-SaaS repo on `v3`. They ship when
we do the full v3 release.
- All checks are green.
2026-07-10 11:51:00 +00:00
EthanHealy01 7529190587 fix(ui): shared Button content-sizing + padding props, and button call-site cleanups (#6914)
## Summary

A batch of shared **design-system** fixes (Button, SegmentedControl,
Chip, a new CarouselDots) and the consumer/call-site cleanups they
unlock, following the button consolidation (#6787). Also includes
dark-theme token alignment and some portal/auth polish that rides on the
same components.

The shared Button now sizes to its content instead of clipping it, gains
per-axis padding controls, and no longer misbehaves while loading or
disabled; several call sites are then migrated onto the proper component
APIs.

## Shared components (`core/ui`)

### Button
- **Content-driven height.** `--button-height` is now a `min-height`,
not a fixed cap. Single-line buttons still land exactly on the shared
control-height scale (pixel-aligned with `ActionIcon` /
`SegmentedControl`), while taller content — wrapped labels, stacked
title + subtitle rows — grows the button instead of being clipped
mid-glyph. Short content is re-centered with `align-content`,
**without** overriding the root `display`, so a consumer's own layout
(e.g. a full-width list row) isn't disturbed.
- **Padding props.** New `p` / `px` / `py` props
(`none`/`xs`/`sm`/`md`/`lg`/`xl`) override the size-based padding per
axis. Vertical padding is applied through a `--sui-btn-py` CSS variable,
so consumers can also set it from their own class.
- **Loading no longer collapses.** A `fullWidth` button is never treated
as icon-only, so an execute button whose label is momentarily absent
while files hydrate (e.g. `ScopedOperationButton`) keeps its full width
with a centered spinner instead of shrinking to an icon-sized square for
a split second.
- **Disabled in dark mode.** A disabled *primary* button keeps a muted
version of its own accent fill (`opacity: 0.55`) instead of Mantine's
near-black `--mantine-color-disabled`, which blended into dark surfaces
and made the button all but disappear. Loading spinners are excluded so
they stay full-strength.

No breaking API changes — buttons that don't opt in render exactly as
before.

### SegmentedControl
- Fixed a bug where a segment marked `disabled` that also happened to be
the currently-selected value was rendered disabled, leaving the active
segment un-selectable/greyed. A disabled option is now only disabled
when it isn't the current value.

### CarouselDots (new)
- New shared dots indicator component (with Storybook story), used by
the login carousel.

### Chip / theme
- Dark-theme tokens in `theme.css` aligned to the portal's `tokens.css`
so the editor and portal (Processor) dark modes stop drifting (chrome
surfaces lift off the darker canvas); plus a Chip dark-mode styling fix
and a small `mantineTheme` cleanup.

## Consumer / call-site cleanups

- **Compare** tool: the swap control is now a regular shared Button
placed **between** the Original and Edited file cards (the bespoke
full-height vertical swap button and its CSS were removed), and the file
cards fill the full available width.
- **Certificate format**: replaced the inline-styled buttons with clean
two-state (primary / secondary) buttons.
- **ToolPicker**: restored the label selectors that #6787 renamed to the
never-emitted `.sui-btn__label`, and fixed the sidebar-search row
clipping.
- **File sidebar**: "View all files" row fix; `FileSidebarFileItem`
migrated off `display:flex` + `gap` on the Button root (which no longer
reaches the nested label) onto `leftSection` / `rightSection` + a
stacked label.

## Portal / auth polish

- Portal button consolidation and styling across Header, SettingsModal,
Home, Infrastructure, ApiKeyCard, and PopularUseCases.
- **Login**: onboarding text now shows the default starting username /
password; login carousel uses the new CarouselDots; desktop OAuth
styling tweak.

## Verification

- Storybook: button sizes measure exactly on the control-height scale
and match `ActionIcon`; icon-only buttons stay square and centered;
`fullWidth` loading buttons hold full width; disabled dark-mode primary
buttons render as a muted accent rather than grey.
- Single-line buttons are pixel-identical before/after; only buttons
whose content previously overflowed a fixed height render differently
(they now fit rather than clip).
- `task frontend:lint` clean; typecheck shows only the pre-existing
third-party `node_modules` noise also present on `main`.
2026-07-10 10:26:47 +00:00
Anthony Stirling b9f9f84907 Route portal Users page to SaasTeamController on SaaS via usersBackend seam (#6940)
## Why

The portal Users page worked on self-hosted but **403'd on SaaS**. It
called the proprietary admin endpoints (`/api/v1/user/admin/*`,
`/api/v1/team/*`, `ui-data/admin-settings`), all `hasRole('ADMIN')`.
SaaS users are always `ROLE_USER` (never `ROLE_ADMIN`), so those
endpoints reject them. This is the last SaaS-release blocker for the
portal.

## What

Route the SaaS build's Users page to the **existing**
`SaasTeamController` (invitation-based team management) - no new
backend. Done via a build-time flavor seam, mirroring the existing
`usersCapabilities` pattern.

- **New seam `@app/portal/usersBackend`** (interface in
`portal/api/usersBackend.ts`) with two impls resolved by the `@app/*`
alias:
- `proprietary/portal/usersBackend.ts` re-exports the existing
admin-endpoint functions - **self-hosted behaves exactly as before**.
- `saas/portal/usersBackend.ts` calls `SaasTeamController`
(`/api/v1/team/*`) via `apiClient.local` (already flavor-aware: SaaS
backend + Supabase JWT). Resolves the leader's team from `GET
/api/v1/team/my`, maps `TeamMemberDTO`/`InvitationDTO` onto the portal
`Member`/`PendingInvitation` types.
- **`manageInvitations` capability** (SaaS `true` / self-hosted `false`)
gates a new **Pending invitations** panel (list from `GET
/{teamId}/invitations`, Cancel via `DELETE
/api/v1/team/invitations/{id}`).
- **Remove re-enabled on SaaS** (was gated off): the roster remove
action now works at team scope against `DELETE
/{teamId}/members/{memberId}`, with a flavor-aware label ("Remove from
team" vs "Remove from org") and confirm copy.
- Invite (email) and rename routed through the seam (`POST /invite`,
`POST /{teamId}/rename`); `fetchAuthConfig` on SaaS is static (no
spurious admin-endpoint 403).
- **MSW handlers** (`mocks/handlers/teamSaas.ts`) mirror the controller
so the SaaS Users page is exercisable in mock mode. Registered in
`handlers` but deliberately **not** `embeddedDataHandlers` (would clash
with the editor's own `/api/v1/team/*` routes when portal shares its
origin).

## Constraints honoured

- No new backend endpoints - reuses `SaasTeamController`.
- Self-hosted path unchanged (proprietary impl re-exports the same
functions).
- No SaaS user is ever `ROLE_ADMIN` - `adminRole`/admin-only UI stay
hidden.

## Notes from an adversarial self-review (both fixed in this PR)

- Solo SaaS users' auto-created **personal team** now hides the Rename
control (the backend rejects renaming personal teams with 400) -
`isPersonal` threaded through `Team`/`TeamGroup`.
- Expired-but-still-`PENDING` invitations are filtered in the adapter,
and the expiry label no longer mislabels a just-expired invite as
"Expires today".

## Testing

- `task frontend:typecheck:all` - all 8 flavors pass.
- Portal vitest project: **122 passing** (added SaaS adapter +
shape-mapping tests via MSW, PendingInvitations panel, and
remove/manageInvitations/personal-team gating).
- `task frontend:lint` (ESLint `--max-warnings=0` + dpdm no circular
deps) and prettier clean.

## Open questions

- **Team resolution on SaaS**: I resolve the leader's single manageable
team (prefer a real non-personal team they lead). If a leader owns
multiple real teams, only the primary is shown - matches the "single
team" framing in the spec; flag if multi-team management is wanted.
- **`isSelf` on SaaS** uses the LEADER role (the portal Users page is
leader-only on SaaS, so the leader row is the viewer). Verified there's
no multi-leader creation path today; revisit if that changes.

Draft - not marking ready until reviewed.
2026-07-10 09:22:05 +00:00
Anthony Stirling 68ec176719 Portal empty states: add CTAs and hide stat boxes (#6952)
# Description of Changes

Empty-state polish across the four processor (portal) list pages, so a
fresh workspace gets clear next steps instead of a row of zeroed-out
stat boxes.

- **Sources / Pipelines** - hide the KPI stat strip when the list is
empty; the empty state now shows an icon plus a primary + secondary CTA
(Connect source / Read the docs; Create a pipeline / Connect a source).
Also closes a gap where a successfully-fetched empty list rendered stat
boxes over a blank page with no empty state at all.
- **Policies** - hide the summary stat strip until at least one policy
is configured; the catalogue cards stay as the "configure a policy"
CTAs.
- **Documents** - hide the filter-pill + search toolbar on an empty
queue; the empty state gains an icon plus Create a pipeline / Connect a
source CTAs.
- **Storybook** - added `Default` + `Empty` stories for all four views;
the preview now loads the real English copy so stories render shipped
text rather than raw i18n keys.

---

## 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)
- [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)
- [x] 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.
2026-07-10 08:54:05 +00:00
Anthony Stirling d17c3f4fec Portal: wire remaining hardcoded strings to i18n (#6953)
# Description of Changes

- Audited every portal page/component for hardcoded UI strings not
routed through `t()`
- Wired the remaining ones to i18n (~80 new `portal.*` keys in
`en-US/translation.toml`):
- Infrastructure status/label maps (deploy, api-key, cert, key-mode,
attestation, audit, model, region, environment) + API-key permissions
- Procurement "Key documents" modal, editor-admin deploy targets, users
seats label, pipeline output-folder placeholder
- Follows the existing house pattern: label maps store i18n keys,
resolved via `t(MAP[value])` at the render site
- Documents CSV export now reuses the on-screen column keys, and fixes a
latent bug where the exported status leaked the raw key instead of the
translated label
- No UI-copy change: en-US values are identical to the previously
hardcoded strings; other locales fall back to en-US as before

---

## 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)

- [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.
2026-07-10 08:54:02 +00:00
Anthony Stirling 783a51950f Update translations for 40 languages via GPT-5.5 (#6954)
# Description of Changes

- Adds and updates translations across **40 languages** (~1,400–2,200
keys each) using GPT-5.5, filling previously-missing UI strings.
- Switches the translation scripts' default model from the year-old
`gpt-5` (5.0) to `gpt-5.5`, adding a `--model` flag and token/cost
reporting.
- Purely additive and validated: no existing translations changed, all
40 files match the en-US key structure, and no new placeholder issues
introduced.

---

## 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)
- [x] 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)
- [x] 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)

- [x] 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.
2026-07-10 08:53:59 +00:00
Reece Browne 51d3d27fd3 Portal policies: SUI setup forms fixes and improvements (#6927)
## What this does

Reworks the portal's policy setup screens so a policy reads as **its own
settings**
rather than a list of tools you wire together, and rebuilds the forms on
the
shared design system so they match the rest of the portal.

## Why

Setup showed one card per underlying tool (tool name + a toggle), which
exposed
the "a policy is a pipeline of tools" plumbing. A policy should read in
terms of
what it does to a document, not which tools run under the hood.

## Changes

- **Setup reads as policy settings.** The per-tool cards are now a
plain-language
list of what the policy does — "Redact sensitive information", "Strip
active
content", "Apply a watermark", and so on — each with a short description
and a
  toggle, with its options appearing inline when turned on.
- **Consistent design system.** The setup and edit forms use the shared
  components instead of one-off styling.
- **Simpler setup.** Removed two sections that aren't part of what ships
here:
  Document Types (scope-by-type) and Retries.
- **Watermarks are text-only.** A policy watermark is a text stamp, so
the image
  option and the type picker are hidden.
- **The editor always shows as a source** (it used to disappear when no
other
  sources were connected), and the source tiles now lay out correctly.
- **Clearer upsell copy.** Locked policies read **"Upgrade to
Enterprise"**
  instead of "Coming soon".

## Scope

UI only — no backend changes. Keeping a policy's settings in sync
between the
portal and the editor is a known, separate issue and is **not** part of
this PR.

## Testing

Prettier, ESLint, typecheck (proprietary + saas), and the
unused-translation
guard all pass. Setup screens verified in Storybook.
2026-07-09 19:13:10 +00:00
Reece Browne ccfd22b2a9 port editor settings into portal (#6945)
The portal's `SettingsModal` was a parallel, mock-backed settings
implementation. It's replaced by the editor's `AppConfigModal`, mounted
via a new `PortalSettingsHost` that supplies the contexts the portal
doesn't have (app config, flavor-resolved session, preferences, editor
theme). Flavor resolution does the rest: the self-hosted portal gets the
admin sections, the SaaS portal gets the saas shell. The self-hosted
account-link panel rides in through the existing seam as an extra
section.

The shell gains three host props (`urlSync`, `initialSection`,
`extraSections`); editor behaviour is unchanged. Net −1,300 lines.

Manually verified on both flavors against live backends.
2026-07-09 16:35:30 +00:00
James Brunton a5ee329c36 Further improvements to policies file tracking (#6941)
# Description of Changes
Fixes requested in review of #6903
2026-07-09 15:43:38 +00:00
Reece Browne 2091874050 Remove in-app portal mocks (#6910)
The portal no longer uses mock data — it always talks to the real
backend. Mocks still power Storybook and tests.

- Mocks button and all the in-app MSW machinery removed.
- Types the app needs moved out of mock files and into the api layer, so
the app no longer depends on `mocks/` at all.
- One deliberate exception for the onboarding tour (#6926):
`enablePortalDemoData()` fills the views with example data while a tour
runs, with zero cost the rest of the time.

Heads up: views without a real backend endpoint yet now show empty/error
states in dev.
2026-07-09 14:50:50 +00:00
ConnorYoh 22e8a82fa1 Portal: add 'Open in browser' CTA to the welcome hero (#6944)
## Screenshots

<img width="2522" height="1322" alt="image"
src="https://github.com/user-attachments/assets/010e2dce-00ae-4c7f-8ec8-7e6519beb4cd"
/>
2026-07-09 14:47:37 +00:00
James Brunton 01751bf2f0 Improve logic for tracking which files have already been processed in policies (#6903)
# Description of Changes
Replaces the `.stirling/done` folder and its friends with a ledger in
the DB which tracks which documents have been processed. This should
scale dramatically better since it's just a few bytes being written for
each PDF processed, rather than each PDF being duplicated and held in
the folder forever. It's designed to work with the current folder
source, but also with S3 buckets and other sources in mind - each source
will define its own strategy for ensuring it knows whether the documents
have had policies run on them or not, and they all get written to the
same ledger.
2026-07-09 12:07:26 +00:00
ConnorYoh 119eb1f5ad Portal: move the admin route from /portal to /processor (#6933)
## What this changes

Moves the admin portal's browser route from **`/portal`** to
**`/processor`**, to match the "Processor" product name (the in-app app
switcher already says "processor").

- `PORTAL_BASENAME` `"/portal"` → `"/processor"` — the single source of
truth for all portal paths on the frontend, so every `toPortalPath(...)`
link and redirect follows automatically.
- `adminRouteExtensions` now mounts at `` `${PORTAL_BASENAME}/*` ``
instead of a hardcoded `"/portal/*"`, so the route can't drift from the
constant.
- **Backend:** `RequestUriUtils.isStaticResource` now treats
`/processor` (not `/portal`) as the SPA shell, so a direct navigation or
hard refresh to `/processor` serves the app instead of 404ing. (This is
the only backend URL reference — there's no Spring Security matcher for
it.)
- Updated the two portal route tests and the doc comments that named the
old path.

**Not changed:** the `@portal/*` import alias — that's the code layer /
flavor-layering path, not the URL. Renaming it would be a much larger,
unrelated churn.

The portal isn't publicly launched yet, so there are no existing links
to preserve — no redirect from the old path is included.

## Testing
- `task frontend:typecheck:all`, full test suite (1,211), lint, format —
green
- `./gradlew :common:test --tests "*RequestUriUtilsTest"` — green
2026-07-09 12:01:48 +00:00
ConnorYoh d3638d786d Portal home: cut the mock blocks, wire the rest to real data (#6931)
## What this changes

Cleaning up the portal home page. Most of what was under the plan card
was mock — it hit endpoints that don't exist on any backend, so on SaaS
it just showed a wall of "—" and "Nothing here yet". I removed the fake
stuff and wired the bits worth keeping to real data.

**Scrapped (all mock, no backend anywhere — self-hosted included):**
- The "No usage yet" usage chart
- The KPI strip (Docs/30d, Pipelines, Agents active, Eval pass rate)
- The "Build a pipeline in seconds" fork wizard (fake build animation,
deploy was a TODO)
- The Sources/Pipelines/Agents product cards
- The "Popular use cases" marketing cards
- Enterprise region health
- The "Try a PDF operation" runner

Deleted their component/api/mock/MSW/story files too, plus the now-dead
i18n keys and CSS.

**Wired to real data (finished, not removed):**
- The plan strip and the sidebar footer now show the **real** 30-day
processed-PDF count from `/api/v1/usage/fleet-stats` (was a mock KPI).
Shows "—" honestly when the backend can't compute it, never a fake
number.
- **Recent activity** now reads the **real audit log** — the same
endpoint the Infrastructure → Audit tab uses.

**Result:** one simple layout for all tiers — plan strip → recent
activity + quick actions → "What runs on your PDFs" (the real policy
summary). Every block is backed by data that actually exists.

Net: **−3,221 / +89 lines**, 17 files removed.

## Testing
typecheck (all variants), full test suite (1211), saas + proprietary
builds, lint, format, storybook build, toml-sort — all green. The
`unusedTranslations` test guarantees no orphaned i18n keys were left
behind.
2026-07-09 11:58:32 +00:00
ConnorYoh e5a258a648 Dev: redirect bare subpath /app → /app/ when RUN_SUBPATH is set (#6934)
## Problem

With `RUN_SUBPATH=app`, the app is served under base `/app/`. Vite
serves `index.html` at `/app/` and redirects `/` → `/app/`, but a bare
**`/app`** (no trailing slash) returns **404** — so you had to type
`localhost:5173/app/` to load the app. `/app` should work too.

## Fix

A small dev + preview middleware that **301-redirects `/app` → `/app/`**
(query string preserved), so either form loads the app. Only active when
`RUN_SUBPATH` is set; no-op otherwise.

Also routed the vite `base` through the same slash-stripped `runSubpath`
value the middleware uses, so a stray `RUN_SUBPATH=/app/` can't produce
a doubled `//app//` base.

## Verified (dev server + prod build, `RUN_SUBPATH=app`)

| Request | Before | After |
|---|---|---|
| `GET /app` | 404 | **301 → `/app/`** |
| `GET /app?foo=1` | 404 | **301 → `/app/?foo=1`** (query kept) |
| `GET /app/` | 200 | 200 (unchanged) |
| `GET /` | 302 → `/app/` | 302 → `/app/` (unchanged) |

Production build under the subpath still emits `<base href="/app/">` and
`/app/assets/...`. Lint + format green.
2026-07-09 11:57:58 +00:00
EthanHealy01 c64369e56c Classifier Policy (#6898)
## Overview

Adds **AI document classification** and a **classification-aware Files
sidebar**: uploaded documents are automatically tagged with
document-type labels (Invoice, Contract, Lab report, …), and the sidebar
groups files under editable parent categories so a large library stays
navigable.

> [!IMPORTANT]
> **This feature only runs in the SaaS build.** Classification depends
on the AI engine and team-scoped label storage, so it's gated to SaaS
end-to-end:
> - The sidebar grouping is a `saas/`-layer override of the
`fileSidebarGrouping` seam; every other build (OSS core, self-hosted
proprietary, desktop) gets the null stub and renders the **unchanged
flat, recency-sorted list** — no categories, no "Other", no picker.
> - The classify/labels backend endpoints are gated on
`policies.enabled` (on in SaaS) and live in `app/proprietary`, so
they're absent from pure OSS and dormant in self-hosted unless
explicitly enabled.
> - The Python classifier is reached only via that gated path.
>
> Shared-layer changes that do compile everywhere are inert without the
engine (dormant schema/field additions) or intentional (`GetInfoOnPDF`
surfacing custom metadata).

## What it does

- **Classifier (engine):** reads the first/last two pages of a PDF and
assigns document-type labels from an allowed vocabulary. Labels are
deliberately document-*type* descriptors — no deep-content/PII
detection, since only a page window is read.
- **Team label vocabulary:** ~270 built-in defaults across ~15 families,
seeded per team. Editable by team leaders/admins in the Classification
policy settings (import/export/reset). Team-scoped and shared;
**per-user personal labels are intentionally out of scope** — the
vocabulary is team-level only.
- **Sidebar categories:** files group under parent categories
(Financial, Legal, Medical, …), busiest-first, collapsible, with a
"Recent" group on top and an "Other" group for anything uncategorised.
The category structure (names, icons, membership, custom categories) is
**device-local and user-editable** via a "Customize" picker — the only
per-user personalization; it never changes the team's label vocabulary.
- Classification results are written to PDF metadata
(`StirlingPDFClassification`), read back to keep files in their groups
without re-parsing.

## Architecture

Spans all three layers, mirroring the existing policy/source subsystem
conventions:
- **`frontend/editor`** — sidebar grouping seam + SaaS override,
category manager, labels editor, icon palette, file grouping, tests,
`en-US` i18n.
- **`app/proprietary` + `app/common` + `app/core`** —
`ClassifyLabelController`, team-scoped `ClassificationLabelStore` (Jpa +
in-process impls, same shape as `PolicyStore`/`SourceStore`), metadata
read/write.
- **`engine`** — the document-classifier agent, contracts, routes,
tests.

## Screenshots

**Files sidebar — grouped by category (SaaS)**

### Loading view

<img width="2056" height="1046" alt="Screenshot 2026-07-07 at 5 12
56 PM"
src="https://github.com/user-attachments/assets/1d712da5-50ae-4349-b0cd-e62665c3ec0c"
/>

### Organized in the sidebar

<img width="2056" height="1045" alt="Screenshot 2026-07-07 at 5 14
05 PM"
src="https://github.com/user-attachments/assets/3ea4fe21-da51-4cea-bc3a-18ce040d3d05"
/>

**Customize categories picker**
### Personal settings to change how labels are grouped in an individual
users editor

<img width="2056" height="1044" alt="Screenshot 2026-07-07 at 5 52
42 PM"
src="https://github.com/user-attachments/assets/40be03ce-0f63-4d1e-b58b-cec045d01cb2"
/>

**Classification labels editor (team settings)**

<img width="2056" height="1042" alt="Screenshot 2026-07-07 at 5 53
00 PM"
src="https://github.com/user-attachments/assets/337b0739-15c9-4749-9c6b-22e3b20825b8"
/>

## Testing

- Frontend `task frontend:check` — green (editor + portal tests,
typecheck across all flavors, lint, label-drift guard).
- Backend `task backend:check` (proprietary) and `:saas:test` — green.
- Engine `task engine:check` — green.
2026-07-09 11:47:37 +00:00
James Brunton f29500c138 Disable Portal UI for guests (#6936)
# Description of Changes
Disallow SaaS guests from accessing the portal. One day we might want to
make this better so they can go there but then have to sign up before
doing anything useful, but this is the easiest way to disallow it for
now.
2026-07-09 11:44:19 +00:00
Anthony Stirling 9d11918bd8 Add Storybook preview deploy + changed-stories comment on PRs (#6929)
## What

Adds a **Storybook preview** for PRs. When a PR changes any story
(`*.stories.{ts,tsx,mdx}`) or the `.storybook` config, this builds the
static Storybook, deploys it to the preview VPS on a PR-scoped port, and
comments with the URL plus an **expandable list of exactly which stories
changed**. Torn down automatically when the PR closes.

New file: `.github/workflows/storybook-preview.yml`. Nothing else is
touched.

## How

- **Detect** (`changes` job) - `dorny/paths-filter` with `list-files:
json` flags Storybook changes and captures the exact changed files.
Skipped on close and for fork PRs (which don't get the VPS secrets).
- **Deploy** (`deploy` job, only when Storybook changed) - builds the
static Storybook (`task frontend:prepare` + `frontend:storybook:build`),
tars it, and serves it from an `nginx:alpine` container on the VPS at
port `PR# + 20000` (offset from the app preview's bare-PR-number port to
avoid collisions). Mirrors `PR-Auto-Deploy-V2.yml`'s VPS SSH pattern and
reuses the same secrets.
- **Comment** - a single bot comment (replaced on each push) with the
preview URL and a `<details>` block listing the changed stories (and any
`.storybook` config changes), e.g.:

  > ## 📚 Storybook preview
  > 🔗 **Preview:** http://&lt;vps&gt;:26911
> <details><summary>2 stories changed (+1 config
file)</summary>…</details>

- **Cleanup** (`cleanup` job, on PR close) - stops the container,
removes the files, and deletes the comment.

## Validation

- Static Storybook builds locally (`task frontend:storybook:build` →
`frontend/storybook-static`, 151 stories).
- Confirmed `task frontend:prepare` regenerates the un-committed
`material-symbols-icons.json` that stories import, so a fresh CI
checkout builds (added it before the build step).
- Comment-markdown logic unit-checked against a sample changed-files
list.
- YAML validated; action pins match the repo (`setup-node` v6.4.0 / node
22, same `paths-filter`, `setup-bot`, `harden-runner`).

## Note

The VPS deploy mirrors the proven `PR-Auto-Deploy-V2` machinery but
couldn't be exercised end-to-end from a dev box (needs the VPS secrets)
- the first live run on a Storybook-touching PR will confirm the
deploy/serve/cleanup path. Everything build- and comment-side is
validated locally.
2026-07-09 10:51:45 +00:00
Anthony Stirlingandaikido-pr-checks[bot] 8d2bb14f99 Add portal user management and access control (#6913)
# Description of Changes

Portal access control + user management
What this does

- Adds server-side portal access enforcement: a ResourceGrant ACL (owner
→ admin → grant → default policy) gates the portal via
@resourceAccess.canUsePortal(), so access is authoritative on the
backend, not just hidden in the UI.
- New proprietary/access module: ResourceAccessService +
ResourceAccessSecurity, PrincipalResolver (default + SaaS + team-lead
lookup), OwnershipService, ResourceGrantController, and a SecretMasker
for safe config display.
- Exposes an authoritative portalAccess flag on /me (AuthController /
AdminUserSummary); drops the old org-principal shortcut.
- Full portal Users page: team + member management (members table,
invite, move-to-team, new/rename team, reset password, access controls,
confirm modals) wired to real user/team/grant endpoints.
- Per-flavor capabilities seam (usersCapabilities): self-hosted
org-admin gets everything; SaaS is trimmed to what a team leader can do
(no ROLE_ADMIN ever surfaced).


SaaS blockers (separate follow-up PR)
The portal Users page works on self-hosted but 403s on SaaS (it calls
the admin API hasRole('ADMIN'), and SaaS users are ROLE_USER). To ship
the portal on SaaS:

- Add a @app/portal/usersBackend seam and point the SaaS build at the
existing SaasTeamController (no new backend).
- Resolve the leader's team-id on SaaS and map member/invitation shapes
to the portal Member type.
- Add pending-invitation management (list + cancel) - the parity gap vs
the editor.
- Re-enable the roster remove action on SaaS against
SaasTeamController's remove-member endpoint.


<img width="1426" height="464" alt="image"
src="https://github.com/user-attachments/assets/7a441a35-7a57-472f-a8c7-e6d8ae998439"
/>

<img width="492" height="722" alt="image"
src="https://github.com/user-attachments/assets/d4e8a088-b2eb-4326-9e00-7ada6eb72a85"
/>

---

## 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.

---------

Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-07-09 10:29:26 +00:00
ConnorYoh b53aaa7d03 Portal procurement: enterprise licence-key mechanism (generate at trial, upgrade on subscription, view/download) (#6902)
Builds on the procurement vertical slice (#6861). Adds the **enterprise
licence-key mechanism**: a Keygen licence is generated at trial,
upgraded in place when the committed subscription is created, and is
viewable/downloadable in the portal. Flag-gated — ships with the mock as
default until Keygen env vars are wired.

### What it does
- **Offline / air-gapped licence** is a new **priced add-on** on the
quote ($12k/yr, flat, alongside indemnification / training / QBR).
- **Licence key visible from the trial step** — the portal shows the key
with **Copy**, and (when the offline add-on is bought) a **Download
offline licence (.lic)** button.
- **Real Keygen client, called directly from Java**
(`KeygenEnterpriseLicenseService`), behind `stirling.keygen.enabled`;
`MockEnterpriseLicenseService` stays the default. All creds are env vars
(`STIRLING_KEYGEN_*`) — nothing committed.
- **Provisioning is driven by the Stripe `customer.subscription.created`
event** (source of truth), not a UI action — so a sales-led deal entered
manually in Stripe provisions a licence too. The webhook calls a new
admin `POST /api/v1/procurement/provision`, which upgrades the trial
licence **in place** to the committed annual term, **valid immediately**
(no wait for payment). The deal stays in the payment step so the
outstanding invoice remains visible.
- Offline `.lic` is checked out `base64+ed25519` (signed, unencrypted)
so the self-hosted `KeygenLicenseVerifier` validates it fully offline.

### Not in scope (deliberate, follow-ups)
- Cloud entitlement flip — a **cloud** customer sees/downloads the key
but the running cloud product doesn't unlock yet (self-hosted/air-gap
**are** unlocked by the key). Immediate next PR.
- `invoice.paid → fully-live` / `payment_failed → suspend` webhook
safety-net.

### Companion change (separate repo)
- The Stripe-webhook wiring that calls `/provision` lives in the
**Stirling-PDF-SaaS** repo (committed on `v3`, not part of this PR):
`stripe-webhook` routes enterprise-committed `subscription.created` →
`provisionProcurement()` → the Java admin endpoint.

### Prod setup required
- Create the committed-enterprise **Keygen policy with
`scheme=ed25519`** under the existing account, set
`STIRLING_KEYGEN_ENABLED=true` + account/token/policy env vars.

### Verified
saas `:saas:test` (procurement) · portal typecheck / eslint / prettier ·
82 portal tests · `deno check` on the webhook handler.

### Review follow-ups (PR review, tracked)
Low-hardening fixes applied in `85369633ed`: keep Keygen response bodies
out of thrown/logged messages; fail-fast at startup when the flag is on
but creds are missing; gate the offline `.lic` on the *accepted* quote
(not the latest draft).

Deliberately deferred, tracked here:
- **Pre-flag verification.** Before `stirling.keygen.enabled=true`,
confirm the id-vs-key addressing against live Keygen. (The shipping
self-hosted edge addresses licences by URL-safe key in the path and
Keygen docs allow it, so the client mirrors that — but confirm
empirically with the real committed-enterprise policy.)
- **No auto-revoke on non-payment.** Provision issues an
immediately-valid annual licence before payment settles; `invoice.paid →
live` and `payment_failed → suspend` are out of scope here. Note the
offline `.lic`, once downloaded, verifies offline for the full term and
**can't be revoked** — so the real mitigation for the offline case is a
shorter bridge term until `invoice.paid`, not just wiring `suspend`.
Enterprise is sales-led/ADMIN-gated, so this is a collections concern,
not mass abuse.
2026-07-08 19:31:26 +00:00
ConnorYoh 3fa0f30d43 Portal: prep for SaaS launch — hide unfinished sections, fix api client, docs link (#6921)
## What this changes

Getting the portal ready to show the world on SaaS. A few things bundled
in here:

**Developer docs tab** — now opens https://docs.stirlingpdf.com/ in a
new tab instead of taking you to an empty page (we haven't built the
in-app docs page yet).

**Hid the bits that aren't finished yet — SaaS only:**
- Took the Agent Builder button off the Sources page.
- Removed the Components page.
- Infrastructure: the tabs that aren't ready (Deployments, Security,
Models, Storage) are greyed out as "coming soon". API keys and Audit
stay live. Also dropped the "Manage editor deployment" button.
- Removed the floating AI assistant blob.

**Fixed the SaaS api client.** Before this, only the usage/billing page
actually reached the backend — everything else (sources, users,
policies, etc.) was going to the vite dev server with the wrong login,
so it never worked. Now every portal call goes to the one SaaS backend
using the Supabase login.

Self-hosted is left exactly as it was — all the SaaS hides go through
the saas override layer, so self-hosted still shows everything.

## Testing
typecheck (all variants), full test suite, both builds, lint + format —
all green.
2026-07-08 16:39:37 +00:00
Anthony Stirling 9ea848570f Wire portal audit tab and documents to real audit data (#6912)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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.
2026-07-08 15:31:01 +00:00
James Brunton d6061eb0aa Support tool selection in Pipelines page in Portal (#6905)
# Description of Changes

Redesigned the Portal Pipelines page so pipelines are created and edited
on their own dedicated builder page, replacing the previous modal
composer and inline detail card.

## Screenshots

### Pipelines list
The redesigned list with summary KPIs; each row opens that pipeline's
own page.

![Pipelines
list](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/01-pipelines-list.png)

### Pipeline builder
The dedicated create page: pipeline settings (sources, trigger, output)
above, operations and per-tool settings below.

![Pipeline
builder](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/02-builder-new.png)

### Tool picker
Type-to-filter, category-grouped picker for adding an operation to the
pipeline.

![Tool
picker](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/03-tool-picker.png)

### Editing a pipeline
An existing pipeline in the builder: reorderable steps, per-tool
settings, and run/delete actions.

![Editing a
pipeline](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/04-builder-edit.png)
2026-07-08 15:23:04 +00:00
ConnorYoh 1759e0bdd5 feat(portal): wire procurement "Schedule a call" to Calendly (#6920)
## What

The procurement flow's **Schedule a call** action (deal-status hero →
side modal) was a mock: a fake "SE" avatar and four hardcoded time-slot
buttons that just closed the dialog. This wires it up to the real
Calendly booking widget the admin provided.

## How

- **New `CalendlyInline` component**
(`portal/components/procurement/CalendlyInline.tsx`)
- Lazily loads `assets.calendly.com/assets/external/widget.js` via the
existing `@app/utils/scriptLoader` — only when the modal actually opens,
deduped across reopens.
- Calls `Calendly.initInlineWidget()` explicitly so it rebuilds on
reopen / theme change.
- Colours track the portal's light/dark theme (`useTheme`) via
Calendly's `background_color` / `text_color` / `primary_color` params,
mapped to the portal design tokens (surface / text-1 / primary), plus
`hide_event_type_details=1`.
- Graceful fallback to an "open in a new tab" link if the script fails
to load.
- Base URL overridable via `VITE_CALENDLY_URL` (defaults to the
group-discussion link).
- **`ScheduleCallModal`** now renders `<CalendlyInline />` instead of
the mock; copy moved into i18n (`portal.procurement.schedule.*`).
- `SideModal` gains a `wide` variant so the embed has room; removed the
now-dead `.portal-se*` / `.portal-slots*` CSS and `SLOTS` constant.

## Notes / follow-ups

- No app-level CSP blocks `calendly.com`, so the embed loads without
config changes.
- Verified with the portal typecheck (`tsc -p src/portal/tsconfig.json`)
and ESLint on the changed files; only pre-existing Storybook/msw dev-dep
type errors remain.


<img width="1160" height="642" alt="image"
src="https://github.com/user-attachments/assets/d9b5d92d-ea7e-4862-9f35-a71f65392a2c"
/>
<img width="3744" height="1990" alt="image"
src="https://github.com/user-attachments/assets/0b8002c6-dd3e-41e6-8e8b-6faf6090314c"
/>
<img width="2620" height="1928" alt="image"
src="https://github.com/user-attachments/assets/781b7a60-7065-4a7a-b9f7-1cdb0dece7b3"
/>
2026-07-08 15:06:55 +00:00
ConnorYoh 514b020f74 Portal: real Free PDF Editors usage card (self-hosted) (#6919)
## What

Replaces the **mocked** "Free PDF Editors" fleet card on the portal
Usage page with live figures. Cost stays a literal `$0`; any figure that
can't be computed renders **N/A** (never a misleading 0).

| Metric | Self-hosted source |
|---|---|
| **Editors deployed** | total users (`UserRepository.count()`) |
| **Active this month** | distinct `source=WEB` principals active in 30d
(excl. `UI_DATA` polling), clamped ≤ deployed |
| **PDFs edited** | cumulative `PDF_PROCESS` + `FILE_OPERATION` audit
events that are **free UI runs** |

## Why the counting approach

"Free operations = UI tool runs." Two dead ends first:
- **Billing/PAYG is the wrong source** — it *deliberately discards* free
ops (classified `BYPASSED`, no DB row); its tables only hold billable
(API/AI/automation).
- **Raw audit is also wrong** — a tool controller emits `PDF_PROCESS`
for UI **and** API/AI/automation calls, and billable traffic exists on
every tier.

So the count is **audit filtered to free UI runs**. Audit events gain a
`source` column, stamped from the always-on signal
`BillingCategoryClassifier.classify(...) == BYPASSED` (not API-key auth,
no `X-Stirling-Automation` header, not `/api/v1/ai/`) — zero
billing-module coupling. Captured on the request thread
(`AuditService.captureCurrentSource`), carried via MDC in
`ControllerAuditAspect` (same propagation as `requestId`), persisted by
`CustomAuditEventRepository`. The count filters `source = 'WEB'`.

## Endpoint

`GET /api/v1/usage/fleet-stats` — admin-gated, EE-only. Returns `null`
per field when EE auditing is off (→ N/A).

## Frontend

- New `portal/api/fleetStats.ts` → `apiClient.local` (this instance's
backend).
- `FreePdfEditorsCard` rewired to `useAsync(fetchFleetStats)`; preview
badge removed, `null`→"N/A", loading→"—".

## Tests

`:proprietary:build` green — `FleetUsageControllerTest` (4) and
`CustomAuditEventRepositoryTest` (+2 for source-from-MDC) pass; spotless
clean.

## Notes / follow-ups

- `deployed` currently counts all users incl. disabled — refine to
enabled-only later.
- **SaaS** (team-scoped endpoint + a `fleetStats.ts` override) is
deferred to a follow-up riding the portal-SaaS layering PR #6900.
- Depends on EE auditing running at `AuditLevel ≥ STANDARD` for the
audit-derived figures; otherwise they show N/A.
2026-07-08 14:42:44 +00:00
Reece BrowneandJames Brunton 18b0b19a67 Block file exit points while a per-file policy run is enforcing (#6904)
## What

While a per-file policy run is in flight, the editor now blocks every
way the file can leave the app, and shows why:

- **Viewer** — a blocking overlay with live progress ("Enforcing
policy…"). Dismissible: collapses to a corner badge (top right, tinted
with the policy's accent) so the file stays readable while the run
finishes.
- **Workbench bar** — Print / Download / Save As / Share are disabled
with an explanatory tooltip and progress bar. The Ctrl+P shortcut and
the form-fill bar's "Download PDF" button are covered too.
- **File lists** — the file sidebar, file-editor thumbnails, and files
page show a spinning shield badge on the affected file, and thumbnail
hover actions (download / upload to server) are blocked with the same
tooltip.

Once a run settles, everything unblocks — including FAILED and CANCELLED
runs. A failed check surfaces through the run's activity feed; it never
locks the user out of their file.

## Why

Upload-triggered policies exist so the enforced output is what leaves
the app. Before this, a file could be printed, downloaded, or shared
while its policy run was still processing.

## Also in here

- **One shared `PolicyBadges` component** — the sidebar, thumbnails, and
files page each had their own copy of the badge markup/CSS and had
drifted (different sizes, tints, missing spinner and glow on the files
page, hardcoded English tooltips). All badge surfaces now render the
same component: accent-tinted shield, spinner while enforcing, one-off
glow when recent, i18n'd tooltips.
- **Cascade fix:** outputs imported from reconciled
(server-rediscovered) runs are now tagged `derivedFromTool`, stopping an
auto-run → import → auto-run loop that produced ever-growing
`_sanitized_sanitized…` filename chains on fresh devices.
- **Core stub for `policyRunStore`** so the core build compiles —
`WorkbenchBar` and `ViewerShareButton` resolve `usePolicyRuns` via
`@app/*`.

## Testing

- `task frontend:check` green: proprietary typecheck, ESLint + dpdm,
Prettier, 915+ editor + 81 portal unit tests.
- `typecheck:core` / `saas` / `desktop` variants all pass.
- Enforcement flow exercised manually against a live backend with an
upload-triggered policy (overlay + progress during the run, dismiss to
corner badge, unblock on completion).

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-07-08 14:09:23 +00:00
ConnorYoh a8bda9240c feat(portal): replace free-tier carousel with static welcome hero (#6901)
## What this PR does

Redesigns the portal home to match the marketing demo, across all tiers.

- Swapped the old rotating welcome carousel for a static **welcome
hero** on the free tier
- Subscribed (Processor) + enterprise get a **deployed-editor hero** —
shows the live instance (host, version, active users) with an *Open in
browser* button, pulled from the real editor-deployment API (same data
the Editor admin page uses)
- Added a time-of-day **greeting** on the paid tiers
- Rebuilt the **"Finish setting up" checklist** so it's real: the counts
and tick-offs come from the actual policies + sources (a step is done
when there's at least one), not hardcoded numbers
- *Download the PDF Editor* → `https://stirling.com/download`; the other
steps deep-link to Policies / Sources
- **Procurement is now a bolt-on to any tier** — if a deal's in flight
the deal-status hero drops into the hero's footer, otherwise you get the
setup checklist
- All new copy is translated (en-US) and it reuses the shared UI kit,
icons and design tokens

## Tidy-ups / fixes found along the way
- The subscribed hero was shadowing the real `/v1/editor/deployment`
endpoint (broke the Editor admin page) — now reuses it
- Renamed the hero's CSS namespace to `.portal-welcome` so it stops
clashing with the procurement hero's `.portal-hero`
- Refactored the merged procurement component into a shared
`useProcurement` hook + banner + flow, so the deal hero can live inside
the tier hero — `/procurement` route unchanged

## Screenshots

<img width="1258" height="1338" alt="pr-free"
src="https://github.com/user-attachments/assets/9bb7db44-5f8e-4388-857a-7f113c2d7d82"
/>

<img width="1258" height="862" alt="pr-enterprise"
src="https://github.com/user-attachments/assets/f1f36faa-1467-404e-9036-dab12e3d0b54"
/>

<img width="1258" height="944" alt="pr-subscribed"
src="https://github.com/user-attachments/assets/775f1228-f182-47b5-82ff-7ac6d5932bd9"
/>

---

## Checklist

### General
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### UI Changes
- [x] Screenshots demonstrating the UI changes are attached

### Testing
- [x] Portal typecheck, ESLint and Prettier pass on the changed files
- [x] Verified all states in Storybook and ran the app locally via `task
dev:portal`
2026-07-08 13:44:30 +00:00
Anthony Stirling 0692058602 Embed admin portal as its own app in the jar behind buildWithPortal (#6911)
## What

Lets the admin portal ("Stirling Processor") ship **inside the JAR**,
gated by a build flag. On `main` the portal already exists as a lazy
`/portal/*` route in the editor but isn't included in production builds
and isn't reachable in a login-enabled server. This PR makes it a
**flag-gated, directly-navigable** part of the editor bundle, and wires
it into the PR preview deployment so it can be tried live.

It keeps the exact architecture `main` uses (portal = a lazy chunk of
the editor, not a separate app), so it inherits all the editor's global
providers/styles and there's no second build to maintain.

## How

**Frontend - gate the existing lazy route**
([`adminRouteExtensions.tsx`](frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx))
```ts
const includePortal = import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
const PortalApp = includePortal ? lazy(() => import("@portal/PortalApp")) : null;
```
Vite bakes the env to a literal, so when off the dynamic import is
**tree-shaken out entirely** (no `PortalApp` chunk emitted). Always on
in dev. `VITE_INCLUDE_PORTAL` is typed in `vite-env.d.ts` and declared
(default `false`) in `editor/.env`.

**Gradle** ([`build.gradle`](app/core/build.gradle)) -
`-PbuildWithPortal=true` forces `buildWithFrontend=true` and sets
`VITE_INCLUDE_PORTAL=true` on the editor build. Process-env takes
priority over `.env`, so the flag wins for JAR builds while plain `vite
build` / Cloudflare Pages default to off.

**Backend - make the shell reachable**
([`RequestUriUtils`](app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java))
- permits `/portal` + `/portal/*` as public SPA routes. The editor keeps
its JWT in localStorage (not a cookie), so a direct nav/refresh to
`/portal` isn't authenticated at the server and would otherwise redirect
to `/login` and never load. Serving the shell pre-auth (like the editor
root already is) lets it load; **access control is unchanged** - the
portal has its own auth gate + `RequirePortalAccess`, and its data APIs
stay protected.

**Docker** - the embedded Dockerfiles take `ARG BUILD_PORTAL=false` →
`-PbuildWithPortal=${BUILD_PORTAL}`. Default off, so official
`push-docker` images do **not** bundle the portal.

**CI - scoped to the PR preview deploy only**
([`PR-Auto-Deploy-V2.yml`](.github/workflows/PR-Auto-Deploy-V2.yml)) -
the one job that builds the JAR and comments owns all portal wiring:
passes `BUILD_PORTAL=true`, enables the portal's backend features
(`POLICIES_ENABLED`, `STIRLING_BILLING_ACCOUNT_LINK_ENABLED`), and adds
an "Admin portal included" line (linking `/portal` via the direct IP) to
the deployment comment. `push-docker`, `build.yml`, `test-build-docker`,
and the shared paths-filter are untouched.

## Validation (real, in the JAR)

Built and booted the JAR with `-PbuildWithPortal=true` and login
enabled:
- `/portal` and `/portal/users` load via direct nav and render **fully
themed** (dark surfaces, gradients, filled buttons).
- Editor-only build (`-PbuildWithFrontend=true`, no portal flag) →
editor ships, **0 portal chunks** (tree-shaken).
- `-PbuildWithPortal=true` → `PortalApp` chunk present.

Green: `frontend:check:all` (typecheck all variants, lint, format,
build, tests incl. the `VITE_*` env guard), backend compile,
`RequestUriUtilsTest`, spotless.

## Notes

- **Official images never bundle the portal** (Dockerfile default off);
only the PR preview does. Flip `BUILD_PORTAL` / `-PbuildWithPortal` to
include it elsewhere.
- The `/portal` shell being public is the one deviation from `main`, and
it's required for the route to be reachable at all in a login-enabled
server; data access is still fully gated.
2026-07-08 12:55:22 +00:00
James Brunton 8150d16b6f Support bidirectional mapping for Change Metadata (#6906)
# Description of Changes
The Change Metadata tool was missed from the bidirectional mappings
added in #6867. This PR adds it to the list of supported tools.
2026-07-08 12:47:11 +00:00
Reece Browne c8f238ae60 Portal/editor switcher (#6907)
Adds the portal's top-left app switcher to the editor sidebar, so you
can jump between the two apps from either side.

- Both sidebars render the same shared `AppSwitch` component (sui
dropdown).
- Switching is client-side (no page reload); portal→editor no longer
breaks when `VITE_EDITOR_URL` is unset.
- Editor side is admin-gated (`portalAccess`) and only exists in flavors
that ship the portal — core/desktop stub it out, same seam pattern as
the portal routes.
- Fixes en route: dropdown menu stacking in the editor sidebar, sui
dropdown item button reset, stale `dist-portal` ESLint ignore.
2026-07-08 12:17:00 +00:00
ConnorYoh 8df49ac053 feat(portal): build portal for SaaS and self-hosted via file-override layer (#6900)
## What

Ground-work so the admin portal can build for the **SaaS flavor**
alongside self-hosted, using the editor's existing **build-time
file-override** mechanism — no runtime flavor flags. This PR
demonstrates SaaS end-to-end (single-login + Usage page loading via the
inherited Supabase session) without changing self-hosted behaviour.

This is intentionally scoped as foundations, not the whole feature.

## How

**Build hook**
- `tsconfig.saas.vite.json`: `@portal/*` now cascades
(`src/saas/portal/*` → `src/portal/*`); added `@portalCore/*` for the
explicit base path.
- New `src/portal-saas/` layer (sibling of `src/portal`) holds SaaS-only
overrides, so `@app/*` resolves only editor layers and `@portal/*` only
portal layers. Self-hosted builds never import it (tree-shaken).

**Seams live in the api-client + composition layers — never in page
components**
- `saasApiBase` — base URL source (self-hosted: `VITE_SAAS_API_URL`;
SaaS reuses the single `VITE_API_BASE_URL` backend).
- `portalSaasSession` — flavor-agnostic token from the shared Supabase
client.
- `PortalAuthBoundary` — self-hosted: Spring `AuthProvider` +
`AuthGate`; SaaS: Supabase `AuthProvider` + session-only gate (inherits
the SaaS session, so no second login).

**Link concept pulled out of the Usage page (one clean cut)**
- `Usage` is now a link-free wallet renderer with generic
`onWalletLoaded` / `onReauth` callbacks; it always loads the wallet and
has zero flavor awareness.
- `PortalBillingGate` is the single flavor seam: self-hosted gates on
link (prompt when unlinked; wires the callbacks onto link/tier +
re-auth), SaaS is a passthrough that renders `Usage` directly.
- Keeps the flavor switch out of the page entirely (no per-flavor code
in `Usage`).

## Testing
Green locally and in CI (CI runs the umbrella `task
frontend:check:all`):
- `task frontend:typecheck:all` — clean across all 7 build variants
- `task frontend:test` — vitest suites pass (portal + saas cover this
change; 146 tests)
- `task frontend:build:saas` and `task frontend:build:proprietary` —
both green
- `task frontend:lint` and `task frontend:format:check` — clean

## Also in this PR (added after the initial foundations)
- **Tier from wallet + full link-layer excision on SaaS.** `TierContext`
no longer reads `LinkContext` (via a `usePlanTier` seam: self-hosted
from link state, SaaS from `wallet.status`), and the SaaS
`PortalProviders` drops `LinkProvider` / `AccountLinkProvider` /
`LinkModalHost` entirely — the link machinery is *absent* from the SaaS
bundle, not mounted-but-inert.

## Deliberately out of scope (follow-ups)
- SaaS-only read-only "connected servers" settings view.
- Shared wallet source so the SaaS tier badge and the Usage page don't
both fetch `/payg/wallet` (harmless double-fetch today).
2026-07-08 12:07:54 +00:00
Anthony Stirling 38ccea074c Version bump 2026-07-08 10:50:37 +01:00
Anthony Stirling a7307ff393 Fix Postgres user settings for some users 2026-07-08 10:50:36 +01:00
Anthony Stirling 328cd8c664 Claude skills walkthrough, feature-walkthrough, and before/after (#6862)
# Description of Changes

Add review only Claude skills

### Skills (Using
https://github.com/Stirling-Tools/Stirling-PDF/pull/6655 as example for
example files)
- **`/ui-walkthrough`** - captures every state of a feature's UI
(empty/populated/dialogs, light + dark + RTL) via the stubbed Playwright
harness, builds a single-image HTML report with a global light/dark
slider, then runs visual-consistency + UX review passes. `--fix`
auto-applies safe fixes and re-shoots.

[REAL-ui-walkthrough-pr6655.html](https://github.com/user-attachments/files/29594945/REAL-ui-walkthrough-pr6655.html)

- **`/feature-walkthrough`** — explains a branch end-to-end (Mermaid
diagrams, annotated file map, before/after, "try it locally") so a
reviewer with no prior context can follow it.

[REAL-feature-walkthrough-pr6655.html](https://github.com/user-attachments/files/29594950/REAL-feature-walkthrough-pr6655.html)

- **`/ui-before-after`** — generic branch/PR visual diff: derives the
changed UI from the diff, screenshots before (base) vs after (head),
pixel-diffs, auto-crops each pair to the region that actually changed
(full-page only when the change is page-wide), and builds PR-ready
before/after montages.

[REAL-ui-before-after-pr6655.html](https://github.com/user-attachments/files/29594954/REAL-ui-before-after-pr6655.html)




---

## 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.
2026-07-08 09:46:55 +00:00
Anthony Stirling 72729e99c1 fix(release): stop msiexec hang in Windows signature verify; don't force latest or regen release notes 2026-07-07 23:35:12 +01:00
Anthony Stirling 5fba2720f0 Fix cert sign not showing under certain instances (#6908) 2026-07-07 22:45:56 +01:00
Anthony Stirling f703a67817 Fix cert sign not showing under certain instances (#6908) 2026-07-07 22:39:57 +01:00
01a1ef8c44 Fix missing app icon on Linux/Wayland (#6875)
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-07-07 22:15:01 +01:00
Ludy 8535c7e9ac feat(ui): add dedicated third-party license sections to settings (#6820) 2026-07-07 22:15:01 +01:00
stirlingbot[bot]andLudy 105af51100 Update Frontend 3rd Party Licenses (#6889)
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
2026-07-07 22:08:01 +01:00
57bf17d348 Fix missing app icon on Linux/Wayland (#6875)
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-07-07 21:58:21 +01:00
Ludy 11df30b914 feat(ui): add dedicated third-party license sections to settings (#6820) 2026-07-07 21:57:34 +01:00
Ludy 43162c40ad chore(frontend): remove unused OG images (#6826) 2026-07-07 21:56:16 +01:00
James Brunton be57f11747 Improve type safety of tool definitions (#6895)
# Description of Changes
Followup work requested in review of #6867. Currently, there is nothing
enforcing that the endpoint chosen in the tool config is the correct
mapping for `toApiParams`, so theoretically it's possible for a tool to
be set up to call an endpoint with the wrong API params for it. There's
also nothing currently enforcing that `toApiParams` and `fromApiParams`
are compatible with each other (using the same types). This PR changes
it so that instead of creating the config object directly, tools create
it via a generic function, which enforces that all of the relevant
mappings are using compatible types.
2026-07-07 16:43:06 +00:00
EthanHealy01 8ba8f69252 Consolidate buttons and related components (#6787)
SegmentedControl, Chip, ChipFlow. Bring in the portal dark mode theme
and other small fixes to issues I found during testing
2026-07-07 16:06:56 +00:00
Reece Browne be97268a7c SUI - setting up mantine backed SUI components (#6890)
## Summary

Converts SUI's existing Select and Slider to Mantine-backed
implementations, and adds three new Mantine-backed SUI components:
MultiSelect, NumberInput, ColorInput.

All five components follow the same contract as the rest of the SUI
catalogue:
- Imported from `@app/ui` — Mantine is an implementation detail
- Explicit prop allowlists: appearance props (color, variant, radius,
classNames, styles) are locked internally to SUI tokens; only
behavioural props are exposed
- Labels and error messages stripped from the interface — callers use
`<FormField>` for both. The components take an `invalid` flag that
applies error styling only; Mantine never renders its own message
element, so the text can't appear twice
- `aria-label` / `aria-invalid` / `aria-describedby` and `FormField`'s
injected `required` are forwarded, so the injected accessibility wiring
reaches the underlying input. Mantine drops some of this wiring
internally (`aria-describedby` on inputs, all aria props on Slider's
thumb, `required` on MultiSelect's field), so `ariaForwarding.ts`
re-applies it to the DOM node and `ariaForwarding.test.tsx` locks the
contract in
- Typed escape hatches (`comboboxProps`, `popoverProps`, `rightSection`)
documented for the z-index-in-modal use case

**Select** — rebuilt from native `<select>` to Mantine combobox. Gains
searchable/clearable. `onChange` now receives the value string directly,
not a DOM event — callers updated.

**Slider** — rebuilt from native `<input type="range">` to Mantine
Slider. Gains accessible keyboard navigation and `marks` support.

**MultiSelect, NumberInput, ColorInput** — new components. The behaviour
(multi-select combobox, number stepper, colour picker) is too complex to
hand-build correctly; Mantine provides it for free behind a locked SUI
interface.

Also wires `suiCssVariablesResolver` into the Storybook
`MantineProvider` so Mantine combobox/popover dropdowns follow the SUI
palette in dark mode, and adds `"neutral"` accent variant to
`IconBadge`.

## Usage

```tsx
import { Select, Slider, MultiSelect, NumberInput, ColorInput } from "@app/ui";
import { FormField } from "@app/ui/FormField";

// Select — onChange receives string | null, not a DOM event
<FormField label="Retention">
  <Select options={options} value={value} onChange={setValue} searchable clearable />
</FormField>

// Slider — same external API as before, now with marks support
<FormField label="Confidence">
  <Slider value={v} onChange={setV} min={0} max={1} marks={[{ value: 0.5, label: "0.5" }]} />
</FormField>

// New components
<FormField label="PII types">
  <MultiSelect data={options} value={value} onChange={setValue} searchable clearable />
</FormField>

<FormField label="Opacity">
  <NumberInput value={opacity} onChange={setOpacity} min={0} max={100} suffix="%" />
</FormField>

<FormField label="Watermark colour">
  <ColorInput value={color} onChange={setColor} />
</FormField>
```

## Notes

- **Select `onChange` is a breaking change** — receives `string | null`
instead of a DOM event. All existing callers in this repo are updated.
- The policy PR (`main` WIP) depends on this merging first.
- Stories for all five components are under **Primitives / Forms** in
Storybook.
2026-07-07 14:29:55 +00:00
ConnorYoh 7bd3826178 Portal procurement: real pricing/trial/quote spine + linked-gated checkout (vertical slice) (#6861)
## What this is

The enterprise procurement flow, built into the customer portal as a
**vertical slice** — one linked account can go the whole way from trial
to a paid, committed subscription, using real Stripe under the hood.

Procurement no longer lives as a nav tab. It sits on **Home** as a
deal-status hero and expands into a full-screen takeover, matching the
marketing prototype.

## The journey (what a customer does)

- **Start a trial** in one click — the deadline and next steps show on
the Home hero (no card, mock licence).
- **Build a quote** — a short form (volume → commitment & service →
details); pricing is computed server-side.
- **Generate the quote** — this creates a real **Stripe Quote** with a
proper **PDF** you can download and share, and it becomes a milestone
you can come back to.
- **Review & sign the agreement** — one combined agreement (MSA + Order
Form + EULA + DPA) with an itemised order form and an "I agree" (no
e-signature yet).
- **Accept** — Stripe creates the committed annual **subscription** and
its **first invoice**, which you can **pay or download right in the
app** (no waiting on email).
- Edit a quote any time — it remembers your inputs and company name; the
old Stripe quote is cancelled so it can't still be accepted.
- The hero also has quick actions: **key documents**, **invite
teammates**, **schedule a call**, and a **trial countdown** you can
extend.

## Architecture — Supabase vs Java

Pricing, deal/quote state, and the commercial journey live in **Java
(`:saas`)**. Everything that touches **Stripe** (writes + PDFs) lives in
**Supabase edge functions** — Java has no Stripe SDK and only reads
Stripe via the sync mirror. The portal calls both.

```mermaid
flowchart LR
  Portal["Portal (React · editor/src/portal)"]

  subgraph JAVA["Java :saas backend (trusted cloud)"]
    Pricing["Pricing engine (volume bands, SLA, term, add-ons)"]
    Deal["Deal + quote state, journey, snapshot"]
    Trial["Trial (mock Keygen licence seam)"]
    Authz["Auth: team resolve + leader gating"]
    Mirror["Reads Stripe via sync mirror (stripe.* tables)"]
  end

  subgraph SUPA["Supabase edge functions (own Stripe)"]
    Issue["issue-procurement-quote → create + finalize Stripe Quote"]
    Accept["accept-procurement-quote → subscription + finalize invoice"]
    Pdf["get-procurement-quote-pdf → proxy the quote PDF"]
    RPC["SECURITY DEFINER RPCs (read/write stirling_pdf, enforce team/leader)"]
  end

  Stripe["Stripe (Quotes · Subscription · Invoice)"]

  Portal -->|"price / build / trial / agreement / snapshot"| JAVA
  Portal -->|"issue / accept / download PDF"| SUPA
  SUPA --> Stripe
  SUPA --- RPC
  Mirror -. reads .-> Stripe
```

| Top-level feature | Handled in |
|---|---|
| Quote pricing (bands, SLA, term, add-ons) | **Java** |
| Deal + quote state, journey, snapshot | **Java** |
| Trial start / extend (mock licence) | **Java** |
| AuthN/Z (team resolve, leader gating) | **Java** |
| Issue quote → Stripe Quote + PDF | **Supabase edge fn** |
| Accept → subscription + invoice | **Supabase edge fn** |
| Quote PDF download | **Supabase edge fn** |
| Reading Stripe state | **Java** (sync mirror) |
| `stirling_pdf` writes from edge | **SECURITY DEFINER RPCs**
(service-role only) |

## Screenshots

<!-- Drag each PNG into the box below it before publishing. -->

**Home deal-status hero (trial)**
<img width="1920" height="1009" alt="hero-check"
src="https://github.com/user-attachments/assets/7ae21831-9578-4f4d-b91a-d3ab2cb171dc"
/>

**Issued quote milestone (with breakdown)**
<img width="1920" height="1009" alt="milestone-breakdown"
src="https://github.com/user-attachments/assets/3a463c67-3c6e-4f93-a1cc-59b250d54cc9"
/>


**Agreement step (itemised order form)**
<img width="1920" height="1009" alt="agreement-itemised"
src="https://github.com/user-attachments/assets/1a4efa16-d0a3-41d0-849c-a125b1492a34"
/>

**Key documents**
<img width="1920" height="1009" alt="keydocs-modal"
src="https://github.com/user-attachments/assets/5672d1d2-99d9-499e-9edf-d485df378e7f"
/>

**Subscription created (pay / download invoice)**
<img width="1920" height="1009" alt="accepted-check"
src="https://github.com/user-attachments/assets/3a8cbe9f-e72c-4389-b365-0c1749108b6f"
/>


## Mocked for now (scaffolding, not wired to real backends)

- **Key documents** ledger — static demo list.
- **Schedule a call** — static solutions-engineer + time slots.
- **Invite teammates** — routes to the existing Users view.
- **Simulate payment received** / **Reset procurement** — demo controls,
**off by default** in prod (flag-gated), 404 unless enabled.

## Deferred (separate follow-up PRs)

- **Real `invoice.paid` webhook** → go-live (today a demo button stands
in).
- **Keygen licence controller** — real licensing (currently a mock
seam).
- **Document sharing**.
- **Stirling admin / Deal Desk** view.
- **Minimum ACV floor** — pending a number from marketing (server-side
enforcement is a one-liner once decided).

## How to test

- **Frontend, no backend:** runs against MSW mocks (Storybook + mocks-on
dev) — the whole journey is clickable.
- **Real end-to-end:** apply the migrations (Flyway `V27–V29` / Supabase
`20260701–20260707`), deploy the three edge functions, ensure
**Invoicing Plus** is enabled on Stripe, and set
`STIRLING_PROCUREMENT_DEMO_CONTROLS_ENABLED=true` if you want the demo
controls.
- Paired SaaS PR: **Stirling-Tools/Stirling-PDF-SaaS#318**.

## Notes for reviewers

- Pricing is server-authoritative (client sends config, never amounts).
- Security review done: edge functions validate the JWT and enforce
**team membership** (and **leader** for issue/accept) via the RPC; demo
endpoints are flag-gated off. Only open item is the ACV floor (policy).
2026-07-07 12:02:02 +00:00
ConnorYoh cca3f42623 Set App version to v2.14.1 (#6891)
Upped version in build.gradle then ran build so version falls through
2026-07-07 12:15:01 +01:00
James Brunton 17aa71850c Convert to consistently use JS modules (#6854)
# Description of Changes
Modernises the codebase and gets rid of warnings where Node complains
that it doesn't know what type of JS it's supposed to be reading on
`.js` files. We might as well update everything to just use correct JS
syntax instead of keeping with some files having Node-specific imports.
2026-07-07 11:11:24 +00:00
James Brunton 1b7ffcdbac Fix tooltip positioning on Add Page Numbers (#6885)
# Description of Changes
## Before

<img width="483" height="227" alt="image"
src="https://github.com/user-attachments/assets/4bf86eec-a9cc-4f63-84f0-4eb2bd535bab"
/>

## After

<img width="732" height="235" alt="image"
src="https://github.com/user-attachments/assets/101d2ea4-36e8-4e8f-990a-d72b33fa0ac2"
/>
2026-07-07 12:09:51 +01:00
Ludy 67a0ca6110 fix(frontend): respect analytics config before initializing PostHog (#6812)
# Description of Changes

Please provide a summary of the changes, including:

- What was changed
- Moved PostHog startup out of `index.tsx` and into a config-aware
initializer inside `AppProviders`.
- Added a dedicated `usePosthogTracking` hook that only initializes
PostHog when `enableAnalytics` is explicitly `true` and `enablePosthog`
is not disabled.
- Kept cookie-consent handling in the same flow so consent is applied
only after PostHog is actually initialized.
- Removed the unconditional `PostHogProvider` and `posthog.init(...)`
bootstrap from the app entrypoint.
- Added targeted frontend tests covering analytics-disabled and
analytics-enabled startup behavior.

- Why the change was made
- The previous frontend bootstrap initialized PostHog before app config
was loaded, so disabling analytics in the UI or via environment settings
did not prevent PostHog network activity.
- This change makes analytics behavior follow the server-provided config
instead of always connecting on page load.

Closes #6358

---

## 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.
2026-07-07 12:09:51 +01:00
Anthony Stirling 8e4b2e2fc6 Disable update check and notification in SaaS mode (#6863)
# Description of Changes

In SaaS mode the self-hosted "Update Available" notification could still
appear and the update-check code (external call to
`supabase.stirling.com/functions/v1/updates`) still ran, even though the
cloud owns app versioning. The web `UpdateStartupPopup` was already
SaaS-gated via a null override, but two other paths were not:

- **Desktop app in SaaS connection mode** - `useDesktopUpdatePopup()`
ran its startup check and rendered the `UpdateModal` regardless of
connection mode, so a self-hosted update popup appeared while connected
to SaaS.
- **Settings → General** - the core `GeneralSection` fired
`checkForUpdate()` on mount unconditionally, even when the update
section was hidden (as SaaS does), so the external call still ran.

**What changed**

- `useDesktopUpdatePopup.ts` - the startup timer now bails out
immediately when `connectionModeService.getCurrentMode() === "saas"`. No
mode lookup, no external fetch, no modal.
- `core/GeneralSection.tsx` - the mount `checkForUpdate()` now returns
early when `hideUpdateSection` is set, so hiding the section (web SaaS,
managed-disabled desktop) also stops the external call.
- `desktop/GeneralSection.tsx` - passes `hideUpdateSection` when
`useSaaSMode()` is true, which (via the above) suppresses the settings
check in desktop-SaaS too.

**Why** - in SaaS the update check should never be called and no update
notification should be shown; the cloud handles versioning.

---

## 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)

- [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.
2026-07-07 12:09:51 +01:00
Anthony Stirling 3c93457021 Fix rearrange-pages DUPLICATE producing shared page nodes (pypdf cyclic-references CI break) (#6851) 2026-07-07 12:09:51 +01:00
James Brunton 20204f0ddc Improve consistency and reliability of tools in Stirling Engine (#6855)
# Description of Changes
A few changes to improve things in the engine:
- Changed the PDF to Markdown code to be a real tool in Java, to remove
the need for the `pdf_ingest` code, which looked a bit like an agent but
wasn't behaving as an agent. It's now just covered automatically by the
edit agent.
- Noticed that 0-parameter-endpoints were previously being ignored by
the `tool_models` generator, so some tools which require no params were
being mistakenly excluded.
- Removed tools which currently never succeed like Add Stamp, Cert Sign,
and Overlay, because they require the supporting files to be sent in a
different location in the API call, which we don't currently do.
Ideally, we'd add proper support for this, but we're better off now
removing support for these tools rather than just have them crash. We
can re-add these tools in a future PR properly.
2026-07-07 11:01:18 +00:00
ConnorYoh 1df6a1759c Set App version to v2.14.1 (#6891)
Upped version in build.gradle then ran build so version falls through
2026-07-07 09:37:35 +00:00
James Brunton b4f7b1d8a9 Add bidirectional API types to frontend (#6867)
# Description of Changes
Fix https://github.com/Stirling-Tools/Stirling-PDF-SaaS/issues/281. Add
generated backend API mappings to the frontend code, and the logic to
convert from a backend API to frontend parameters objects.

Previously, it was impossible to tell if changing the backend API would
require a change to the frontend to support it because the frontend had
no static type information about the backend API. This PR adds
autogenerated tool API types to the frontend (in `toolApiTypes.ts`) and
adds explicit typed mappings between the frontend parameter types and
the backend API types, so theoretically the type checker should be able
to catch issues when changing one puts us in an invalid state with the
other. During development, it pointed out several inconsistencies that
we have between the frontend and backend types, some of which were
genuine bugs, and others were only happening to work because the backend
is more permissive than its API claims to be.

This also unlocks the ability for us to render the frontend settings on
saved backend API structures, which we've previously had to avoid doing
because we had no reverse mapping.
2026-07-07 07:47:07 +00:00
James Brunton f881828cd8 Fix intermittently failing Playwright tests (#6886)
# Description of Changes
Fixes intermittently failing tests (and replaces one that wasn't useful
in its previous state) and also adds a CI check to warn if there are any
Playwright tests which failed on their first go and succeeded on
retries, to hopefully help find intermittently failing tests more
quickly and avoid them being merged in the first place.
2026-07-06 21:37:21 +00:00
Peter Dave HelloandJames Brunton 16cfbc170e Clean up typos in docs, comments, and UI copy (#6045)
# Description of Changes

Fix wording, numbering, path references, and minor grammar issues across
project guides, backend comments, and frontend strings.

This keeps documentation and user-facing text consistent without
changing application behavior.

---

## 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/devGuide/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 tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-07-06 14:07:06 +00:00
James Brunton 355d736487 Skip enterprise tests for forks since they can't run without secrets (#6888)
# Description of Changes
OSS contributions which trigger the enterprise e2e tests will always
fail due to missing secrets (see
https://github.com/Stirling-Tools/Stirling-PDF/actions/runs/28776342146/job/85337748018?pr=6045).
This PR disables them for OSS PRs.
2026-07-06 13:26:10 +00:00
ConnorYohandJames Brunton f201aa5915 feat(account-link): Phase 2 — instance metering + daily usage sync (#6839)
## Account-link Phase 2: metering + daily usage sync

Phase 1 (already on main) let a self-hosted instance link a SaaS account
and blocked billable work when it was over its limit. It blocked, but it
never actually charged anything. This PR adds the metering + billing
half.

**It's off by default.** Everything sits behind
`stirling.billing.account-link.metering.enabled`, on top of the existing
`stirling.billing.account-link.enabled` master flag. Both have to be on
for any of it to run, so it can't touch production. The billing model
isn't going live yet — this is a dark merge.

### How it works
1. The instance classifies each billable request (API / AI / Automation
— manual PDF editing stays free) and counts it locally into a per-period
counter.
2. Once a day it reports its running totals to SaaS.
3. SaaS bills only the delta since the last report, reusing the existing
charge path (free grant + wallet ledger + Stripe meter). No new money
logic.
4. The portal shows current usage (synced spend plus anything not
reported yet), and when you subscribe it now reflects the new plan right
away instead of waiting for a cache to expire.

### What's worth a reviewer's eyes
- **It can't double-charge.** SaaS only ever bills the delta, refuses a
counter that goes backwards, dedups repeat/late reports on a monotonic
sequence number, and takes a row lock so a duplicate delivery can't
charge twice.
- **The cap is enforced at the instance gate**, not in the charge path
(same as the in-cloud flow). A $0 cap blocks all metered work.
- Page counts use jpdfium so the instance and the cloud agree on the
number that gets billed.
- New SaaS surface: `POST /api/v1/instance/sync`, migrations V25
(`payg_instance_usage`) and V26 (allow the `LINKED_INSTANCE` job
source), and a small `POST /api/v1/payg/wallet/refresh` the portal calls
after checkout.

### Companion PR
Stirling-PDF-SaaS #314 (on `v3`): the checkout edge function so the
embedded Stripe flow finishes in-page instead of reloading, plus a
`Deno.serve` migration so the edge functions actually deploy.

### Testing
Java unit tests (proprietary + saas), portal vitest, and the SaaS
edge-function tests all pass. Branch is merged up to date with main.

### Not done yet (doesn't block this merge — only matters once both
flags are on)
- V25 Supabase twin in the SaaS repo.
- Same in-page checkout fix for the editor's upgrade modal.
- A flags-on smoke test in staging (one real sync round-trip).

---------

Co-authored-by: James Brunton <james@stirlingpdf.com>
2026-07-06 11:39:24 +00:00
James Brunton b69b787d63 Fix Playwright tests in Firefox and Safari (#6868)
# Description of Changes
Playwright tests currently fail in Firefox and Safari because of
inconsistent behaviour across the browsers. This is causing the
nightlies to fail every night. This PR fixes the test behaviour to work
consistently across browsers (most of the issues were to do with the
tests opening the file picker, which was being automatically suppressed
in Chromium, but not the other browsers).
2026-07-06 09:25:46 +00:00
James Brunton e630d6697b Fix tooltip positioning on Add Page Numbers (#6885)
# Description of Changes
## Before

<img width="483" height="227" alt="image"
src="https://github.com/user-attachments/assets/4bf86eec-a9cc-4f63-84f0-4eb2bd535bab"
/>

## After

<img width="732" height="235" alt="image"
src="https://github.com/user-attachments/assets/101d2ea4-36e8-4e8f-990a-d72b33fa0ac2"
/>
2026-07-06 09:22:41 +00:00
Ludy a15e8227b4 fix(ci): upload Playwright reports from the correct frontend directory (#6859)
# Description of Changes

This change fixes the artifact upload path used by the Playwright E2E
workflows after the frontend directory structure was updated.

### What was changed

- Updated the Playwright report artifact path from:
  - `frontend/editor/playwright-report/`
  - to `frontend/playwright-report/`
- Applied the fix to:
  - `build-enterprise.yml`
  - `e2e-stubbed.yml`
  - `nightly.yml`
- Renamed the nightly Playwright artifact from:
  - `playwright-nightly-${{ github.run_id }}`
  - to `playwright-report-nightly-${{ github.run_id }}`
  for consistency with the other workflows.

### Why the change was made

The workflows attempted to upload artifacts from a directory that no
longer exists, causing GitHub Actions to report:

> No files were found with the provided path:
`frontend/editor/playwright-report/`

Updating the upload path ensures Playwright reports are successfully
collected and available for debugging failed E2E runs.

---

## 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.
2026-07-06 09:21:57 +00:00
Ludy 12563050a6 Generate frontend license report on push (#6877)
# Description of Changes

`app/allowed-licenses.json` has been modified in preparation for when
"org.springframework.boot" is upgraded to version "4.0.7".

---

## 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.
2026-07-06 09:21:41 +00:00
Ludy 1abd23cf94 fix(frontend): respect analytics config before initializing PostHog (#6812)
# Description of Changes

Please provide a summary of the changes, including:

- What was changed
- Moved PostHog startup out of `index.tsx` and into a config-aware
initializer inside `AppProviders`.
- Added a dedicated `usePosthogTracking` hook that only initializes
PostHog when `enableAnalytics` is explicitly `true` and `enablePosthog`
is not disabled.
- Kept cookie-consent handling in the same flow so consent is applied
only after PostHog is actually initialized.
- Removed the unconditional `PostHogProvider` and `posthog.init(...)`
bootstrap from the app entrypoint.
- Added targeted frontend tests covering analytics-disabled and
analytics-enabled startup behavior.

- Why the change was made
- The previous frontend bootstrap initialized PostHog before app config
was loaded, so disabling analytics in the UI or via environment settings
did not prevent PostHog network activity.
- This change makes analytics behavior follow the server-provided config
instead of always connecting on page load.

Closes #6358

---

## 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.
2026-07-06 09:21:09 +00:00
James Brunton 11ba3814e5 Restructure Portal code to be inside Editor (#6857)
# Description of Changes
We don't have any strong reasons to keep the Portal as a separate Vite
app, and it needs access to so many things from the Editor that it no
longer makes sense to keep them separate. This PR moves the Portal code
to have direct access to the Editor code and gets rid of the shared
folder.
2026-07-03 13:20:02 +00:00
Anthony Stirling 675afe9b71 Disable update check and notification in SaaS mode (#6863)
# Description of Changes

In SaaS mode the self-hosted "Update Available" notification could still
appear and the update-check code (external call to
`supabase.stirling.com/functions/v1/updates`) still ran, even though the
cloud owns app versioning. The web `UpdateStartupPopup` was already
SaaS-gated via a null override, but two other paths were not:

- **Desktop app in SaaS connection mode** - `useDesktopUpdatePopup()`
ran its startup check and rendered the `UpdateModal` regardless of
connection mode, so a self-hosted update popup appeared while connected
to SaaS.
- **Settings → General** - the core `GeneralSection` fired
`checkForUpdate()` on mount unconditionally, even when the update
section was hidden (as SaaS does), so the external call still ran.

**What changed**

- `useDesktopUpdatePopup.ts` - the startup timer now bails out
immediately when `connectionModeService.getCurrentMode() === "saas"`. No
mode lookup, no external fetch, no modal.
- `core/GeneralSection.tsx` - the mount `checkForUpdate()` now returns
early when `hideUpdateSection` is set, so hiding the section (web SaaS,
managed-disabled desktop) also stops the external call.
- `desktop/GeneralSection.tsx` - passes `hideUpdateSection` when
`useSaaSMode()` is true, which (via the above) suppresses the settings
check in desktop-SaaS too.

**Why** - in SaaS the update check should never be called and no update
notification should be shown; the cloud handles versioning.

---

## 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)

- [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.
2026-07-03 08:55:07 +00:00
Anthony Stirling 6c85200eb9 Add portal access control and S3/MCP/API integration configs (#6795) 2026-07-01 13:49:02 +01:00
Reece Browne 467f3a86c4 Portal policies (#6852) 2026-07-01 13:42:26 +01:00
Anthony Stirling 9d3701a585 Fix rearrange-pages DUPLICATE producing shared page nodes (pypdf cyclic-references CI break) (#6851) 2026-07-01 13:40:27 +01:00
James Brunton c22ecc6c09 Add counts to sources page (#6819) 2026-07-01 11:42:35 +01:00
b38c849726 Portal: Procurement surface — layout rework + stateful mock backend (#6785)
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
Co-authored-by: Connor Yoh <con.yoh13@gmail.com>
2026-07-01 11:38:38 +01:00
dependabot[bot]andAnthony Stirling 41f1cb2c22 build(deps): bump test pypdf + add translations (#6831)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 23:40:26 +01:00
Anthony Stirling ff3e3bd0fc Add desktop hardware token signing and trust-aware signature validation (#6765)
# Description of Changes

<img width="432" height="800" alt="image"
src="https://github.com/user-attachments/assets/a01ed9ac-220c-4911-9134-b51e0f321be8"
/>

<img width="408" height="859" alt="image"
src="https://github.com/user-attachments/assets/a9c285b6-5b75-493a-95ec-09e08d0f58f1"
/>

<img width="426" height="874" alt="image"
src="https://github.com/user-attachments/assets/a60db96e-be93-4cc5-ba0a-63512c2857ba"
/>

<img width="356" height="1076" alt="image"
src="https://github.com/user-attachments/assets/24d03674-94d3-40ed-99ee-73395bafae6a"
/>


---

## 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.
2026-06-30 23:23:58 +01:00
EthanHealy01 54042c8e5e Signing UI edge-case cleanup (#6849) 2026-06-30 23:04:10 +01:00
stirlingbot[bot]andAnthony Stirling bb92ecc143 Update Backend 3rd Party Licenses + Translations and bump versio (#6794)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
2026-06-30 22:41:40 +01:00
EthanHealy01 7ab30d2629 add file share to the top workbench bar and add shared signing (#6715) 2026-06-30 22:14:49 +01:00
James Brunton 276eb8f2a7 Add pipelines page to portal (#6818)
# Description of Changes

Connect pipelines page to the backend. Note that this is really half an
implementation because the portal doesn't have access to the tools list
and their settings, but I can't fix that without re-architecture work,
which I'll do in another PR, then come back to finish this off in a new
PR.

<img width="786" height="579" alt="image"
src="https://github.com/user-attachments/assets/d3f06110-a35d-4d48-a2f9-1edb900c5c35"
/>

<img width="1232" height="519" alt="image"
src="https://github.com/user-attachments/assets/9f344648-ea45-498d-9e84-9558a3999838"
/>
2026-06-30 16:11:48 +00:00
James Brunton e44da5c410 Fix missing refresh token on desktop (#6838)
# Description of Changes
Fix #6801, along with fixing policies on desktop, which would attempt to
download policy outputs from the local backend instead of the server,
where they actually live. I've changed the policies logic to maintain
the same backend for the file retrieval as it used for the policy
running, so when we support running policies locally, it should still
work correctly.
2026-06-30 14:07:12 +00:00
Reece Browne 0beff1a92b feat(shared): make @shared the single home for brand logo assets (#6714)
## What

Makes `@shared` the single home for the Stirling brand logo assets.
Moves the editor's two logo sets — `classic-logo` + `modern-logo` (22
files: marks, wordmarks, favicons, login headers, PNGs) — out of
`editor/public/` into `shared/assets/brand/`, and adds a Storybook
**Brand/Logos** gallery.

## Why this shape (not a plain move)

The editor serves logos by **URL** from `public/` and switches
`classic`/`modern` by a **user preference** (`useLogoAssets`,
`manifest.json` / `manifest-classic.json`, `index.html` favicon links).
Rewiring all that to module imports would be a large, risky change to
the variant system.

Instead the editor keeps its variant system **unchanged** and just
sources the files from shared: `vite-plugin-static-copy` copies
`shared/assets/brand/{classic,modern}-logo/*` back to the served
`/{classic,modern}-logo` paths (the editor already uses this plugin for
pdfium/pdfjs assets). Single source of truth in shared, zero editor
code/manifest/markup changes.

## Verified

- **Build:** editor builds with both sets present at
`dist/{modern,classic}-logo/`; `manifest.json` + favicon refs resolve.
- **Dev:** the vite dev server serves the bridged paths —
`/modern-logo/logo512.png`,
`/modern-logo/StirlingPDFLogoNoTextDark.svg`,
`/classic-logo/favicon.ico` all return **HTTP 200** (the plugin's dev
middleware).
- Typecheck clean on core/proprietary/saas; prettier clean; `storybook
build` succeeds with the `Brand/Logos` gallery bundled.
- The portal's existing `@shared/assets` brand imports are untouched.

## Follow-ups (not in this PR)

- **Dedup:** `shared/assets/stirling-mark-*.svg` is byte-identical to
`brand/modern-logo/StirlingPDFLogoNoTextDark.svg`, and
`stirling-pdf-logo-*` is a near-twin of the modern wordmark. Reconciling
these (and re-pointing the portal) needs a designer eye on which
wordmark is canonical, so it's left out here to avoid changing the
portal's rendered logo.
- `editor/src/logo.svg` appears unused (no references) — candidate for
deletion separately.
2026-06-30 11:24:14 +00:00
ConnorYoh 425b76e9a7 fix(portal/i18n): add inline default values to account-link + billing t() calls (#6842)
## Problem

The account-link / billing / Usage strings migrated to i18next in #6738
call `t("key")` with **no inline default**. When no i18next instance is
initialized — which is the case in **Storybook** (the preview doesn't
load the portal i18n config) — or whenever a key is missing,
react-i18next renders the **raw key** (e.g. `billing.walletMeter.title`)
instead of English. That's why the billing stories regressed to showing
keys.

## Fix

Add the English string as the `t()` default value, matching the
**existing portal convention** (`AuthGate`, `Header`, `Sidebar`) and the
editor:

- plain → `t("key", "English")`
- interpolation → `t("key", "English {{var}}", { var })`
- plural → `t("key", "{{count}} …", { count })`

Dynamic keys resolved via data fields carry a sibling `*Default` string
passed as the default:
- `LINK_INFO` badge labels → `labelDefault` (`t(info.labelKey,
info.labelDefault)`)
- `PdfsProcessedCard` segment legend → `labelDefault` / `descDefault`

Defaults were sourced **verbatim from the merged
`en-US/translation.toml`**, so the TOML stays the source of truth — the
inline default only fills in when the catalogue isn't loaded or lacks
the key.

## Scope

All strings added in #6738: 5 account-link + 12 billing components + the
Usage view (157 static call sites + the `LINK_INFO` / segment dynamic
ones). No new keys; no copy changes.

## Verification

- `tsc -p portal/tsconfig.json` → 0
- `eslint --max-warnings=0` (changed files) → 0
- `prettier --check` → clean
- portal `vitest` → **62/62 pass**

No behaviour change when i18n is initialized; Storybook and any
missing-key fallback now render English.
2026-06-30 09:23:13 +00:00
Reece Browne c8af6e3b7e feat(policies): enforce run-on-export policies on all PDF exit paths (#6788)
> **Draft / WIP** — print enforcement is still to come (see below).

## Goal

A "run on export" policy must enforce on **every** path where a PDF
leaves the editor, not just the main Download/Export button. This routes
the remaining exits through the existing export-policy gateway
(`downloadFileWithPolicy`), which runs `enforceExportPolicies` before
the file leaves and is a no-op when no export policy is active.

## Audit of exit paths

| Path | Status |
|---|---|
| Web download / export, page-editor, file-editor, thumbnails | 
already covered (gateway) |
| **Form-fill download** (`FormSaveBar`) |  fixed here — was a raw
`createObjectURL` download |
| **Desktop Ctrl+S save** (`useSaveShortcut`) |  fixed here — was raw
`downloadService` |
| **Desktop save-operation-results** (`operationResultsSaveService`) | 
fixed here — was raw `downloadService` |
| Viewer `saveAsCopy` (annotations/redactions) | n/a — in-memory version
saves, not exits |
| **Print** (`printActions.print`) |  pending — enforce-then-print
(below) |
| Web operation-results (`downloadFromUrl`) |  pending — URL-stream,
needs a fetch→enforce wrapper |
| Share link | excluded by design (enforce at share-creation, not
recipient download) |

## In this PR

All three fixes are the same pattern — route the raw download through
`downloadFileWithPolicy` instead of `URL.createObjectURL` / the raw
download service.

## Still to come (why it's a draft)

- **Print** — enforce-then-print: on print, run the same
`enforceExportPolicies`; if it changed the doc, swap the viewer to the
enforced version (new version in history) and toast *"PDF updated by
policy enforcement — review, then print again"* rather than silently
printing a different doc; if unchanged, print. Covers Ctrl+P, the
toolbar button, and embedded PDF-JS print.
- **Web operation-results** (`downloadFromUrl`) — fetch the result to a
blob, enforce, then download.

## Verification

Typecheck (core/proprietary) + prettier clean for the changes here;
desktop tsc clean for the touched files. The print UX, once added, needs
a manual run with an active export policy — there's no automated path
for it.
2026-06-29 18:01:12 +00:00
James Brunton 82ec2acaba Make explicit signed and unsigned desktop CI jobs (#6840)
# Description of Changes
Makes it easier to skip signing on nightlies, which we don't need to do
since we're just warming the Rust cache.
2026-06-29 16:14:40 +00:00
Anthony Stirling 5e97746721 UX improvement for side menu bookmark, comments and attachments (#6552)
- Inline "Add bookmark" form in the bookmark sidebar (title + page,
defaults to current page) - saves via
/api/v1/general/edit-table-of-contents without leaving the viewer
- Persistent "+ Add" rows above the list in Bookmarks, Attachments,
Comments and Files sidebars (was only in empty state)
- Close (X) button in every viewer sidebar header (Bookmarks,
Attachments, Comments, Layers, Thumbnails)
- "Add comment" button morphs into "Click a page to place… (cancel)"
while textComment is armed, ESC to cancel
- "Add attachment" auto-closes the attachment sidebar so you don't end
up with two stacked panels
- Footer link in bookmark sidebar to the full Edit Table of Contents
tool for nesting/reordering
- Fix: bookmark/attachment sidebars getting stuck on "Loading…" after a
file swap (cache no longer caches `loading`, retry treats null bridge as
not-ready)
- Fix: Save silently routing to the editor tool on a fresh /read upload
when `activeFileId` is still null
- New Playwright tests (stubbed + live) covering Add buttons, Save flow
with PDF round-trip, and close buttons
<img width="720" height="1032" alt="06-thumbnails"
src="https://github.com/user-attachments/assets/62298d0d-8eba-4397-9bc2-96871be29b3c"
/>
<img width="790" height="1062" alt="01-bookmarks"
src="https://github.com/user-attachments/assets/1eb33667-c038-4b78-8711-97f354344fae"
/>
<img width="720" height="1032" alt="02-bookmarks-empty"
src="https://github.com/user-attachments/assets/3db263ef-9550-4bac-9ffa-c729263f42c3"
/>
<img width="1032" height="1032" alt="03-attachments"
src="https://github.com/user-attachments/assets/33580e64-020a-4e07-bf9a-595faf695fd8"
/>
<img width="919" height="1062" alt="04-comments"
src="https://github.com/user-attachments/assets/89ef01a8-35a6-406b-825a-f04beec02f29"
/>
<img width="720" height="1032" alt="05-layers"
src="https://github.com/user-attachments/assets/57d3cfe9-0a4c-468d-b497-ed855ddd69e5"
/>

---

## 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.
2026-06-29 14:05:58 +00:00
ConnorYohandJames Brunton 14245d33d1 feat(saas): account-link — connected self-hosted billing (Mode A) [WIP, flag-gated] (#6738)
> **Draft / WIP.** Combined-billing **Mode A** (connected self-hosted).
Entirely behind `stirling.billing.account-link.enabled` (default **off**
→ beans absent → 404). Pairs with Stirling-PDF-SaaS PR #313 (twin
migration → `v3`).

## What this does

A self-hosted instance links a SaaS account in the **Portal**, gets a
**device credential**, and authenticates unattended metering/entitlement
with it — no long-lived user JWT on the server. The Portal then surfaces
the team's **billing** (free trial → metered Processor plan) driven by
the live wallet.

```mermaid
sequenceDiagram
  participant Portal as Portal (browser)
  participant Supa as SaaS Supabase Auth
  participant Local as Self-hosted backend
  participant SaaS as SaaS Java (app/saas)
  Portal->>Supa: signIn / signUp (Supabase JS, short-lived JWT)
  Supa-->>Portal: JWT (SDK-refreshed, stays in browser)
  Portal->>Local: hand JWT (same-origin)
  Local->>SaaS: POST /account-link/register (Bearer JWT, leader)
  SaaS-->>Local: { device_id, device_secret }  (secret once)
  Note over Local: store device_secret server-side
  loop unattended
    Local->>SaaS: /api/v1/instance/** (X-Device-Id + X-Device-Secret)
    SaaS-->>Local: entitlement / gate decision
  end
```

**Auth model:** human auth = Supabase JS (ephemeral JWT, kept for
attended portal features). Durable instance auth = a team-bound
**device_id + secret** (SHA-256 stored, shown once), non-user
`ROLE_LINKED_INSTANCE`, path-scoped to `/api/v1/instance/**`. Instance
binds to a **team**, never a user.

## Billing surface (Portal · Mode A states)

`Usage & billing` is state-driven by the link/subscription dimension and
built to the marketing designs, sharing one component layer across
states:

- **Unlinked** → link-account prompt.
- **Linked · Free** — the *Processor trial*: a one-time 500-PDF free
grant ("Process 500 PDFs free, then $X/PDF"), the team's free-editor
fleet, and a leader-only **Switch on the Processor →** (embedded Stripe
Checkout).
- **Linked · Subscribed** — the *Processor plan* dashboard:
PDFs-processed split (API / Agents / Automation), **spend this month**
vs. a **spend limit** meter with a run-rate projection and an **in-place
cap editor** (preset buckets + suggested value + guardrail), Stripe
**invoices** (with billed PDFs per invoice), and the default **payment
method**. Card / subscription changes deep-link to Stripe's hosted
portal.

Manual PDF editing is always free — only Automation / AI / API is
metered; a `$0` cap blocks all metered work (≠ "no cap").

**Shared, not duplicated:** the editor-fleet card, the Enterprise
upsell, and the meter (`@shared/billing` `MeterBar`) render in both the
free and subscribed views; money/cap math lives once in
`@shared/billing`. The page header is a sticky, full-bleed bar.

**New SaaS reads** (defensive — degrade to empty/"—" when the Stripe
mirror lacks a table, never 500):
- `GET /api/v1/payg/payment-method` — default card (brand / last4 /
expiry) from `stripe.payment_methods`.
- Invoice **PDFs processed** — billed line-item quantity from
`stripe.invoice_line_items`.

## Progress

- [x] Schema: `V22 linked_instance` (+ Supabase twin in #313)
- [x] `AccountLinkController` register / list / revoke (leader-only,
team from caller)
- [x] Device-credential filter (path-scoped, constant-time,
revocation-aware) + `SupabaseSecurityConfig` wiring (conditional)
- [x] `GET /api/v1/instance/whoami` + **`/entitlement`** (reuses
`EntitlementService`/`TeamBillingService`) + tests
- [x] Self-hosted backend (`app/proprietary`): orchestrator + instance
gate (dark + **fail-open**) + tests
- [x] Portal: in-app Supabase login modal + register hand-off +
`LinkContext` (unlinked default) + "Linked instances" view — all
`@shared` Storybook components
- [x] **Portal billing surface** — free (Processor trial) + subscribed
(Processor plan) Usage views to marketing spec; link-state derived from
the **live wallet**; in-place cap editor; over-cap banner
- [x] **SaaS reads** — payment-method endpoint + invoice billed-units
(defensive `stripe.*` mirror DAOs) + tests
- [x] Orphan guard: block leaving/accepting away from a team whose
departure orphans its linked instances
- [ ] Metering Step 2 (lease + reconcile loop) + bounded fail-open
cutoff
- [ ] Proprietary hardening (SaaS base-url config, secret-at-rest, finer
billable classification) + HTTP integration test
- [ ] Cross-repo Stripe lifecycle certified end-to-end (subscribe →
meter → cancel → 402)
- [ ] Admin ⟺ SaaS-leader enforcement (separate portal-team-mgmt
workstream)

## Verification — all green
| Gate | Result |
|---|---|
| `STIRLING_FLAVOR=saas :saas:test` | BUILD SUCCESSFUL (account-link +
payg, incl. `PaygPaymentMethodControllerTest`,
`PaygInvoicesControllerTest`) |
| `:proprietary:test` | BUILD SUCCESSFUL (account-link + entitlement
cache/interceptor) |
| portal | tsc 0 · eslint 0 · **vitest 55** · storybook build (all
billing stories) |
| frontend post-sync | typecheck shared + portal + editor (saas +
desktop): 0 |

## Screenshots — billing UI
_Latest Storybook renders (Portal/Billing). Drag each capture below its
caption — kept out of the repo._

**Linked · Free — Processor trial**


<img width="1648" height="503" alt="01-free-processor-trial"
src="https://github.com/user-attachments/assets/afe6238a-d3b4-47fd-8ea2-cbaed8b0a653"
/>

**Linked · Subscribed — Processor plan dashboard**

<img width="1648" height="930" alt="02-subscribed-processor-plan"
src="https://github.com/user-attachments/assets/329e6808-a9a9-4e65-99af-5a8a5e6bf4ab"
/>

**Spend limit — in-place cap editor**

<img width="1648" height="411" alt="03-spend-limit-editor"
src="https://github.com/user-attachments/assets/acc95096-bf8e-4ab0-a32c-3c20dc94f816"
/>


## Review feedback applied
Reworked the portal after first-pass feedback: linking signs in via the
**shared Supabase login** (SSO + email/password) — no bespoke form; the
**device secret is never shown in or sent to the FE** (the local backend
registers + stores it server-side); billing copy reads **PDFs**, not
"units"; the wallet surface uses **`@shared` components** matching the
SaaS Plan page. Re-verified including an assertion the link response
carries no `deviceSecret`/`deviceId`.

**Synced onto unified auth + in-app login (2026-06-23).** Merged `main`
incl. **#6725 unified auth** (`frontend/shared/auth`); the link flow
uses a shared `useSupabaseLogin` hook + `SupabaseLoginForm`, a portal
`LinkAccountModal`, and `useAccountLink.completeLink(session)` (+
on-mount SSO redirect-return). Config: `VITE_SAAS_SUPABASE_URL` +
`VITE_SAAS_SUPABASE_ANON_KEY`. The local `/account-link/link` call
carries the Spring admin bearer with the SaaS JWT in the body. **SSO**
needs the SaaS Supabase project to allow-list the portal redirect URL
(email/password works without it).

## Assumptions / open
- **Proprietary remains a scaffold** (placeholder SaaS base-url,
plaintext device secret at rest, coarse billable classification).
- Payment-method + invoice-quantity render only when
`stripe.payment_methods` / `stripe.invoice_line_items` are in the
Sync-Engine target (confirm in the Supabase/Sync-Engine config);
otherwise they degrade gracefully.
- A self-contained local HTML report + manual E2E runbook live in
`notes/account-link-report/` (dev artifacts, outside the repo).

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-29 13:35:07 +00:00
Anthony Stirling 84739e8b0e Align settings.yml defaults and fix dead/mismapped settings (#6816)
# Description of Changes

Align settings.yml defaults and fix dead/mismapped settings

---

## 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.
2026-06-29 11:33:55 +00:00
Anthony Stirling 0996277c41 Brand MSI installer and rename display name to Stirling PDF (#6764)
# Description of Changes

Add icons to stirling PDF installer and changed app name from
Stirling-PDF to Stirling PDF

<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/6f23b501-d765-43a6-a713-b330ea199a04"
/>
<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/83d50ac9-2220-474b-8269-bfcfad01166c"
/>
<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/f0113ef5-9567-46d0-820c-0891d33b2355"
/>

vs old

<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/d50fa652-cb42-4668-b951-4f2ce52eba14"
/>
<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/b113890b-f06d-4dea-9738-1b885a9ba125"
/>
<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/3b6792aa-48a5-425f-9ae2-13938fd297a5"
/>


---

## 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.
2026-06-29 11:17:30 +00:00
Anthony StirlingandJames Brunton d508bc41bf Fix PR docker CI when the base image changes (#6809)
# Description of Changes

- Fix PR CI for base-image changes: the embedded build's buildx
container builder could not resolve the locally-built
`stirling-pdf-base:pr-test` and tried to pull it from a registry,
failing the build
- `test-build-docker.yml`: when the base changed, build the embedded
image with the docker driver (`docker build`) so the locally-built base
resolves from the daemon image store
- `docker-compose-tests.yml`: when the base changed, skip the buildx
container builder + gha cache so `test.sh`'s local base build resolves
via the default docker driver

---

## 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 (if functionality has heavily
changed)
- [ ] I have read the section Add New Translation Tags (for new
translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-29 09:59:17 +00:00
James Brunton 6ff910f26c Expand any type linting in frontend (#6808)
# Description of Changes
Continued effort to expand linting scope to ban the `any` type in our
codebase. This PR pulls in a lot of subfolders into the linting scope,
because the excluded list was getting short enough that it was feasible
to move a layer down. I then fixed all the trivially fixable `any` type
violations in the subfolders, which just required local changes to the
one file. The aim of this PR is more to expand the scope to all the
folders we can that already avoid `any` types, rather than actually fix
violations.
2026-06-29 08:32:30 +00:00
James Brunton 013f145462 Upgrade to TS7 for local type-checking (#6815)
# Description of Changes
We can't convert to TS7 completely yet because it lacks the TS API, so
ESLint and some of our scripts don't work, but we can do [what the TS
team suggest and run TS6 and TS7
side-by-side](https://devblogs.microsoft.com/typescript/progress-on-typescript-7-december-2025/#compiler).
When we do that, we take the `task frontend:typecheck:all` job from ~76s
to ~13s, and everything else continues to work as it did before.

I've set it so that CI will still use TS6 for the time being and locally
we use TS7 out of an abundance of caution because CI time doesn't really
matter but local time does. I do think it was a bit pointless doing that
since the TS team claim the type checking performs identically, but we
might as well have it like that for now. If it happens to go badly
locally for any devs, they can use `CI=true task frontend:typecheck` to
revert to use TS6 trivially.
2026-06-26 15:05:07 +00:00
James Brunton eea9696bd4 Actually build the frontend for Playwright nightlies (#6817)
# Description of Changes
[Our nightlies have literally never passed
before](https://github.com/Stirling-Tools/Stirling-PDF/actions/workflows/nightly.yml).
As far as I can tell, that's because the frontend was never being built,
so the Playwright tests would just never start up.

I've forced a nightly run from this branch, and the Playwright tests
still fail, but for legitimate failures now. It's a separate job to
track down why they're actually failing, so I'm leaving that for
followup work.
2026-06-26 14:53:51 +00:00
James Brunton 3f7e898c69 Add sources service and frontend (#6774)
# Description of Changes
Redesign policies backend to treat sources a lot closer to how the
frontend imagined them working (they're persistent now and have an API).
Then connect the portal to the sources when mocks are off to allow for
source creation in the UI. It's not particularly useful to do that right
now because there's no policies UI, but I've tested manually that
sources set up in the UI are usable by policies created via the API.

I had to change the portal so that when mocks are off, it doesn't just
hard crash when attempting to connect to all the backend APIs that don't
exist yet. It'll still log the errors, but just continues on rendering
the UI now.

I also changed all the policies backend APIs to be gated behind a flag
instead of behind the SaaS profile. This is because we haven't yet got
the payment model sorted, but we're going to need this stuff running
self-hosted to be able to test it locally.
2026-06-26 13:21:53 +00:00
James Brunton def3cf79f6 More desktop CI optimisations (#6786)
# Description of Changes
- Change the nightly build to not sign any of the desktop builds, since
we just care about the compiled code. The restored code will still be
signed dependent on the OS in the PR builds.
- Change RPM Linux to use zstd for compression because the one it was
using runs really slowly, and the Jar is already compressed so it makes
basically no difference (arguably we shouldn't compress at all)
- ~Switch to consistently use Depot for Docker caching to stop filling
up the GHA cache and evicting the Rust cache~ Decided against switching
to Depot because we're probably doing another PR to remove Depot
altogether in the near future
2026-06-26 11:08:08 +00:00
Matheus Saito 501a7199e0 Add bulk comment and annotation clearing to editor (#6792)
# Description of Changes
Closes #6695 

This PR adds bulk cleanup actions for comments and annotations in the
PDF editor, while tightening the save and navigation behavior around
annotation edits.

### Comments sidebar

Adds a “Clear all comments” action to the comments sidebar overflow
menu. The action opens a confirmation modal before clearing sidebar
comments and replies.

The implementation distinguishes between standalone comment annotations
and comments attached to existing visual annotations. Standalone
comments and replies are removed from the document, while comments
attached to markup, shapes, ink, or other visual annotations are cleared
from the sidebar without deleting the underlying annotation itself. This
preserves the visible document markup while removing the comment
metadata and persisted comment contents.

The comments sidebar state is also reset after clearing, including draft
comments, reply drafts, edit state, and open confirmation/delete modal
state.

### Annotate tool

Adds a document-level “Clear all annotations” action to the Annotate
tool. The action is exposed through the annotation panel’s overflow menu
and uses a confirmation modal before removing annotations.

The clear operation is routed through the existing annotation API bridge
and delegates to EmbedPDF’s document-level annotation clearing API. The
UI handles unavailable annotation state, successful clears, and
failures.

After annotations are cleared, the editor resets annotation interaction
state, exits placement/selection-specific state, returns to select mode,
and marks the document as having unsaved changes only when annotations
were actually removed. The user can then persist the removal through the
normal Save Changes flow.

### Save and navigation hardening

Improves the viewer save/apply flow used by annotations and manual
redactions.

Save operations are now deduplicated while an apply operation is already
in flight, preventing duplicate exports or duplicate file consumption
when users trigger save/navigation repeatedly.

The global unsaved-changes navigation modal now waits for “Apply &
Leave” to complete successfully before navigating. If saving fails, the
modal keeps the user in place instead of leaving with unsaved edits
still present.

The Annotate panel also prevents “Save Changes” and “Clear all
annotations” from running concurrently.

<!--
Please provide a summary of the changes, including:

- What was changed:
- Why the change was made
- Any challenges encountered

-->

---


## 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)
- [X] 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)

Clear all comments : 
<img width="310" height="397" alt="image"
src="https://github.com/user-attachments/assets/d1682611-13f8-4f40-aa77-44b37450e56e"
/>

Clear all annotations: 
<img width="284" height="549" alt="image"
src="https://github.com/user-attachments/assets/e4049bc1-f07b-4b36-b08e-ad6d6b86fe62"
/>



### 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.
2026-06-25 08:55:11 +00:00
Anthony Stirling bc6f1a1ff5 Add login agreement disclaimer feature (#6766) 2026-06-24 22:07:19 +01:00
Anthony Stirling d06d3cabaf chore: update svg conversion and database import handling (#6796) 2026-06-24 22:01:32 +01:00
EthanHealy01 b040277220 fast-path local PDF transport and reduce chat re-renders (#6798) 2026-06-24 21:44:13 +01:00
dependabot[bot]andAnthony Stirling f715a73f1b build(deps): bump astral-sh/setup-uv from 8.1.0 to 8.2.0 + translation files (#6748)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-24 21:42:56 +01:00
Anthony Stirling 5be9a0e1df fix desktop bundles (#6773)
# Description of Changes

Changes
- Use 127.0.0.1 instead of localhost for the local backend. The bundled
backend starts on a random port and binds the IPv4 wildcard, but the
frontend health-checked http://localhost:{port}. On macOS (and some
Linux) localhost resolves to IPv6 ::1 first, so the connection is
refused and every backend-dependent tool shows "backend offline" even
though the backend started fine. Switched getBackendUrl() and the
health-check URL to the 127.0.0.1 loopback literal (already in the Tauri
HTTP capability allowlist, and what the OAuth loopback server already
uses). Client-side tools were unaffected, which matches the reports.
- Fail the desktop build when the bundled JRE is older than the app JAR.
The app JAR is compiled for Java 25, but the bundle could ship an older
runtime/jre (jlink:runtime short-circuits on an existing runtime, and
nothing checked its version), producing UnsupportedClassVersionError at
launch so the backend never starts. Added a jlink:verify task that reads
the jlink release file and fails the build if the bundled JRE major is
below REQUIRED_JAVA (25, kept in sync with build.gradle
modernJavaVersion). It runs after the runtime is staged - including the
short-circuit reuse path that lets a stale JRE slip through.
Cross-platform Node script, no new 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.
2026-06-24 15:12:48 +00:00
Anthony Stirling f7f7b8790e fix update notification visibility and install flicker (#6776)
# Description of Changes

- Closes #6754
- Update popup now hidden on mobile, for non-admins, and never on SaaS
- Respects admin "Show Update Notifications" setting (`showUpdate` /
`showUpdateOnlyAdmin`, now default on)
- Fixes update modal flickering during desktop install
---

## 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.
2026-06-24 14:00:43 +00:00
Ludy 26021425e3 chore(ci): upgrade Gradle to 9.6.0 across workflows, Docker builds, and wrapper (#6790)
# Description of Changes

## What was changed

- Updated all GitHub Actions workflows using Gradle from older versions
(9.3.1 and 9.5.1) to Gradle 9.6.0.
- Updated the Gradle Wrapper distribution URL to use Gradle 9.6.0.
- Updated all Gradle-based Docker build stages to use the
`gradle:9.6.0-jdk25` image and corresponding image digest.
- Aligned CI, Docker, and local development environments on the same
Gradle version.
- Included the regenerated `gradlew` script changes produced by the
Gradle wrapper update process.

## Why the change was made

- Ensures consistent Gradle versions across local development, CI
workflows, and Docker builds.
- Takes advantage of the latest Gradle 9.6.0 improvements, fixes, and
compatibility updates.
- Reduces the risk of version mismatches causing build or deployment
inconsistencies.
- Simplifies maintenance by standardizing the build toolchain throughout
the repository.

---

## 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.
2026-06-24 08:36:05 +00:00
Ludy e35594f946 chore(build): centralize Gradle dependency version management (#6499)
# Description of Changes

This change centralizes several dependency version declarations into
shared Gradle version properties and updates module build files to
reference those properties instead of hardcoded version strings.

### What was changed

- Added centralized version properties in the root `build.gradle` for:
  - commons-io
  - commons-lang3
  - rhino
  - okhttp BOM
  - gson
  - guava
  - bucket4j
  - archunit
  - batik
  - jpdfium
  - JWT
  - AWS SDK
  - Testcontainers

- Replaced hardcoded dependency versions across multiple modules with
shared version variables.
- Updated `resolutionStrategy.force` declarations to use centralized
version properties.
- Updated dependency constraints and BOM references to use shared
version variables.
- Removed module-specific duplicate version declarations from
`app/proprietary/build.gradle`.
- Standardized dependency declarations across `common`, `core`,
`proprietary`, and `saas` modules.

## Why the change was made

- Reduce duplication of dependency version definitions.
- Simplify future dependency upgrades and maintenance.
- Ensure consistent dependency versions across all modules.
- Improve readability and reduce the risk of version drift between
subprojects.
- Make security-related dependency overrides easier to maintain from a
single location.

---

## 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.
2026-06-24 08:34:59 +00:00
Anthony Stirling 8a0b12b5ab Remove ffmpeg from published Docker images (#6791)
## Summary

Published Docker images (`stirling-pdf:latest`, `:2.13.1`) still shipped
the full `ffmpeg` package even though it was disabled in source back in
#6053.

**Root cause:** `push-docker.yml` passed a hardcoded
`BASE_VERSION=1.0.0` build-arg for the regular image, overriding the
Dockerfile's `ARG BASE_VERSION=1.0.2` default. Base `1.0.0` is the
original base that still does the explicit `ffmpeg` apt install, so the
published image never picked up the removal.
2026-06-24 08:21:28 +00:00
Ludy 60fff188a6 fix(team): hide users already assigned to the selected team (#6760)
# Description of Changes

# Description of Changes

- What was changed
- Filtered the "Add Member to Team" user picker so users who are already
members of the selected target team are no longer shown.
  - Applied the same filtering in both team management entry points:
    - `TeamsSection`
    - `TeamDetailsSection`
- Kept users in other teams visible, so they can still be moved into the
selected team.

- Why the change was made
- The modal was showing users who were already part of the target team,
which made the action misleading and allowed redundant selection.
- Hiding already-assigned users keeps the UI aligned with the actual
action: adding new members to the team

before:

<img width="442" height="425" alt="image"
src="https://github.com/user-attachments/assets/9faf991f-a7d3-4a48-91cd-f47730decde8"
/>

after:

<img width="430" height="382" alt="image"
src="https://github.com/user-attachments/assets/f8291172-feb6-4d8d-b536-eebf752b764b"
/>



---

## 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.
2026-06-24 08:15:08 +00:00
James Brunton 41181c9da1 Redesign tool config types to avoid any typing (#6582)
# Description of Changes
Fixes one of the main causes of `any` typing left in tools, the way that
we register tool parameters in the registry. Currently, it just accepts
tool params via `any`, but instead we can explicitly change them to
`Record<string, unknown)`, so on the way back out they can more safely
be cast back to their correct type when known.

One consequence of this is that I had to redesign the way we
special-case the Convert tool, which previously was a different shape
than all the other param types. Now it's just got optional parameters on
it, which isn't quite as type-safe as before, but it does mean all tools
are a consistent shape now, which I think is worth the tradeoff.
2026-06-23 15:44:52 +00:00
EthanHealy01 8e485801c9 change policies ui (#6683)
• Removed colors from policies to make them look more professional.
• upgraded to enterprise link to contact us.
• Hid inactive policies from users (Kept for admin and team lead).
• Closing policies had wrong arrow, made a standard component for chat,
tools and policies header.
2026-06-23 13:57:17 +00:00
EthanHealy01 436afa51d7 Always use the modern logo in the SaaS build (#6775)
## What

Make the **SaaS** build always use the modern logo, so the classic logo
can no longer appear anywhere in the SaaS app.

This is a minimal, SaaS-only alternative to the full classic-logo
removal PR (~80 files). **OSS (`core`) and proprietary builds are
untouched** — they keep the full modern/classic variant system,
including the admin _Logo Style_ picker.

## How

A single SaaS-layer override shadows the core hook:

- `frontend/editor/src/saas/hooks/useLogoVariant.ts` → returns
`"modern"` unconditionally.

In the SaaS build the `@app/*` alias cascade resolves
`@app/hooks/useLogoVariant` to `src/saas/*` before `src/core/*`, so this
shadows the core implementation (which otherwise resolves the variant
from the stored user preference or the server `logoStyle`).

## Why one file is enough

All logo rendering funnels through `useLogoVariant()`:

- `useLogoAssets()` → favicon, web manifest, apple-touch icon, wordmark,
`logo512`, tooltip logo — consumed by `BrandingAssetManager` (which sets
`<link rel="icon|manifest|apple-touch-icon">`), `Wordmark`, `LogoIcon`,
`Tooltip`.
- `useLogoPath()` → the no-text logo SVGs.
- The login-carousel slides (`buildLoginSlides`) receive the variant
from `AuthLayout`, which calls the hook.

Everything else that references a logo in SaaS already hardcodes
`modern-logo` (`index.html`, the SaaS
`Login`/`Signup`/`AuthCallback`/`OAuthConsent` routes, cloud onboarding,
account/MFA QR logos).

The only hardcoded `classic-logo` reference — the admin _Logo Style_
picker in `AdminGeneralSection` — is **not shipped in SaaS**:
`createSaasConfigNavSections` builds from the core nav sections and
never includes the proprietary admin sections.

`manifest-classic.json` and the classic assets remain in the shared
`public/` folder (served by all builds) but are never referenced in the
SaaS bundle.

## Test plan

- [x] `task frontend:typecheck:saas` — clean
- [x] `eslint` on the new file — clean
2026-06-23 13:03:17 +00:00
Anthony Stirling 0a29186ed6 Add JUnit tests to raise coverage across all modules (#6782)
# Description of Changes

AI generated junit tests

---

## 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.
2026-06-23 09:27:03 +00:00
James Brunton 101502cf4f Upgrade to TypeScript 6 (#6772)
# Description of Changes
TS6 introduced backwards-incompatible changes which affected us a little
bit. Other than that, I don't think it significantly changes things for
us, but we will need to deal with these breaking changes to be able to
upgrade to TS7 (the version written in Go, so dramatically faster), so
I'd rather do the work now before TS7 actually releases.

Main things I've done:
- Removed the use of `baseUrl` in the `tsconfig.json` files 
- Explicitly provide the `node` types where needed
- Explicitly reference the un-referenced but required Google API types
- Dropped the installation of `madge` which we weren't using and wasn't
directly compatible with TS6
- Updated the `i18next` packages for explicit TS6 compatibility
- Explicitly override `tsconfck` to force TS6 compatibility since we
can't upgrade it. We're only using that for `vite-tsconfig-paths` and it
all still seems to work fine, so I don't think this is an issue. I think
we can theoretically drop `vite-tsconfig-paths` when we upgrade to Vite
8 ([because it supports
`paths`](https://v8.vite.dev/guide/features#paths)), but that's a bigger
job than I want to do in this PR
2026-06-23 08:52:32 +00:00
James Brunton f2b65f4a77 Make pre-commit scripts more OS-agnostic (#6724)
# Description of Changes
Fix #6723
2026-06-23 08:42:18 +00:00
James Brunton 1816bad1ba Unified auth for portal and editor (#6725)
# Description of Changes
Refactor frontend auth to the shared folder and hook it up to both the
portal and editor so they share the same system. Also adds various tasks
to help run the portal, including `task dev:portal` to spawn the portal
with the backend, and `task dev:portal:proxy` to spawn the editor,
portal and backend, and a reverse proxy (at localhost:3000) to allow you
to use both at once to simulate how this will actually be deployed,
allowing you to check whether the seamless transition between the two
actually works.
2026-06-22 16:22:36 +00:00
James Brunton 9aee85d55e Wrap first login popup in a form so enter works to change password (#6769)
# Description of Changes

> [!note]
> GitHub absolutely mangles the diff unless you change to ignore
whitespace changes

This page has never had the Enter key bound to the Change Password
button:

<img width="673" height="745" alt="image"
src="https://github.com/user-attachments/assets/7a1b06f0-2945-4270-a795-f799ec556c12"
/>

This PR changes the modal to be properly wrapped in a form so key
commands work correctly on it.
2026-06-22 13:59:28 +00:00
James Brunton 72f8705460 Build Rust cache on nightlies (#6768)
# Description of Changes
Rust cache added in #6732 never fired because `main` builds don't
include building the desktop apps. We could build them on `main` builds,
but that's fairly expensive, so just build them on nightlies instead to
warm the cache for any desktop PRs the next day
2026-06-22 13:59:13 +00:00
Reece Browne dffc292888 I18n on portal (#6761)
## Overview

Internationalizes the **developer portal**, which previously had **zero
i18n** — every string was hardcoded across ~118 components. Rather than
stand up a parallel system, this shares the **editor's** existing i18n
setup (same TOML locale format, same Crowdin pipeline), then converts
every portal surface to `react-i18next` and adds a CI guard so coverage
can't regress.

## What's included

### 🔗 Shared i18n core (`@shared/i18n`)
- Extracts the editor's `TomlBackend` (HTTP loader for
`public/locales/{lng}/translation.toml`) and language metadata/helpers
(the 42-language list, RTL set, `LanguageSource` priority, code
normalizers) into `frontend/shared/i18n/`.
- The **editor** now imports and re-exports these from `@shared/i18n` —
its 20+ consumers are unchanged. Its local `tomlBackend.ts` is deleted.
- The **portal** builds its own i18next instance from the shared core,
with **en-US as the source of truth** and the same
`/locales/{lng}/translation.toml` layout.

### 🌍 Full portal coverage
- Every view and component converted to `t()` — all feature areas (home,
pipelines, sources, infrastructure, usage, documents, agent-builder,
editor-admin, policies, users, docs, catalogue, components view) plus
app shell, nav, modals, and the home/domain widgets.
- **1108 keys across ~30 namespaces** in
`portal/public/locales/en-US/translation.toml`, grouped by feature;
shared strings under `[common]`. Plurals use i18next count forms;
dynamic labels (nav, settings sections, status badges) use template keys
against populated tables.
- Data-driven strings (values from `@portal/api/*` mocks, enum/id
values, code samples) are intentionally left untranslated — they're
data, not UI chrome.

###  CI coverage guard
- `portal/scripts/check-i18n.mjs` fails if any static `t("key")` in
portal source is missing from the en-US locale. Wired into
`frontend:check` and `frontend:check:all`, so missed keys break CI. This
mirrors the editor's `missingTranslations` test for the portal, which
has no vitest harness of its own.

## Testing
- `task frontend:check:all` passes locally (typecheck all variants,
lint, format, **portal i18n guard**, builds, tests, storybook).
- Every static `t()` key verified to resolve in the locale (1108 keys /
186 source files); all dynamic key prefixes map to populated tables.
- Runtime sweep of all 12 portal routes shows **no unresolved keys** on
screen; nav labels, plurals, and array-backed copy all render real text.

## Follow-ups (not in this PR)
- **Crowdin** — register `frontend/portal/public/locales/` as a source
so portal strings flow through the same translation pipeline as the
editor (an ops step on the Crowdin side; there's no Crowdin config in
the repo).
- Only `en-US` is populated; other languages will arrive via the
pipeline.
2026-06-22 12:57:12 +00:00
Ludy c95fb89c63 fix(frontend): correctly display the current user role in the edit dialog (#6758)
# Description of Changes

This PR fixes the role field in the People settings "Edit User" dialog
so the currently assigned role is displayed correctly.

- What was changed
- The edit dialog now uses the actual role identifier from the user data
when preselecting the role.
- The role selection is made more robust by falling back to a valid
default when the backend response does not provide a usable role value.
- The role label shown in the UI is derived consistently from the role
identifier.

- Why the change was made
- The dialog could open with an empty role field even though the user
already had a role assigned.
- This made role editing confusing and could lead to accidental changes.
- The fix keeps the People UI aligned with the backend role data model.

before:

<img width="438" height="465" alt="image"
src="https://github.com/user-attachments/assets/929d8c63-8ae9-436a-b895-e6a746f22458"
/>

after:

<img width="437" height="461" alt="image"
src="https://github.com/user-attachments/assets/942f6787-d678-4555-90a9-bf5c7c5c363d"
/>


---

## 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.
2026-06-21 10:25:30 +01:00
dependabot[bot]andAnthony Stirling 956b8000e4 build(deps): bump ws from 8.20.1 to 8.21.0 in /frontend (#6679)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 19:25:27 +01:00
Anthony Stirling a3fe15bfd0 Add metrics for numerical count of total PDFs (#6737) 2026-06-19 18:57:03 +01:00
EthanHealy01andJames Brunton 1a770af47c fix create tool in the AI chat (#6673)
AI PDF creation ("create a PDF for me") has been broken since the
Policies backend (#6527) introduced PolicyExecutor as the tool execution
pipeline. PolicyExecutor runs normal single-input tools with a per-file
loop, but generator tools like `create-pdf-from-html-agent` take no
input file and build their output purely from parameters. With zero
input files the loop ran zero times, so the endpoint was never called
and the step silently produced nothing. The chat reported success
("Created Purchase Order") while no document ever appeared.

This adds an `else if (inputFiles.isEmpty())` branch so a generator tool
is called once with an empty file list, matching what the multi-input
branch already does for an empty input. Two files changed: the
one-line-ish fix in `PolicyExecutor`, and a regression test covering the
no-input case.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-19 17:06:28 +00:00
Anthony Stirling 3870ac3d7d Add desktop mobile-upload page and fix LAN QR URL (#6736)
# Description of Changes

Desktop can not use QR code upload due to API backend not having UI for
it...
Because of this we add UI, has to be custom because can not support
OpenCV and in app camera due to its https requirement

<img width="919" height="2048" alt="image"
src="https://github.com/user-attachments/assets/d84c3903-fd23-421a-8919-24d89ba6c753"
/>
<img width="919" height="2048" alt="image"
src="https://github.com/user-attachments/assets/d6c8fdf3-837b-4dc3-92ea-37f7502f8462"
/>
<img width="919" height="2048" alt="image"
src="https://github.com/user-attachments/assets/fd4ce3b4-69ad-45dd-b066-bff2d082c583"
/>

<img width="1550" height="790" alt="image"
src="https://github.com/user-attachments/assets/701d4dcc-ebd6-4e03-aa7b-2d8623525fc9"
/>

---

## 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.
2026-06-19 15:33:49 +00:00
James Brunton b9ea9064c7 Cache Rust build to improve Tauri build job times (#6732)
# Description of Changes
The Tauri jobs are very slow, especially the Linux ones, which can take
>1hr to build all the necessary code. A lot of that is because of the
actual Rust compilation, which isn't cached at all as far as I can tell.
This introduces a cache step for the Rust dependencies, so PRs will just
reuse the compiled Rust from the last build of main (if it's safe to do
so).
2026-06-19 15:27:14 +00:00
Anthony Stirling fe7a2a5ac7 Fix Multi Tool page rotation lost on save (#6733)
# Description of Changes

Rotating a page in the Multi Tool and saving could leave the page at its
original rotation (the change appeared lost), with inconsistent results
across pages.

- Page rotation is now always written on export, including 0°, so
rotating a page that already had a non-zero rotation in the source PDF
(e.g. a 270° page rotated back to upright) is no longer dropped.
- Per-page rotation is always read when building the Multi Tool
document, so pages keep their true orientation regardless of file size.
- Rotation is only applied after a page imports successfully, avoiding a
misaligned or failed export when an import fails.

---

## 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)
- [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.
2026-06-19 14:19:51 +00:00
James Brunton 6a9876a067 Fix more any typing usage in the frontend (#6664)
# Description of Changes
Continued effort to remove the remaining uses of the `any` type from our
TS code. The vast majority of these uses that it cleans up was just
catching errors as `any`, which are pretty simple to fix. I couldn't
completely remove the `any` type usage from `core/tools` because there
were cascading issues from a couple of the files in there (most notably
Automate) but still, moving in the right direction.
2026-06-19 13:37:53 +00:00
James Brunton 3793a6df52 Fix bad frontend architecture (#6730)
# Description of Changes
#6727 introduced frontend code which goes against the architecture, so
this PR re-implements it in the architecture properly, along with
another bad Tauri check that I found in the source. I also updated the
`AGENTS.md` file to use Claude's "read this file" syntax to try and
force AI to actually read the file instead of just suggesting that it
does it.
2026-06-19 12:34:13 +00:00
dependabot[bot]andAnthony Stirling 66841db2b7 build(deps): bump js-yaml from 4.1.1 to 4.2.0 in /devTools (#6680)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md">js-yaml's
changelog</a>.</em></p>
<blockquote>
<h2>[4.2.0] - 2026-06-01</h2>
<h3>Added</h3>
<ul>
<li>Added <code>docs/safety.md</code> with notes about processing
untrusted YAML.</li>
<li>Added <code>maxDepth</code> (100) loader option. Not a problem, but
gives a better
exception instead of RangeError on stack overflow.</li>
<li>Added <code>maxMergeSeqLength</code> (20) loader option. Not a
problem after <code>merge</code> fix,
but an additional restriction for safety.</li>
<li>Added sourcemaps to <code>dist/</code> builds.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Stop resolving numbers with underscores as numeric scalars, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/627">#627</a>.</li>
<li>Switched dev toolchains to Vite / neostandard.</li>
<li>Updated demo.</li>
<li>Reorganized tests.</li>
<li><code>dist/</code> files are no longer kept in the repository.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Fix parsing of properties on the first implicit block mapping key,
<a
href="https://redirect.github.com/nodeca/js-yaml/issues/62">#62</a>.</li>
<li>Fix trailing whitespace handling when folding flow scalar lines, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Reject top-level block scalars without content indentation, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/280">#280</a>.</li>
<li>Ensure numbers survive round-trip, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/737">#737</a>.</li>
<li>Fix test coverage for issue <a
href="https://redirect.github.com/nodeca/js-yaml/issues/221">#221</a>.</li>
<li>Fix flow scalar trailing whitespace folding, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Fix digits in YAML named tag handles.</li>
</ul>
<h3>Security</h3>
<ul>
<li>Fix potential DoS via quadratic complexity in merge - deduplicate
repeated
elements (makes sense for malformed files &gt; 10K).</li>
</ul>
<h2>[3.14.2] - 2025-11-15</h2>
<h3>Security</h3>
<ul>
<li>Backported v4.1.1 fix to v3</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/nodeca/js-yaml/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-yaml&package-manager=npm_and_yarn&previous-version=4.1.1&new-version=4.2.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)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

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 <dependency name> 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).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-19 12:15:23 +00:00
dependabot[bot]andAnthony Stirling 377677c182 build(deps-dev): bump js-yaml from 4.1.1 to 4.2.0 in /frontend (#6677)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md">js-yaml's
changelog</a>.</em></p>
<blockquote>
<h2>[4.2.0] - 2026-06-01</h2>
<h3>Added</h3>
<ul>
<li>Added <code>docs/safety.md</code> with notes about processing
untrusted YAML.</li>
<li>Added <code>maxDepth</code> (100) loader option. Not a problem, but
gives a better
exception instead of RangeError on stack overflow.</li>
<li>Added <code>maxMergeSeqLength</code> (20) loader option. Not a
problem after <code>merge</code> fix,
but an additional restriction for safety.</li>
<li>Added sourcemaps to <code>dist/</code> builds.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Stop resolving numbers with underscores as numeric scalars, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/627">#627</a>.</li>
<li>Switched dev toolchains to Vite / neostandard.</li>
<li>Updated demo.</li>
<li>Reorganized tests.</li>
<li><code>dist/</code> files are no longer kept in the repository.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Fix parsing of properties on the first implicit block mapping key,
<a
href="https://redirect.github.com/nodeca/js-yaml/issues/62">#62</a>.</li>
<li>Fix trailing whitespace handling when folding flow scalar lines, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Reject top-level block scalars without content indentation, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/280">#280</a>.</li>
<li>Ensure numbers survive round-trip, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/737">#737</a>.</li>
<li>Fix test coverage for issue <a
href="https://redirect.github.com/nodeca/js-yaml/issues/221">#221</a>.</li>
<li>Fix flow scalar trailing whitespace folding, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Fix digits in YAML named tag handles.</li>
</ul>
<h3>Security</h3>
<ul>
<li>Fix potential DoS via quadratic complexity in merge - deduplicate
repeated
elements (makes sense for malformed files &gt; 10K).</li>
</ul>
<h2>[3.14.2] - 2025-11-15</h2>
<h3>Security</h3>
<ul>
<li>Backported v4.1.1 fix to v3</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/nodeca/js-yaml/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-yaml&package-manager=npm_and_yarn&previous-version=4.1.1&new-version=4.2.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)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

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 <dependency name> 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).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-19 12:15:13 +00:00
dependabot[bot]andAnthony Stirling f25f7e5fc9 build(deps): bump dompurify from 3.4.1 to 3.4.11 in /frontend (#6722)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.1 to
3.4.11.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/cure53/DOMPurify/releases">dompurify's
releases</a>.</em></p>
<blockquote>
<h2>DOMPurify 3.4.11</h2>
<ul>
<li>Fixed an issue with a leaky config for hooks via
<code>setConfig</code>, thanks <a
href="https://github.com/trace37labs"><code>@​trace37labs</code></a></li>
<li>Bumped vulnerable development dependencies to arrive at plain 0 with
<code>npm audit</code></li>
<li>Updated the <code>osv-scanner</code> suppression list as no
vulnerable dependencies are left for now</li>
<li>Updated up the linting tool-chain and removed now-redundant lint
directives</li>
<li>Updated the documentation is several spots, README, wiki, etc.</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.10</h2>
<ul>
<li>Refactored codebase for clarity: extracted the public type
declarations into <code>types.ts</code></li>
<li>Decomposed the three largest sanitizer functions into focused
helpers</li>
<li>Removed duplicated defaults and dead branches, consolidated
<code>SAFE_FOR_TEMPLATES</code> scrubbing into single shared path</li>
<li>Improved per-node performance by hoisting the mXSS probe regexes and
testing <code>textContent</code> before <code>innerHTML</code></li>
<li>Added a deterministic micro-benchmark harness (<code>npm run
bench</code>) with a <code>--compare</code> mode</li>
<li>Reduced CI cost by running the full three-engine browser suite once
per PR</li>
<li>Refreshed the <code>demos/</code> folder so every demo runs again,
and added a SVG-via-<code>&lt;img&gt;</code> demo</li>
<li>Documented the bench and <code>test:happydom</code> scripts in the
README</li>
<li>Completed the Attack Classes &amp; Bypass History wiki page</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.9</h2>
<ul>
<li>Further improved the handling of Trusted Types config options,
thanks <a
href="https://github.com/offset"><code>@​offset</code></a></li>
<li>Further improved the handling of <code>IN_PLACE</code> sanitization,
thanks <a
href="https://github.com/mozfreddyb"><code>@​mozfreddyb</code></a></li>
<li>Added more test coverage for <code>IN_PLACE</code> and Trusted Types
related usage</li>
<li>Bumped several dependencies where possible</li>
<li>Updated README and wiki with more accurate documentation &amp;
attack samples</li>
</ul>
<h2>DOMPurify 3.4.8</h2>
<ul>
<li>Cleaned up the repository root, renamed some and removed unneeded
files</li>
<li>Fixed an issue with handling of Trusted Types policies, thanks <a
href="https://github.com/fulstadev"><code>@​fulstadev</code></a></li>
<li>Fixed the node iterator for better template scrubbing, thanks <a
href="https://github.com/IamLeandrooooo"><code>@​IamLeandrooooo</code></a></li>
<li>Included formerly missing LICENSE-MPL in published npm package,
thanks <a
href="https://github.com/asamuzaK"><code>@​asamuzaK</code></a></li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.7</h2>
<ul>
<li>Hardened the handling of Shadow Roots when using
<code>IN_PLACE</code>, thanks <a
href="https://github.com/GameZoneHacker"><code>@​GameZoneHacker</code></a></li>
<li>Removed a problem leading to permanent hook pollution, thanks <a
href="https://github.com/offset"><code>@​offset</code></a></li>
<li>Refactored the test suite and expanded test coverage
significantly</li>
</ul>
<h2>DOMPurify 3.4.6</h2>
<ul>
<li>Fixed several issues with DOM Clobbering in <code>IN_PLACE</code>
mode, thanks <a
href="https://github.com/offset"><code>@​offset</code></a> &amp; <a
href="https://github.com/Bankde"><code>@​Bankde</code></a></li>
<li>Hardened the checks for cross-realm <code>IN_PLACE</code> and Shadow
DOM sanitization, thanks <a
href="https://github.com/offset"><code>@​offset</code></a> &amp; <a
href="https://github.com/Bankde"><code>@​Bankde</code></a></li>
<li>Added more test coverage for <code>IN_PLACE</code> and general DOM
Clobbering attacks</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.5</h2>
<ul>
<li>Fixed a bypass caused by the new HTML element
<code>selectedcontent</code> added in 3.4.4, thanks <a
href="https://github.com/KabirAcharya"><code>@​KabirAcharya</code></a></li>
</ul>
<p><strong>Note that this is a security release for an issue introduced
in 3.4.4 and should be upgraded to immediately.</strong></p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/cure53/DOMPurify/commit/0cae5187403132f96a6d357649e4b15633fc210a"><code>0cae518</code></a>
release: 3.4.11 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1494">#1494</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/6ee5716f8336989753611beeca364957c0eb0c3e"><code>6ee5716</code></a>
release: 3.4.10 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1478">#1478</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/52102472d46035857c52df19e44285f8a1e102fc"><code>5210247</code></a>
release: 3.4.9 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1459">#1459</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/bcdd8285412dc9c4c149652aed2d712e790d6ccf"><code>bcdd828</code></a>
release: 3.4.8 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1439">#1439</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/ca30f070c360df162a3e3848e80e6fd3c9e74bff"><code>ca30f07</code></a>
release: 3.4.7 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1414">#1414</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/bb7739e5bccec7e1ab3dae3f3e42d02db3acaaae"><code>bb7739e</code></a>
release: 3.4.6 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1394">#1394</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/011b0c78f2a0f57ee54f5fcccb697a46ca6e63ea"><code>011b0c7</code></a>
release: 3.4.5 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1382">#1382</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/5817ad969c15e67dfcd6cb37248d6e9c1553e7c3"><code>5817ad9</code></a>
release: 3.4.4 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1374">#1374</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/520edb0371a9638f9b51f1798051299a250c686b"><code>520edb0</code></a>
release: 3.4.3 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1352">#1352</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/6f67fd396a7b8c64294343999fe607ca1f5299c0"><code>6f67fd3</code></a>
Sync/3.4.2 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1322">#1322</a>)</li>
<li>See full diff in <a
href="https://github.com/cure53/DOMPurify/compare/3.4.1...3.4.11">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=dompurify&package-manager=npm_and_yarn&previous-version=3.4.1&new-version=3.4.11)](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)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

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 <dependency name> 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).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-19 12:14:27 +00:00
James Brunton b57958531d Add message when running task with no arguments (#6731)
# Description of Changes
Add message describing the most common tasks when running `task` with no
arguments. I think this should help newcomers because `task --list` is
massive at this point and nobody's going to read through it all. Let me
know if you think any other commands should be in the default message.
2026-06-19 10:44:30 +00:00
Ludy f8ceca0c3f feat(i18n): sync editor translations with pluralization support and new UI strings (#6565)
# Description of Changes

## What was changed

- Updated editor translation files across multiple locales.
- Migrated numerous count-based translation keys from legacy
`{{plural}}` handling to ICU-style plural forms using `_one`, `_other`,
and where applicable `_zero` variants.
- Added translations and localization keys for newly introduced features
and UI areas, including:
  - Stirling Agents
  - Chat interface and quick actions
  - Files management and folder organization
  - Desktop update workflow
  - Folder scanning warnings
  - Team and workspace management
  - Sharing and upload dialogs
  - Comparison status messages
  - Relative time formatting
  - Additional tool panel and update UI strings
- Added missing translation entries required by recently introduced
frontend functionality.
- Reorganized some translation sections to maintain consistency and key
ordering.

## Why the change was made

- To align locale files with the current frontend feature set.
- To support proper pluralization behavior across languages.
- To prevent missing translation keys and fallback text in newly added
UI components.
- To improve localization consistency and maintainability as the
application grows.

---

## 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.
2026-06-19 10:34:55 +00:00
Anthony Stirling e6d476297d Clean up update dialog UI and fix desktop external links (#6727) 2026-06-18 22:20:02 +01:00
stirlingbot[bot] 3456316569 Update Backend 3rd Party Licenses (#6719)
Auto-generated by stirlingbot[bot]

This PR updates the backend license report based on dependency changes.

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-06-18 19:54:02 +00:00
Anthony StirlingandEthanHealy01 215bba39bc Give SaaS users their own team and harden the user list endpoint (#6717)
# Description of Changes

Previously, new SaaS users were placed on a shared Default team and then
migrated to their own. A race (or a failed migration, or an
anonymous→registered upgrade) could leave them stuck on that shared
team, where unrelated users could see each other
Instead they now get their own personal team during creation so
unrelated users no longer collide on one team. SaaS-only
(@Profile("saas")); self-host's Default behaviour is untouched.
Also happens during call to avoid uncaught users

Scope GET /api/v1/user/users. Anonymous callers get 403; a caller on a
system team (Default/Internal) gets only themselves, not the team's
members.

---

## 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.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-06-18 16:27:22 +00:00
ConnorYohandEthanHealy01 900b66b030 chore(saas): remove dead ErrorTrackingService island + credits path exclusion (#6718)
## What

Residual dead-code cleanup following the credits engine teardown
(#6687).

- **Delete the `ErrorTrackingService` dead island** (6 files): the
service, `UserErrorTrackerRepository`, `UserErrorTracker`,
`ProcessingErrorType`, `CreditsProperties`, and
`ErrorTrackingServiceTest`. These formed a self-referential cluster with
**zero external callers** once the credit machinery was removed.
- **Remove `/api/v1/credits/**`** from both `excludePathPatterns` blocks
in `PaygWebMvcConfig` — the credits controller no longer exists, so the
exclusion is defunct. (spotless collapsed the lists to one line.)

## Verification

- `./gradlew :saas:compileJava :saas:compileTestJava` → **BUILD
SUCCESSFUL**
- grep confirms zero dangling references to the deleted types

## Not in scope (deliberately deferred)

Destructive DB drops
(`user_credits`/`team_credits`/`user_subscription_plans` tables, dead
`payg_shadow_charge` columns, `user_error_tracker` table) are gated
behind the post-release soak (`live_ratio==1.0 ≥7d`) and tracked in a
separate bundle.

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-06-18 14:32:54 +00:00
c3795c1a3c fix(viewer): wire Ctrl+A to select all text in the PDF (#6517)
# Description of Changes

Allow Ctrl A support in viewer and fix select text to copy issues via a
hovering copy button

---

## 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.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-18 13:50:08 +00:00
Anthony StirlingandEthanHealy01 c8925acee7 add prerendered Open Graph previews and OG card generator (#6661)
# Description of Changes

add prerendered Open Graph previews and OG card generator
so that /compresss etc shows a pre generated static html file (Since
google etc doenst render javascript)


---

## 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.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-06-18 13:49:16 +00:00
James Brunton eb08e60d67 Redesign pre-commit commands to run through Task (#6670)
# Description of Changes
The `pre-commit` commands in this repo are inconsistent with the rest of
the dev workflow, as they are impossible to run through Task and they
can cause CI to fail with no way for a developer to run the `pre-commit`
scripts after they've failed. This PR adds `task pre-commit` (and `task
pre-commit:fix`) and then hooks up the existing `pre-commit` hooks and
CI to call the Task rule, so if developers are using pre-commit hooks
then they should still work, but they're also runnable without using
pre-commit at all.

I think it'd be worth reviewing what we're actually running at
pre-commit in the future because I'm not entirely convinced by all of
the scripts that we are running, but this should at least make what we
have properly enforced and usable by all devs.
2026-06-18 12:55:53 +00:00
EthanHealy01 18da914bf9 fix theme issues, remove dead rainbow mode code, standardize theme us… (#6668)
Fix issues with the theme of the app that caused some things to persist
in light mode/dark mode whilst the rest of the app was the opposite
theme.

Removed dead rainbow mode code.

Added system theme option to settings.
2026-06-18 12:42:19 +00:00
Reece Browne 8f46ca0d92 feat(policies): lock policies to the SaaS build + profile (#6702)
## What & why

Policies (automation-backed enforcement) execute and bill through the
cloud backend, so the feature should only be available in the hosted
**SaaS** product — not in self-hosted proprietary or core builds. Today
it's enabled in the proprietary build (and the API is exposed in any
proprietary backend), so this locks it to SaaS on both layers.

## Frontend (build-flavor gate)

`POLICIES_ENABLED` is the single gate `usePoliciesEnabled` uses (rail +
auto-run controller).

- `proprietary` flag → **`false`** (self-hosted web no longer shows
policies)
- new `src/saas/constants/featureFlags.ts` → re-exports proprietary
flags, overrides `POLICIES_ENABLED = true`
- new `src/desktop/constants/featureFlags.ts` → same `true` override —
**required**: desktop's `@app` alias has no saas layer, and desktop
already gates policies on `POLICIES_ENABLED && useConfirmedSaaSMode()`,
so without a `true` here that runtime gate could never be satisfied.
Behaviour unchanged: desktop shows policies only when connected to SaaS.
- `PoliciesSidebar.test` mocks the flag on (it tests the component, not
the build gate — same pattern the existing `usePolicyAutoRun.retry.test`
uses).

## Backend (`@Profile("saas")` gate)

The saas backend runs under the `saas` Spring profile (as
`EntitlementGuard`, the AI controllers, etc. already do). The policy
beans are now `@Profile("saas")`, so `/api/v1/policies/*` and the
auto-run triggers exist **only** in the saas backend:

`PolicyController`, `PolicyEngine`, `PolicyRunner`, `PolicyRunRegistry`,
`PolicyValidator`, `JpaPolicyStore`, `FolderInputSource`,
`FolderOutputSink`, `InlineOutputSink`, `PolicyAccessGuard`,
`FolderAccessGuard`, `FolderWatchTrigger`, `ScheduleTrigger`,
`PolicyTriggerManager`.

**Deliberately *not* gated:** `PolicyExecutor` — `AiWorkflowService`
(always-on) injects it to run ad-hoc pipelines, so it stays
profile-free. It only depends on shared infra (`InternalApiClient`,
`ToolMetadataService`, `TempFileManager`, `ObjectMapper`), so leaving it
on is safe. Gating the engine/store/triggers as a set keeps wiring
consistent (nothing un-gated depends on a gated bean).

The saas `PolicyManagementAuthority` impl
(`TeamLeaderPolicyManagementAuthority`, `@Profile("saas")`) satisfies
`PolicyAccessGuard` in the saas context.
`AdminPolicyManagementAuthority` (`@Profile("!saas")`) becomes an unused
orphan in non-saas builds — harmless; left as-is rather than expanding
this PR's scope.

## Testing
- Frontend: full suite **869 pass**; typecheck clean on
proprietary/saas/core.
- Backend: `:proprietary` compiles, spotless clean, policy tests pass,
and the proprietary (non-saas) Spring context still boots with the
policy beans gated out (verified via the MCP `@SpringBootTest`
integration tests — no missing-bean failures).

Net: SaaS web build + desktop-in-SaaS-mode get policies (UI + API);
self-hosted proprietary and core get neither the UI nor the
`/api/v1/policies` endpoints.
2026-06-18 11:05:55 +00:00
Anthony Stirling 9a3bc6b47f Add cloud-aware delete and version history to My Files (#6704)
## /files
- Cloud-aware delete: choose device / cloud / both
- `/files` left rail always collapsed
- Details panel: pinned buttons, collapsible info, smaller preview
- Removed redundant Quick view
- New i18n keys added to en-US/en-GB

## Sidebar 
- Sidebar kebab: upload to server, delete, version history (+ cloud
badge)
- Version history modal everywhere files appear
2026-06-18 10:33:18 +00:00
Reece Browne 2b05865a84 Portal: unified-design surfaces (Policies, Users, Components, Agent Builder, Editor deploy) + Settings rebuild (#6696)
Builds the remaining developer-portal surfaces from the unified design
and rebuilds Settings, on top of the portal scaffold merged in #6686.
All tier-aware, mock-driven (MSW), componentised with Storybook
coverage. Touches only `frontend/portal` + `frontend/shared` — the
editor is untouched.

## New surfaces
- **Policies** — org-wide governance across the five categories
(Ingestion / Security / Compliance / Routing / Retention) with a
designer + per-doc-type overrides
- **Users** — members, roles, invite, tier-scaled SSO/SCIM access
- **Components** — embeddable `@stirling/*` SDK catalogue with
per-action pricing
- **Getting Started** — three-step funnel (use case → analyse a document
→ API key + snippets)
- **Agent Builder** — agent lifecycle (scenarios, tool modes,
evals/golden-sets, versions), reached from Sources
- **Editor deployment** — deploy/pair/operate the editor (targets,
pairing, health, credential rotation, air-gapped bundle), reached from
Infrastructure

## Reworks
- **Documents** → review/approval queue (confidence, extractions, audit
drawer, zero-standing-access elevation); the doc-type catalogue is
retained as a second tab
- **Pipelines** → golden-set pass column + "Promoted from the Editor"
section
- **Infrastructure** → new **Models** tab; deeper **Security** (managed
/ BYOK / HYOK + SOC 2 / ISO 27001 / HIPAA / GDPR / PCI attestations)
- **Home** → "What runs on your PDFs" policy summary + tier-aware
processing-status strip + pipeline-fork wizard

## Settings & shared
- New shared **`SettingsShell`** (grouped left-nav + content pane),
modelled on the editor's account-settings modal so both apps can
converge on one layout
- Portal **Settings** rebuilt on it as scoped sections — Account /
Workspace / Admin (Authentication, Active sessions, Early access)

## Brand
- Adopt the editor's brand mark + favicon; sidebar reads **Stirling
Processor**; app-switcher labels the active app "Processor"

## Mock contract
- Every surface follows the 3-layer pattern (typed `api/*` → MSW handler
→ fixtures); new endpoints documented in `MOCKS.md`. The read contract
is backend-ready; writes are marked `// TODO(backend): <METHOD> <path>`.

## Verification
- tsc (portal + shared) ✓ · eslint ✓ · dpdm (no circular) ✓ ·
`build:portal` ✓ · `storybook:build` ✓ · Prettier ✓

## Deferred (noted, not in scope)
- Unified shell / auth / role→surface routing / Workspace=Plan
(architectural epic)
- Tier rename (Editor / Processor / Bespoke) and the Usage flat-pricing
+ PAYG quick-amounts + Bespoke modal
- Editor adopting the shared `SettingsShell`; converting marked
write-stubs into live `api/` seams
2026-06-18 10:28:03 +00:00
James Brunton 0c503cc41d Fix all top-level dev tasks treating engine as enabled (#6705)
# Description of Changes
Currently, `task dev` explicitly calls the backend with
`AIENGINE_ENABLED=true` even though it isn't being spawned, so you just
get a dead FAB in the UI. This PR fixes it so that the engine will only
be enabled for tasks that will actually spawn the engine.

It also fixes a bug with the chat which makes it unusable locally. The
API path was not going through `apiClient` so for local dev you end up
with `//api/v1/...` which is not a valid path, so you get CORS errors
when trying to connect to the AI engine.
2026-06-18 10:08:50 +00:00
albanobattistella b1fef4c647 Update Italian translations (#6713) 2026-06-18 08:35:31 +00:00
EthanHealy01 06254853af allow drag and drop onto left files section and make top bar slightly smaller (#6711)
<img width="1261" height="984" alt="Screenshot 2026-06-17 at 5 59 30 PM"
src="https://github.com/user-attachments/assets/849dee17-1927-4336-81fc-dff7e91e55e7"
/>
2026-06-18 08:28:11 +00:00
Anthony Stirling d9e6041a75 set z-index on config dropdowns so they render above the modal (#6674) 2026-06-18 09:08:50 +01:00
Anthony Stirling 8f81fdc762 Use glibc base for ultra-lite and bundle per-arch JPDFium natives (#6706) 2026-06-18 08:32:55 +01:00
James Brunton 13af10a6d1 Redesign policy running (#6609)
# Description of Changes
Redesign policy running so the server is in charge of policy IDs and
running, to make it impossible to have the frontend miss the results.
This solves a minor bug that we currently have in policies, where if you
load a file and then refresh while the policy is running, you'll never
receive the outputted file.
2026-06-17 16:18:50 +00:00
EthanHealy01 3750111ffc fix agent overlay chat position when workbench size changes (#6682)
<img width="2056" height="1047" alt="Screenshot 2026-06-16 at 12 42
05 AM"
src="https://github.com/user-attachments/assets/74a38b93-f31f-4263-bb62-24c2334a22e8"
/>
<img width="1443" height="1051" alt="Screenshot 2026-06-16 at 12 42
33 AM"
src="https://github.com/user-attachments/assets/adb3ba47-f3e6-44a7-bbc3-2097e15843b6"
/>
2026-06-17 15:54:09 +00:00
ConnorYoh 20c88feabb refactor(saas): remove the legacy credits engine (FE + Java) (#6687)
Complete legacy-credits teardown ("Group 3"). The per-user/per-team
credit model is fully superseded by PAYG (`wallet_ledger`) — confirmed
no PAYG code references it. Authorized to also remove the `TeamCredit`
pool + its monthly reset.

## Frontend (saas)
- Deleted `saas/hooks/useCredits.ts`, `apiKeys/hooks/useCredits.ts`,
`types/credits.ts`, `apiKeys/UsageSection.tsx`.
- `UseSession.tsx`: removed credit members (`creditBalance`,
`creditSummary`, `hasSufficientCredits`, `updateCredits`,
`refreshCredits`, `fetchCredits`) + the credit types + global
credit-update callback. **Kept** `isPro`/`refreshProStatus` and the
Supabase auth subscription listener.
- `services/apiClient.ts`: removed the dead `x-credits-remaining`
handler + low-credit plumbing (token-refresh / PAYG / 401 logic
untouched).
- Credit refs removed from `ApiKeys.tsx`, `AppConfigModal.tsx`,
`auth/teamSession.ts`.

## Java (:saas)
**Deleted (15):** `UserCredit`(+repo),
`TeamCredit`(+repo)+`TeamCreditService`, `CreditService`,
`CreditHeaderUtils`, `CreditResetScheduler`, `CreditController`,
`CreditInterceptorConfig`, `UnifiedCreditInterceptor`,
`CreditSuccessAdvice`, `CreditErrorAdvice`, `CreditConsumptionResult` (+
the CreditController test).

**Edited — stripped legacy credit side-effects, preserved
auth/role/AI/PAYG logic:**
- `AiCreate`/`AiProxyController`: dropped the
`X-Credits-Remaining`/`X-Credit-Source` response header (its only
consumer, the desktop credit system, was already removed).
- `SaasTeamService`: dropped UserCredit/TeamCredit init on team-create +
seat-update.
- `SupabaseAuthenticationFilter` / `SupabaseSecurityConfig`: dropped
`getOrCreateUserCredits` on signup + the credit field/CORS header.
- `UserRoleService`: dropped `resetCycleAllocationForRoleChange`;
`ROLE_PRO_USER` grant/revoke preserved.
- proprietary `UserRepository`: dropped
`findUsersWithApiKeyButNoCredits()`.
- Tests updated to drop credit mocks/refs.

## Kept / scope
- `isPro` / `is_pro` RPC / `ROLE_PRO_USER` (that's the separate Group-4
/ EE effort) and **all PAYG** are untouched.
- **No DB tables dropped.** `user_credits`/`team_credits` stay until a
later **gated** migration — which this PR unblocks (the JPA entities
that pinned them are gone).

## Verify
`:saas:compileJava` + `:saas:compileTestJava` pass; FE `tsc --noEmit`
(saas) + eslint clean; 0 stray artifacts; no residual source refs to the
deleted classes.

## Follow-up (not in this PR)
`ErrorTrackingService` (+
`UserErrorTracker`/`ProcessingErrorType`/`CreditsProperties`) is now a
dead island — its only callers were the deleted interceptors. Safe to
delete, but it cascades beyond the credit scope, so it's a separate
tidy-up.

Targets `feat/desktop-cloud-saas-reuse`.
2026-06-17 14:11:06 +00:00
ConnorYoh 4f26fdeb5c feat(desktop): show the AI assistant in SaaS mode via the cloud kill switch (#6666)
## What & why

Chained on top of #6649 (the `cloud/` refactor). The AI assistant was
effectively dead on desktop:

1. **Hidden** — `ChatFAB` gates on `aiEngineEnabled`, which desktop
reads from the **local** bundled backend's `/api/v1/config/app-config`.
The local backend has no AI engine, so the flag is always `false` and
the FAB never renders.
2. **Mis-routed** — even if shown, AI calls used `getApiBaseUrl()`,
which is empty/local on desktop, so the orchestrate stream and AI
result-file download missed the engine (which only runs in the cloud).

This PR wires AI properly **without hardcoding it on**, so the cloud
keeps the kill switch: flip `aiEngineEnabled` server-side and the
desktop FAB disappears on the next load — no desktop release required.
(Deliberately *not* assume-on, so a future "turn AI off" doesn't strand
shipped versions.)

## Changes

**General SaaS app-config service** (reusable for any cloud flag, not
just AI):
- `desktop/services/saasAppConfigService.ts` — SaaS-mode-only fetch +
5-min cache of the **public** `/api/v1/config/app-config` from the
**SaaS** backend over native HTTP (`@tauri-apps/plugin-http`, no CORS).
Returns `null` outside SaaS mode.
- `desktop/hooks/useSaasAppConfig.ts` — hook over it; reloads on
connection-mode change.

**AI gating + routing seams:**
- `useAiEngineEnabled()` — core reads `useAppConfig()` (web), desktop
reads `useSaasAppConfig()`. `ChatFAB` consumes it.
- `getAiBaseUrl()` — core uses the normal API base (web), desktop points
AI calls at the SaaS backend. `ChatContext` uses it for the orchestrate
stream + result-file download.
- `operationRouter` — route `/api/v1/ai/*` to the SaaS backend
(cloud-only prefix).

**Docs:** AGENTS.md gains a short "cloud feature flags on desktop" note
so the pattern is maintained.

## Verification
- `tsc --noEmit` green for saas / desktop / cloud flavors
- `eslint --max-warnings=0` clean (cloud-layer guardrail respected — the
platform-coupled bits live in `desktop/`)
- New `saasAppConfigService.test.ts` (3 tests) + existing
`operationRouter` / `tauriHttpClient` / `httpErrorHandler` suites green
- 0 stray compiled artifacts

## Not headlessly verifiable — needs a live Tauri smoke
The orchestrate **SSE stream** uses the webview's global `fetch` (native
HTTP can't stream the body the same way), so it's subject to browser
CORS to the SaaS backend. The `SupabaseSecurityConfig` tauri-origin
allowance (from #6649) covers it, but please confirm on a real build:
open the FAB in SaaS mode, run an agent task, watch the stream + a
result-file download succeed.
2026-06-17 14:10:35 +00:00
Anthony Stirling df9dbc5179 MCP token rejection reason and stop logging the raw tokens (#6700)
- Surface the real reason an MCP token is rejected: the 401's
WWW-Authenticate header now includes error_description
(audience/issuer/expiry), and a present-but-rejected token logs the
concrete OAuth2 reason. Tokenless 401s (the normal discovery handshake)
stay at debug.
- Add McpConfigValidator that sanity-checks MCP config at startup and
logs actionable warnings (missing issuer-uri/resource-id, unrecognized
auth mode, sub + require-existing-account, open access, scopes,
allow/block overlap) so misconfig shows up in the logs before a client
ever connects.
- Align the audience-rejection message to mention both resource-id and
accepted-audiences.
- Harden audit writes: hash JWT-shaped or over-long principals
(token:<sha256-prefix>) so the insert fits the column and never stores a
raw bearer token, and stop logging the raw principal on persist failure.
---

## 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.
2026-06-17 11:06:05 +00:00
Anthony Stirling de9242c4f7 Add JUnit tests for saas module coverage (#6699)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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.
2026-06-17 11:04:53 +00:00
Anthony Stirling 460c037bbb Prefer JBoss mirror over shibboleth repo for opensaml (#6701)
# Description of Changes

Jboss not shibboleth first

---

## 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.
2026-06-17 10:55:59 +00:00
ConnorYohandJames Brunton cd7264a76a refactor(fe): share the SaaS PAYG experience with desktop via a cloud/ layer (#6649)
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-17 11:12:05 +01:00
James Brunton ef0deef4f2 Skip flaky Playwright test (#6698)
# Description of Changes
One of the Playwright tests is flaky, despite several attempts to fix it
before it made it into main. This disables the test for now so a
followup PR can try to fix it again.
2026-06-17 09:12:09 +00:00
65fcc036fe Fix inverted link toolbar in rotated PDFs (#6518) (#6684)
Closes #6518 

# Cause of the bug 
This is a fix to the #6518 issue. The bug happened because the link
toolbar was rendered inside the PDF page layer. That layer can be
affected by the viewer/page rotation transform, so the toolbar was laid
out using local page coordinates and then visually transformed together
with the page.

As a result, the placement logic could calculate a position that was
correct in the page’s local coordinate space, such as above or below the
link, but the parent transform would rotate or shift that result after
layout. On rotated pages, this could make the toolbar appear on the
wrong side, inverted, or misaligned relative to the link.

More specifically, in the PDF that exposed the bug, the page content
appears to have been authored upside down and then corrected with a
180-degree page/viewer rotation so it looks normal to the user.

Because the toolbar was rendered inside the same transformed page layer,
it inherited that 180-degree rotation as well. The PDF content looked
upright because the rotation was part of how the page was displayed, but
the toolbar is viewer UI and should not be rotated with the page. As a
result, the tooltip appeared upside down even though the PDF itself
looked correct.


# Description of Changes

Fixes the inverted link tooltip/toolbar positioning in rotated PDF
viewer pages.

The link toolbar is now rendered through a body portal and positioned
from the link element’s real viewport bounds, so page rotation
transforms no longer flip or misalign it.

The update also keeps the toolbar within the viewport during scroll,
resize, zoom, and rotation changes, preserves the hover delay between
the link and toolbar, centralizes the z-index in a shared constant, and
improves label sizing to avoid clipped text.

Note: The link hover styling was also changed from an underline to a
subtle rectangular highlight based on the PDF link annotation bounds.

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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)

UI behaviour before the changes :

<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-12-10"
src="https://github.com/user-attachments/assets/321edbb3-42a2-4bc3-96ad-3ccc70a355b8"
/>

<img width="762" height="496" alt="Captura de tela de 2026-06-16
00-11-46"
src="https://github.com/user-attachments/assets/be4c1af4-5488-4a54-9b6f-675e3bea73b8"
/>

<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-12-57"
src="https://github.com/user-attachments/assets/60f44cd5-c772-44a8-97c8-bde135764e53"
/>


UI behaviour after the changes :
 
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-22-02"
src="https://github.com/user-attachments/assets/dda77bda-0780-4807-a70d-3bbc60683e5a"
/>

<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-23-07"
src="https://github.com/user-attachments/assets/5745c37e-438a-4bbe-ba1e-c6f2098421de"
/>

<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-23-24"
src="https://github.com/user-attachments/assets/85932541-4a6f-48e4-879f-41f34a6d79e6"
/>


### 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.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-17 08:15:31 +00:00
Reece Browne f127d4f575 fix(policies): poll runs to completion with progress, soft-retry when queue is full (#6690)
## What & why

Production reports of policy enforcement "hanging" traced to
large/many-page documents: the watermark step's flatten-to-image
(`convertPDFToImage`) on a 500+ page PDF takes minutes, exceeding both
the client poll cap and the backend per-step timeout. This makes the
slow case graceful instead of looking broken, and makes load-shedding
non-fatal.

### Poll runs to completion (no false "hang")
The client poll loop used a flat ~150s cap that was **shorter than the
backend's 300s per-step timeout**, so it abandoned long-but-healthy runs
mid-flight. The budget is now sized to the backend's real worst case —
`stepCount × per-step timeout + grace`, learned from the first status
report — so the client always polls long enough to surface the run's
**actual** terminal state (success or the backend's real error) rather
than a misleading client-side timeout.

### Per-step progress
The activity feed now shows `Enforcing… · step n/m` (from
`currentStep`/`stepCount`), so a slow step shows movement instead of a
dead spinner.

### Soft-retry on queue rejection
Under load the shared `JobQueue` rejects runs ("queue full"), which
previously surfaced as a hard failure needing a manual Retry. The
backend now tags that rejection with a stable `POLICY_QUEUE_FULL`
errorCode; the client treats it as transient backpressure and
**auto-retries the file in place** with exponential backoff (≈4s→64s, ~2
min), shown as a soft "Busy — retrying…" row, falling back to the manual
Retry only once the retry budget is spent.

## Testing
- **Frontend unit tests** (30 pass across the policies suite), including
a new `usePolicyAutoRun.retry.test.tsx` that drives the real controller
orchestration (poll → `POLICY_QUEUE_FULL` → relabel → backoff → in-place
re-dispatch), plus poll-budget, step-progress, and activity-feed relabel
cases.
- **Backend** `PolicyEngineTest` case asserting a queue-rejected run
carries the `POLICY_QUEUE_FULL` code.
- Typecheck clean on all three flavors (proprietary/saas/core); prettier
+ spotless clean.
- Poll-budget + progress + real-error surfacing were also verified live
end-to-end against a 599-page run (survived past the old cap, showed
step progress, reported the backend's real 300s-timeout failure,
recovered after a simulated network drop).

## Not included (follow-ups)
- The underlying flatten-to-image cost itself (bounded-memory/streaming
flatten, revisiting `convertPDFToImage` default and the 300s timeout) —
the real perf fix, deliberately out of scope here.
2026-06-16 17:32:12 +00:00
Anthony Stirling f33f4f8f75 Add JUnit tests for common and core module coverage (#6675)
# Description of Changes

JUNITS!
They JUnits were 100% AI generated however no code was touched

---

## 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.
2026-06-16 16:23:34 +00:00
7e67bfc459 Fix SaaS issues (#6694)
# Description of Changes

Fixes several SaaS issues,  was integration branch for saas release

---

## 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.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Reece <reece@stirlingpdf.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-06-16 17:16:07 +01:00
Anthony Stirling 686fb1fb50 Revert "SaaS fixes" (#6693)
Reverts Stirling-Tools/Stirling-PDF#6578 due to mistaken squash merge
not normal merge
2026-06-16 17:13:10 +01:00
Anthony Stirling 5389e39cfc Revert "SaaS fixes (#6578)"
This reverts commit ddf78d11ae.
2026-06-16 16:48:30 +01:00
ddf78d11ae SaaS fixes (#6578)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Reece <reece@stirlingpdf.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-06-16 16:41:25 +01:00
James Brunton 10b4551449 Fix failing Playwright test in SaaS (#6688)
# Description of Changes
Fix Playwright failing test in SaaS (I think this is my third attempt
now so who knows if this will actually fix it for real this time, but
hopefully it does)
2026-06-16 15:56:30 +01:00
Reece Browne 96accea984 Portal: full mock-driven surfaces, demonolithed components, backend-ready mocks (#6686) 2026-06-16 12:20:35 +01:00
James Brunton 9a883be697 Cleanup of SaaS code (#6669)
# Description of Changes
De-AI comments and fix ridiculously indented code
2026-06-16 11:49:13 +01:00
dependabot[bot]andAnthony Stirling 6716398ccb build(deps): bump go-task/setup-task from 2.0.0 to 2.1.0 (#6429)
Bumps [go-task/setup-task](https://github.com/go-task/setup-task) from
2.0.0 to 2.1.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/go-task/setup-task/releases">go-task/setup-task's
releases</a>.</em></p>
<blockquote>
<h2>v2.1.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Replaced <code>typed-rest-client</code> with
<code>@actions/http-client</code> for GitHub API calls
to eliminate the Node 24 <code>DEP0169</code> deprecation warning about
<code>url.parse()</code> (<a
href="https://redirect.github.com/go-task/setup-task/issues/5">#5</a> by
<a href="https://github.com/vmaerten"><code>@​vmaerten</code></a>).</li>
<li>Modernized the TypeScript tooling stack (vitest, oxlint,
<code>@actions/core@2</code>,
<code>@actions/io@2</code>, updated <code>@types/node</code>,
<code>@vercel/ncc</code>, <code>prettier</code>, etc.) (<a
href="https://redirect.github.com/go-task/setup-task/issues/5">#5</a> by
<a href="https://github.com/vmaerten"><code>@​vmaerten</code></a>).</li>
<li>Migrated the project to ESM (sources + bundle). Aligns with the new
<code>@actions/*</code> ESM-only majors and produces a ~47% smaller
<code>dist/index.js</code> (<a
href="https://redirect.github.com/go-task/setup-task/issues/5">#5</a> by
<a href="https://github.com/vmaerten"><code>@​vmaerten</code></a>).</li>
<li>Upgraded <code>@actions/core</code> 2 → 3,
<code>@actions/http-client</code> 2 → 4,
<code>@actions/io</code> 2 → 3, <code>@actions/tool-cache</code> 2 → 4,
<code>typescript</code> 5 → 6, and
<code>markdownlint-cli</code> 0.47 → 0.48 (<a
href="https://redirect.github.com/go-task/setup-task/issues/5">#5</a> by
<a href="https://github.com/vmaerten"><code>@​vmaerten</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/go-task/setup-task/blob/main/CHANGELOG.md">go-task/setup-task's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2>Unreleased</h2>
<h2>v2.1.0 - 2026-05-17</h2>
<ul>
<li>Replaced <code>typed-rest-client</code> with
<code>@actions/http-client</code> for GitHub API calls
to eliminate the Node 24 <code>DEP0169</code> deprecation warning about
<code>url.parse()</code>.</li>
<li>Modernized the TypeScript tooling stack (vitest, oxlint,
<code>@actions/core@2</code>,
<code>@actions/io@2</code>, updated <code>@types/node</code>,
<code>@vercel/ncc</code>, <code>prettier</code>, etc.).</li>
<li>Migrated the project to ESM (sources + bundle). Aligns with the new
<code>@actions/*</code> ESM-only majors and produces a ~47% smaller
<code>dist/index.js</code>.</li>
<li>Upgraded <code>@actions/core</code> 2 → 3,
<code>@actions/http-client</code> 2 → 4,
<code>@actions/io</code> 2 → 3, <code>@actions/tool-cache</code> 2 → 4,
<code>typescript</code> 5 → 6, and
<code>markdownlint-cli</code> 0.47 → 0.48.</li>
</ul>
<h2>v2.0.0 - 2026-03-18</h2>
<ul>
<li><strong>BREAKING</strong>: Upgraded to Node 24. Requires a GitHub
Actions runner with
Node.js 24 support
(<a
href="https://redirect.github.com/go-task/setup-task/pull/10">#10</a> by
<a href="https://github.com/vmaerten"><code>@​vmaerten</code></a>).</li>
</ul>
<h2>v1.1.0 - 2026-03-17</h2>
<ul>
<li>Added configurable HTTP retry for API requests
(<a href="https://redirect.github.com/go-task/setup-task/pull/7">#7</a>
by <a
href="https://github.com/vmaerten"><code>@​vmaerten</code></a>).</li>
</ul>
<h2>v1.0.0 - 2025-09-12</h2>
<ul>
<li>Forked <a
href="https://github.com/arduino/setup-task">arduino/setup-task</a> (by
<a href="https://github.com/pd93"><code>@​pd93</code></a>).</li>
<li>Default <code>repo-token</code> to <code>{{github.token}}</code>
(<a
href="https://redirect.github.com/arduino/setup-task/pull/642">arduino/setup-task#642</a>
by
<a href="https://github.com/shrink"><code>@​shrink</code></a>).</li>
<li>Fixed a bug where the action would fail is Task pushed a tag without
a release
(<a
href="https://redirect.github.com/arduino/setup-task/pull/490">arduino/setup-task#490</a>,
<a
href="https://redirect.github.com/arduino/setup-task/pull/1193">arduino/setup-task#1193</a>
by
<a href="https://github.com/trim21"><code>@​trim21</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/go-task/setup-task/commit/01a4adf9db2d14c1de7a560f09170b6e0df736aa"><code>01a4adf</code></a>
chore: release v2.1.0</li>
<li><a
href="https://github.com/go-task/setup-task/commit/56fc0886350e15a75ed1e6f4b7a83d3b831b3054"><code>56fc088</code></a>
fix(taskfile): make mktemp utilities portable across BSD and GNU</li>
<li><a
href="https://github.com/go-task/setup-task/commit/4de50203767624993e228e2135c1d13cc156b095"><code>4de5020</code></a>
chore(release): add release task automation (<a
href="https://redirect.github.com/go-task/setup-task/issues/14">#14</a>)</li>
<li><a
href="https://github.com/go-task/setup-task/commit/f95f6c5aebc71143d70361ab79d87c447fd4c48f"><code>f95f6c5</code></a>
chore(deps): update all dependencies (<a
href="https://redirect.github.com/go-task/setup-task/issues/12">#12</a>)</li>
<li><a
href="https://github.com/go-task/setup-task/commit/dc4f00abd355059e622d428a1a905dfcd1169477"><code>dc4f00a</code></a>
chore(deps): update all dependencies (<a
href="https://redirect.github.com/go-task/setup-task/issues/2">#2</a>)</li>
<li><a
href="https://github.com/go-task/setup-task/commit/035a5f11fa6bbb298cc436d4173402c6ebfbc411"><code>035a5f1</code></a>
chore: modernize stack (<a
href="https://redirect.github.com/go-task/setup-task/issues/5">#5</a>)</li>
<li><a
href="https://github.com/go-task/setup-task/commit/099972a06751959896ae32ae844ed17001f59da5"><code>099972a</code></a>
docs: mark v2.0.0 release in changelog</li>
<li>See full diff in <a
href="https://github.com/go-task/setup-task/compare/3be4020d41929789a01026e0e427a4321ce0ad44...01a4adf9db2d14c1de7a560f09170b6e0df736aa">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-15 22:04:16 +00:00
dependabot[bot]andAnthony Stirling 5b20257dea build(deps): bump actions/stale from 10.2.0 to 10.3.0 (#6487)
Bumps [actions/stale](https://github.com/actions/stale) from 10.2.0 to
10.3.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/stale/releases">actions/stale's
releases</a>.</em></p>
<blockquote>
<h2>v10.3.0</h2>
<h2>What's Changed</h2>
<h3>Bug Fix</h3>
<ul>
<li>Enhancement: ignore stale labeling events by <a
href="https://github.com/shamoon"><code>@​shamoon</code></a> in <a
href="https://redirect.github.com/actions/stale/pull/1311">actions/stale#1311</a></li>
</ul>
<h3>Dependency Updates</h3>
<ul>
<li>Upgrade dependencies (<code>@​actions/core</code>,
<code>@​octokit/plugin-retry</code>, <a
href="https://github.com/typescript-eslint"><code>@​typescript-eslint</code></a>)
by <a href="https://github.com/Copilot"><code>@​Copilot</code></a> in <a
href="https://redirect.github.com/actions/stale/pull/1335">actions/stale#1335</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/shamoon"><code>@​shamoon</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/stale/pull/1311">actions/stale#1311</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/stale/compare/v10...v10.3.0">https://github.com/actions/stale/compare/v10...v10.3.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/stale/commit/eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899"><code>eb5cf3a</code></a>
chore: upgrade dependencies and bump version to 10.3.0 (<a
href="https://redirect.github.com/actions/stale/issues/1335">#1335</a>)</li>
<li><a
href="https://github.com/actions/stale/commit/db5d06a4c82d5e94513c09c406638111df61f63e"><code>db5d06a</code></a>
Enhancement: ignore stale labeling events (<a
href="https://redirect.github.com/actions/stale/issues/1311">#1311</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/stale/compare/b5d41d4e1d5dceea10e7104786b73624c18a190f...eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/stale&package-manager=github_actions&previous-version=10.2.0&new-version=10.3.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)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

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 <dependency name> 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)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-15 22:04:09 +00:00
dependabot[bot]andAnthony Stirling 2f5fc7be4e build(deps): bump depot/build-push-action from 1.17.0 to 1.18.0 (#6488)
Bumps
[depot/build-push-action](https://github.com/depot/build-push-action)
from 1.17.0 to 1.18.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/depot/build-push-action/releases">depot/build-push-action's
releases</a>.</em></p>
<blockquote>
<h2>v1.18.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Upgrade action runtime to Node 24 (<a
href="https://redirect.github.com/depot/build-push-action/issues/48">#48</a>)
<a href="https://github.com/Akatama"><code>@​Akatama</code></a></li>
<li>Add Depot Registry save example (<a
href="https://redirect.github.com/depot/build-push-action/issues/47">#47</a>)
<a href="https://github.com/maschwenk"><code>@​maschwenk</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/depot/build-push-action/commit/98e78adca7817480b8185f474a400b451d74e287"><code>98e78ad</code></a>
Merge pull request <a
href="https://redirect.github.com/depot/build-push-action/issues/48">#48</a>
from depot/upgrade-node-24-runtime</li>
<li><a
href="https://github.com/depot/build-push-action/commit/e97ebff18729ac91be067461138674a006ab9bff"><code>e97ebff</code></a>
Remove Node 24 compatibility docs</li>
<li><a
href="https://github.com/depot/build-push-action/commit/2db929fa768ebb3ad332ae8fc530412bf0964782"><code>2db929f</code></a>
Upgrade action runtime to Node 24</li>
<li><a
href="https://github.com/depot/build-push-action/commit/f78af826a1c272c4b60c485e934974b515094928"><code>f78af82</code></a>
Merge pull request <a
href="https://redirect.github.com/depot/build-push-action/issues/47">#47</a>
from maschwenk/maschwenk/add-depot-registry-example</li>
<li><a
href="https://github.com/depot/build-push-action/commit/6855818d5954fa4361879bb0b0e5c32856fc6703"><code>6855818</code></a>
Update action.yml</li>
<li><a
href="https://github.com/depot/build-push-action/commit/b984f6a1944d5420eefb2b012d6eb856249bd225"><code>b984f6a</code></a>
Clarify save/save-tag/save-tags input descriptions</li>
<li><a
href="https://github.com/depot/build-push-action/commit/1a34abd3707433f4f7b6d594e49e56b4b9f4d6d0"><code>1a34abd</code></a>
Add Depot Registry save example</li>
<li>See full diff in <a
href="https://github.com/depot/build-push-action/compare/5f3b3c2e5a00f0093de47f657aeaefcedff27d18...98e78adca7817480b8185f474a400b451d74e287">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=depot/build-push-action&package-manager=github_actions&previous-version=1.17.0&new-version=1.18.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)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

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 <dependency name> 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)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-15 22:04:03 +00:00
Anthony Stirling d18caf6116 Fix PDF text selection locked out on touch devices (#6656) 2026-06-15 23:04:43 +01:00
dependabot[bot]andAnthony Stirling 3bafbb1919 build(deps): bump docker/metadata-action from 6.0.0 to 6.1.0 (#6490)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 23:03:55 +01:00
James Brunton 42c1cce56d Fix Java formatting 2026-06-15 15:05:26 +01:00
fb6a118be9 Update SaaS to latest main (#6667)
# Description of Changes
> [!warning]
> **Do not** squash this on merge. It should be merged via a merge
commit

Fixes conflicts in `pgvector_store.py`. 

Also since codespell is failing, add comments to ignore the errors in
`sync_en_us_spelling.py`

---------

Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-15 14:53:22 +01:00
James Brunton c55beacead Shut codespell up 2026-06-15 14:20:25 +01:00
James Brunton 04d68c650a Merge remote-tracking branch 'origin/main' into saas-update
# Conflicts:
#	engine/src/stirling/documents/pgvector_store.py
2026-06-15 14:10:21 +01:00
Anthony Stirling 9d7467cf90 Scope signing user picker to team for multi-tenant SaaS (#6583) 2026-06-15 13:44:34 +01:00
James Brunton 2a905c01c3 SaaS tidying (#6665)
# Description of Changes
* Remove complex port selection logic from `engine.yml`. It's
inconsistent with the frontend & backend task files, and caused issues
with Docker, which have been worked around but would be simpler to just
get rid of the problem altogether
* Fix Ruff formatting of Python script
* Remove payg tests which are failing and have drifted too far from the
implementation to save directly
2026-06-15 13:21:33 +01:00
Anthony Stirling d6a5777c69 Fix pgvector (#6591) 2026-06-15 13:09:40 +01:00
James Brunton c1a637d764 Fix CI errors in SaaS (#6662)
# Description of Changes
Fix CI errors in #6578 to make SaaS branch ready for merge into main
2026-06-15 11:26:29 +01:00
Anthony Stirling eefa8eff61 Route mobile scanner API and vendor loads through the app base path (#6648) 2026-06-12 16:00:11 +01:00
Reece Browne 63ecbe3b6d Policies: centre the collapsed-rail policy button between its dividers (#6646) 2026-06-12 13:39:03 +01:00
Anthony Stirling f1ed850a73 Fix SaaS mobile scanner being auth-gated under /app base path (#6642) 2026-06-12 13:13:49 +01:00
Anthony Stirling b11c272e87 Feature/v2/guest action gating (#6643) 2026-06-12 13:13:38 +01:00
Reece Browne f5e697347b Policies: drop the Pro-license gate from the policy API (#6645)
`PolicyController` was annotated `@PremiumEndpoint` (requires a
Pro-or-higher server license). Policies don't need a server-license
gate:

- On SaaS the server runs in Pro mode, so the check is always satisfied
anyway — it gates nothing in practice.
- Access is already governed by team scoping (#6632) plus the per-user /
guest gates.

So the annotation is dead weight and misleading. This removes it (and
its import) — a 2-line change.

## Verification
- `:proprietary:compileJava` succeeds; spotless clean. No other premium
gate on policy classes.
2026-06-12 13:13:01 +01:00
Reece Browne 4e880c7510 Policies: summon the guest sign-up banner when a guest clicks a policy (#6644)
Guests (anonymous users on a login-enabled deployment) could open a
policy's setup/detail. Policies are an account feature, so a guest
clicking a policy should be nudged to sign up rather than opening it.

## Behaviour
A guest clicking a policy row — or a collapsed-rail icon — now
**re-summons the existing guest sign-up banner** ("You're using Stirling
PDF as a guest!…") and does **not** open the policy.

- `GuestUserBanner` listens for a `stirling:show-guest-banner` window
event and re-shows (even if previously dismissed; the render guard still
hides it for non-anonymous users).
- The policy sidebar dispatches that event on a guest click (cross-layer
via `CustomEvent`, same pattern as `payg:signupRequired`; a no-op on
builds without the banner).
- `usePolicyGuestBlocked()` gates it: `config.enableLogin === true &&
user.is_anonymous === true`.
- **Login-disabled single-user** deployments have an anonymous local
operator with full access → not gated.

## Verification
- Typecheck clean (proprietary + saas); eslint clean; sidebar tests
pass.

## Note
No dedicated guest unit test — the suite mocks `useAppConfig` at module
scope, and making it per-test controllable needs `vi.hoisted` plumbing
that risked the existing tests. Easy follow-up.
2026-06-12 13:10:37 +01:00
James Brunton 511b92b321 De-AI the onboarding prose (#6641)
# Description of Changes
De-AI the onboarding prose.
2026-06-12 11:39:19 +01:00
ConnorYoh 87723d3ce2 fix(payg): fire the usage-limit modal when an AI agent run hits the limit (#6638)
## Problem

We're getting 402s when an AI **agent** (chat) run hits the free
allowance / spending cap, but the frontend handles them poorly and never
pops the usage-limit modal.

The agent runs its tool calls **server-side** (loopback HTTP via
`PolicyExecutor`), so the 402 never reaches the `apiClient` interceptor
that pops the modal for direct calls. It was caught by the generic
tool-failure handler and flattened into a `CANNOT_CONTINUE` reason
string (`"The /api/v1/… tool failed: 402…"`), streamed as a `result`
event, and rendered as a scary chat bubble. This is the same gap the
policy auto-run path bridges (#6626) — one layer up.

## Fix

**Backend** (`proprietary`)
- `AiWorkflowResponse` gains `errorCode` + `errorSubscribed`.
- `AiWorkflowService` detects a downstream 401/402 entitlement sentinel
in its three tool-exec catch sites (`onToolCall`, `runPlan`,
`onConvertMarkdown`) and surfaces the structured code (+ `subscribed`)
on the terminal response instead of the raw failure text.
- Factored the 401/402 body extraction `PolicyEngine` already had into a
shared `DownstreamEntitlementError` util so the two server-side paths
can't drift.

**Frontend**
- New `usageLimitBridge` (`PAYG_LIMIT_REACHED_EVENT` +
`dispatchPaygLimitReached`) generalises the previously policy-only
bridge. Proprietary can't import the saas modal API (layering), so
server-side limit hits broadcast a window event the saas
`UsageLimitModalHost` opens the modal from. Migrated the policy path
onto it.
- `ChatContext` fires the matching modal (free → subscribe, subscribed →
raise cap) on the limit result **and** on a direct 402, replacing the
raw reason with a brief friendly line
(`chat.responses.usage_limit_reached`).

No Python engine changes — the charge/402 happens on the Java tool
endpoint that Java itself calls.

## Test plan

- [x] `:proprietary:compileJava` + `spotlessCheck` clean
- [x] `AiWorkflowServiceTest` + `PolicyEngineTest` green
- [x] eslint, proprietary + saas typechecks clean
- [ ] Manual: drive an agent run over the limit → brief line in chat +
the right modal (free vs cap)

> Note: proprietary test compilation is currently blocked on the
pre-existing `InitialSecuritySetupTest` 6-arg ctor break (unrelated,
tracked separately); verified locally by temporarily patching it.
2026-06-12 11:38:07 +01:00
EthanHealy01 eb2527fc7f Properly sync US and GB translation files (#6635)
add en-US changes to SaaS, previously merged into main. So this is
effectively a main -> SaaS PR also. It seems to be all additive.

Also take the 230 ish missing translations from en-GB over to en-US
using a script, and also make and english spellings American when adding
them to the en-US file, and fix any existing American spellings in the
en-GB file.
2026-06-12 11:18:25 +01:00
James Brunton d363a1e957 Improve search logic (#6637)
# Description of Changes
Search has got significantly worse since #6581, where I added all the
missing tags for tools that should have been there for months. Turns out
that the fuzzy matching search logic has always been way too permissive
to match words with Levenshtein distances way too far away from the
target word, so long searches include way too much stuff. The new tags
just exposed that underlying logic issue. This PR makes the Levenshtein
logic much stricter, so it is still tolerant to minor typos in tool
names, but doesn't match completely inappropriate strings.
2026-06-12 10:34:11 +01:00
James Brunton ea102cdb93 Merge pull request #6636 from Stirling-Tools/SaaS-update
Update SaaS to latest main
2026-06-12 10:19:35 +01:00
James Brunton d995471a55 Merge remote-tracking branch 'origin/main' into SaaS-update
# Conflicts:
#	frontend/editor/src/proprietary/components/chat/ChatContext.tsx
#	frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx
2026-06-12 09:58:40 +01:00
Reece Browne e3e49c07ae Policies: let team leaders configure policies in the UI (#6634)
Frontend follow-up to #6632 (team-scoped policies, editing gated to team
leaders on the backend). Brings the UI's edit gate in line.

## Problem
The policy config UI gated editing to `config.isAdmin`. On SaaS, org
users are never the single global admin, so **no one could open the
policy editor** — the same lockout #6632 fixed on the backend.

## Fix
`usePolicies` now allows a **team leader** to configure, falling back to
a global admin self-hosted:

```ts
canConfigure =
  config != null && (!config.enableLogin || isTeamLeader || config.isAdmin === true);
```

- SaaS → `isTeamLeader` (from `useSaaSTeam()`) — team leaders can
configure; members get the read-only surface.
- Self-hosted → `config.isAdmin` (the core `useSaaSTeam` stub returns
`false`, so admins aren't locked out).
- Login disabled (single-user) → always allowed.
- The `config != null` guard keeps the gate closed until app-config
resolves, so edit controls never flash for users who can't use them.

The two locked-policy banners now read "Contact a team leader to change
this policy" (updated in the `t()` defaults and the `en-GB`
translations).

## Verification
- Typecheck clean (proprietary + saas); eslint clean.
- Tests pass: `usePolicies`, `PoliciesSidebar`.
2026-06-11 23:51:26 +01:00
EthanHealy01 962119e14f UI ux/add ai warning and change style (#6633)
<img width="394" height="426" alt="Screenshot 2026-06-11 at 11 39 50 PM"
src="https://github.com/user-attachments/assets/15805931-73fd-416b-841b-99a556468433"
/>
bottom text input is sticky even when shrunk down
2026-06-11 23:49:52 +01:00
Reece BrowneandClaude Opus 4.8 cc1235bbf2 i18n(policies): route policy UI strings through i18n (English only) (#6628)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:37:43 +01:00
Reece Browne e88d22d2fc Policies: scope to the owning team; editing restricted to team leaders (#6632) 2026-06-11 23:21:48 +01:00
Anthony Stirling ddf10f0aaf Stop advertising mcp.tools scopes in OAuth metadata when scope enforcement is disabled 2026-06-11 23:03:43 +01:00
EthanHealy01 b756b5befb add agent warning and update style (#6629) 2026-06-11 21:55:51 +01:00
ConnorYoh eddc54c6c0 fix(payg): land usage-limit modal CTAs on the Plan section (#6630) 2026-06-11 21:42:59 +01:00
ConnorYoh 22379fd5ab fe(payg): show the usage-limit modal when the limit is hit (direct + policy) (#6626) 2026-06-11 21:28:44 +01:00
Reece Browne 6f1c19c179 Policies: enforce input on uploads only; badge follows edited files (#6627) 2026-06-11 21:27:44 +01:00
Reece BrowneandClaude Opus 4.8 ef65e6b015 feat(policies): org-wide policies with admin-only editing (#6625)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:23:06 +01:00
Anthony Stirling 47e5977a31 Drop startup credit-reset catch-up (bulk per-user loop; lazy reset covers it) 2026-06-11 21:21:28 +01:00
Anthony Stirling 3a4b340313 Remove legacy CreditBackfillRunner (PAYG replaces it; rows created lazily) 2026-06-11 21:03:09 +01:00
EthanHealy01 41d2aa8174 UI ux/move footer links to settings (#6606)
<img width="2056" height="1044" alt="Screenshot 2026-06-11 at 2 15
34 PM"
src="https://github.com/user-attachments/assets/e58a9f8f-7172-4f30-ab28-0760b66249c9"
/>
<img width="2056" height="1045" alt="Screenshot 2026-06-11 at 2 15
43 PM"
src="https://github.com/user-attachments/assets/890b7a0b-740f-4c7f-9a48-c9a2c28e8ded"
/>
2026-06-11 20:43:33 +01:00
ConnorYoh ee9fdeed6b fix(payg): run the entitlement guard before the charge interceptor (#6622)
## Problem

When the `EntitlementGuard` refuses a request with **402** (team is over
its free allowance / spending cap, or has no subscription to bill), the
handler never runs — so it must not charge. But it did: the guard (order
**1100**) ran *after* the charge interceptor (**1000**), so
`openProcess` had already written the charge before the 402, and
`afterCompletion` then billed it as "customer paid for the attempt" — a
ledger debit, and a **Stripe meter for a subscribed-over-cap team**.

## Fix

**Run the guard first** (order **900**, before the charge interceptor at
1000). Spring runs interceptors in ascending order on the way in and
**skips a later interceptor's `preHandle` (and `afterCompletion`)
entirely once an earlier one returns `false`** — so a refused request
short-circuits with its 402 *before* the charge interceptor runs at all.
A blocked request never opens a process, materialises inputs, or writes
a charge.

This replaces the earlier attribute-flag + `afterCompletion`-refund
approach with a simpler reorder (per review): the no-charge-on-block
guarantee is now **structural**, and it also avoids the wasted
open-then-refund churn (no temp-file write, no debit/refund pair) for
refused requests.

### Why the reorder is safe
- `EntitlementGuard` reads no `PaygChargeInterceptor` state and has **no
`afterCompletion`** (only `preHandle`), so reverse-order teardown is a
non-issue.
- The legacy `UnifiedCreditInterceptor` (default order **0**, and only
registered under the `legacy-credits` profile) still runs first, so any
legacy rejection wins.
- For *admitted* requests both interceptors still run (guard then
charge) — behaviour is unchanged; only refused requests now
short-circuit before the charge.

## Tests

`PaygWebMvcConfigTest` locks the `ENTITLEMENT_GUARD_ORDER <
INTERCEPTOR_ORDER` invariant (if it's ever reversed, refused requests
would bill again — this fails first). Existing `EntitlementGuardTest`
already proves the guard returns 402 on a degraded/billable request.
`:saas:test` + spotless green.

## Related (separate, in progress)

The **fail-cleanly + fire-the-modal** half (suppress the error toast,
trigger the subscribe/raise-cap modal via the existing
`subscribed`/`category` signal — catching the 402 centrally so direct
API usage is handled, and propagating the entitlement reason through the
async policy-run status) lands **with Ethan's modal** so we don't remove
the toast before there's a popup to replace it.
2026-06-11 20:42:40 +01:00
Anthony Stirling b1a960a240 Skip self-host user-table bootstrap (team backfill, grandfathering) in SaaS 2026-06-11 20:39:48 +01:00
EthanHealy01 7e493226c4 add popups for free limit hit and spend cap hit (#6623) 2026-06-11 20:34:59 +01:00
Anthony Stirling d48017a5b5 Skip engine free-port probe in containers (fixed port) 2026-06-11 20:18:54 +01:00
ConnorYoh 37b4d24a95 fix(payg): gate + charge AI document tools and AI Create sessions (#6617)
## Problem

Two AI surfaces slipped through PAYG unbilled:

1. **AI document tools** — `/api/v1/ai/tools/**`
(`PdfCommentAgentController`, `MathAuditorAgentController`) live in the
**proprietary** module, which can't depend on `saas` and so can't carry
the saas-only `@RequiresFeature`. They also lacked
`@AutoJobPostMapping`, so the charge interceptor's scope gate
short-circuited them **before** category resolution: **not charged, and
not even entitlement-gated** — whether called directly or dispatched by
the orchestrator.
2. **AI Create** — `/api/v1/ai/create` is JSON/session-based with no
file input, so the multipart charge path never fired. The old
per-generation charge ran through the now-dead legacy credit system, so
it currently charges nothing.

## Fix

- **`AiToolRoutes`** (new, saas) — single source of truth for the
`/api/v1/ai/tools/**` prefix. The proprietary controllers stay
untouched; the saas hot-path recognises them by path:
- **`PaygChargeInterceptor`**: brings these routes into scope and bills
them **AI** on a direct call. An orchestrator-dispatched call still
resolves to **AUTOMATION** first (the `X-Stirling-Automation` header is
checked before the path rule), so AI-tool-inside-a-workflow keeps
billing as automation.
  - **`EntitlementGuard`**: gates them on **`AI_SUPPORT`**.
- This keeps the `proprietary → saas` layering intact (no backwards
dependency).
- **`JobChargeService.chargeStandalone(ctx, units)`** — charges a fixed
unit count for a non-file billable action, reusing the existing
free-grant split + shadow row + ledger debit + `close()`→meter path on a
standalone bookkeeping job (no lineage inputs, so nothing lineage-joins
it). **`JobService.open(ctx, docUnits)`** opens that bare job.
- **`AiCreateController.createSession`** — charges **one document per
session** at creation (best-effort; entitlement is already enforced
upstream by the class-level `@RequiresFeature(AI_SUPPORT)`). Follow-up
edits (`outline` / `reprompt` / `draft` / `template` / `stream`) carry
**no** charge — they have no charge hook, so "charge on create,
follow-ups free" falls out naturally.

Per the agreed scope: charge AI Create on create now; we can optimise
follow-up handling later. **AI workflow categorisation (AUTOMATION vs
AI) intentionally left as-is** (the orchestrator's automation header
dominates by design).

## Tests

- `PaygChargeInterceptorTest`: AI-tool route (no annotations) is in
scope + **AI** category; same route with the automation header →
**AUTOMATION**; a plain non-AI route still short-circuits.
- `EntitlementGuardTest`: AI-tool route is in scope + gated on
**AI_SUPPORT** (degraded team → 402; anonymous → 401 with `category:
AI`).
- `JobChargeServiceTest`: `chargeStandalone` charges + meters the paid
portion for a subscribed team, draws the free grant (no meter) for an
unsubscribed team, and rejects `BYPASSED`.

`:saas:test` + `:saas:spotlessCheck` green; coverage gates met.

## Follow-ups (not in this PR)

- Make AI Create follow-ups explicitly cheaper / chained if we want
(currently free by absence of a hook).
- Decide whether AI-tool-inside-a-workflow should bill as AI rather than
AUTOMATION.
2026-06-11 20:10:44 +01:00
ConnorYoh aaa2599e23 fix(saas): block accepting an invite when it would orphan a paid team (#6616)
## Problem

A **free team can invite a paid team's leader** to join. The leader
accepts, and:
- `acceptInvitation` moves them onto the inviting team and removes their
membership from the old team, but only deletes the old team if it's
**personal**.
- Their paid (non-personal) team is left **memberless but still
subscribed** — an orphaned Stripe subscription billing for a team nobody
is in.

## Root cause

Two gaps in `SaasTeamService.acceptInvitation`:

1. The pre-accept guard checks `hasPaidSubscription(acceptingUser)` →
`existsActivePaidSubscriptionForUser(supabaseId)`, keyed on
**`user_id`**. A team's plan is keyed on **`team_id`**
(`existsActiveSubscriptionForTeam`), so a paid team's *leader* isn't
caught and accepts freely.
2. The 'leave existing teams' loop deletes the membership directly and
only marks **personal** teams for deletion — it bypasses the last-leader
protection `leaveTeam` already enforces (`"Cannot leave as the last team
leader. Transfer leadership first."`), and never cleans up / cancels the
non-personal team.

There is no in-app subscription-cancel path (cancellation is
Stripe-portal/webhook driven), so nothing reconciles the orphan after
the fact — it has to be prevented.

## Fix

Add `assertCanLeaveCurrentTeamsToJoinAnother(user)`, called in
`acceptInvitation` before any membership changes. For each non-personal
team where the user is the **last leader**, the accept is rejected:
- team has an active subscription → *"Cancel the plan or transfer
leadership before joining another team."*
- otherwise → *"Transfer leadership before joining another team."*

This mirrors the protection `leaveTeam` already has and is
team/leadership-aware, closing the `user_id`-vs-`team_id` gap. Regular
members and teams with another leader are unaffected.

## Verification

- `ENABLE_SAAS=true ./gradlew :saas:compileJava` — passes.
- Manual: with a paid team leader, accepting an invite to another team
should now be rejected with the message above; verify a non-leader
member can still accept.

## Note

This prevents *new* orphans. Any teams already orphaned by this bug
(memberless, still subscribed) would need a one-off reconciliation —
happy to follow up with a query/cleanup if useful.
2026-06-11 20:10:21 +01:00
EthanHealy01 33026e1a82 update saas onboarding (#6619)
<img width="1002" height="487" alt="Screenshot 2026-06-11 at 6 20 10 PM"
src="https://github.com/user-attachments/assets/5ee3cfc2-6c4f-4b35-9586-ef45fa216c6a"
/>
2026-06-11 19:39:02 +01:00
Anthony Stirling 1d598d5caa MCP OAuth discovery fix + Supabase consent page (#6608) 2026-06-11 18:30:49 +01:00
ConnorYoh 9e5fe2f4ca fix(payg): attribute policy runs to the owner so usage is charged (#6620)
## Problem

A policy ran over a file but the owner's **free usage was never
consumed**.

A policy run executes on a **background virtual thread**
(`PolicyEngine.submit` → `asyncExecutor`), and Spring's
`SecurityContextHolder` is thread-local — so the worker thread has no
identity. When `PolicyExecutor` → `InternalApiClient.post` resolves the
tool-call API key via `UserService.getCurrentUsername()`, it finds
nothing and falls back to the **`INTERNAL_API_USER`** key. The loopback
tool calls then authenticate as that system account, so
`PaygChargeInterceptor` attributes the charge to *its* team (or none) —
the real owner's free grant is untouched. Folder-watch / scheduled
triggers are even further removed (fired from a background watch loop
with no request context at all).

The charging *mechanism* was fine (AUTOMATION, multipart,
`openProcess`); only the **attribution** was wrong.

## Fix

Propagate the acting identity onto the worker thread using the
**audit-principal MDC key** that `UserService.getCurrentUsername()`
already reads as its documented async fallback (the same mechanism used
for other async jobs). No new plumbing through the executor.

- **`runPolicy`** (stored policies — covers triggers *and* manual
`runWith`) → bill the **policy owner**. `Policy.owner` is the username
stamped at creation, so `getApiKeyForUser(owner)` resolves it.
- **`submit`** (ad-hoc Automate/AI one-offs) → bill the **submitting
user**, captured on the request thread (it doesn't survive the hop to
the worker otherwise).

With the principal set, `InternalApiClient` dispatches each tool call as
that user → the interceptor resolves the right team → free grant draws /
Stripe meters correctly.

## Tests

`PolicyEngineTest`:
- `runPolicyDispatchesToolCallsAsTheOwner` — asserts MDC
`auditPrincipal` == the policy owner at the moment
`InternalApiClient.post` is invoked.
- `adHocRunDispatchesToolCallsAsTheSubmittingUser` — asserts it's the
submitting user for an ad-hoc run.

`:proprietary:test` + `:saas:test` + spotless green; coverage gates met.

## Heads-up (not in this PR)

Once attributed, **automatic folder-watch / scheduled runs consume free
grant (or bill) per file** — set up once, runs forever. That's
automation-is-billable working as intended, but a set-and-forget policy
can drain an allowance fast, so it may warrant a per-policy cap or a
heads-up in the UI. Flagging for a product decision.
2026-06-11 18:28:58 +01:00
Reece Browne 9ee0bc4b32 Policies: enforce on upload or export (#6614)
Follow-up to #6604 (merged). Builds the Security policy out so it
actually enforces, driven from the editor.

## What it does
- **Run on upload or export** — a single choice in the wizard: enforce
when a file is uploaded, or just before it's exported.
- **Output** — enforced result is a **new version** of the file
(default) or a **new file**, with optional filename
prefix/suffix/auto-number ("Output filename" subsection; auto-number
only for new files).
- **Export enforcement** — exporting an export-mode file runs the policy
first and downloads the enforced result; never hard-blocks (on failure
the original downloads). For new-version policies the in-editor file is
versioned too. Covers every export path incl. multi-file ZIP. A toast
(glowing in the policy's accent while it runs) reports progress and
fades after ~10s.
- **Affordances** — a freshly enforced file briefly glows its policy
accent and carries a shield badge.
- **Config tidy-up** — removed the unwired Security setting fields + the
wizard's review step; "Upgrade to enterprise" on locked categories;
category accent in the detail/wizard headers.

## Notes
- Builds on the manual-only (client-driven) policy model from #6587
(`trigger: null`, metadata in `output.options`), adding the `runOn`
field + export-time enforcement.
- The page-editor merge-export (no single source file) enforces +
downloads but doesn't version in place.

## Verification
typecheck (core + proprietary), eslint, prettier; proprietary suite
(105) green; flows checked in-app.
2026-06-11 18:12:01 +01:00
ConnorYoh 5fa5e12c64 fix(saas): show team invitation banner in SaaS web build (#6612)
## Problem

When a user is invited to a team, the SaaS web app shows **no invitation
banner** — even though the pending invite is returned by
`/api/v1/team/invitations/pending` on refresh.

## Root causes

1. **Never rendered in SaaS.** `TeamInvitationBanner` only existed in
`desktop/`, wired solely into `DesktopBannerInitializer`. The SaaS
banner stack rendered only `<UpgradeBanner />`.
2. **Single banner slot.** `BannerContext` holds one node; `setBanner`
replaces it. `TrialStatusBanner` called `setBanner(null)` when there was
no active trial (and re-fired once `trialStatus` resolved async), wiping
any other banner.
3. **Shadowing was too fragile.** A first attempt shadowed the
proprietary `UpgradeBannerInitializer` from the saas layer, but
`vite-tsconfig-paths` resolves the `@app` specifier once at dev-server
start — a newly-added shadow of an already-resolved module isn't picked
up on a browser refresh, only a full restart. So the proprietary
initializer kept running and no invite banner appeared (while the SaaS
team context still fetched + populated the invite, which is why the
pending call was visible).

## Fix

- Add `saas/components/shared/TeamInvitationBanner.tsx` — ported from
desktop, minus the desktop `connectionMode` gate and explicit billing
refresh (SaaS `acceptInvitation` already refreshes credits + session).
- Render it **inline in `saas/routes/Landing.tsx`** next to
`GuestUserBanner` — a new import specifier in an existing file
(HMR-friendly), unambiguously inside `SaaSTeamProvider`, mirroring the
proven `GuestUserBanner` pattern. No dependency on the single banner
slot.
- **Remove `TrialStatusBanner`** (trials are being retired) so it can't
clobber banners. Also drops the stale mention from the stripe-lazy-load
test comment.

## Verification

- `tsc --noEmit -p tsconfig.saas.vite.json`: clean in touched files;
total unchanged from baseline (37 pre-existing, unrelated).
- Manual: pull + verify the Accept/Decline banner appears for an account
with a pending invite.
2026-06-11 18:01:58 +01:00
James Brunton 34ead60194 Kill off agents pane now that we have FAB (#6613)
# Description of Changes
Kill off agents pane now that we have the FAB. Also fixes a bug with the
FAB where it would sometimes fail to render the chat, and fixes a
duplicated entry in the Vite config which was throwing a warning
2026-06-11 17:28:05 +01:00
ConnorYoh 5bc7ae626d fix(payg): cancelled subscription left team gated as subscribed (#6611)
## Problem

A team that **cancelled** its PAYG subscription kept full subscribed
access:

- **UI didn't reflect cancellation** — the Plan tab still rendered the
subscribed view, never the free/upgrade view.
- **Automation wasn't stopped** — automation / AI / API kept running
without ever falling back to the free-grant gate.

## Root cause

`TeamBillingService.compute` decided `subscribed` as:

```java
boolean subscribed =
    subscriptionId != null
        || extOpt.map(PaygTeamExtensions::getStripeCustomerId).filter(s -> !s.isBlank()).isPresent();
```

On cancellation, the `customer.subscription.deleted` webhook calls
`payg_unlink_subscription`, which nulls `payg_subscription_id` but
**deliberately keeps `stripe_customer_id`** (so a future re-subscribe
can reuse the Stripe customer).

`payg_link_subscription` is the **only** writer of
`payg_team_extensions.stripe_customer_id`, and it writes it in the
*same* `UPDATE` as `payg_subscription_id` (on
`customer.subscription.created`). So the customer id is never set before
the subscription id — the "pre-webhook stand-in" the old comment claimed
**cannot happen**. The fallback only ever pinned a team that *ever*
subscribed to `subscribed` forever, because the Stripe customer outlives
the subscription.

Both symptoms are this one flag:
- `PaygWalletController` status → `SUBSCRIBED` vs `FREE`
- `EntitlementService` gate branch → monthly-cap vs free-grant

## Fix

Gate `subscribed` purely on `payg_subscription_id != null`. A cancelled
team now correctly drops to free (UI shows free; billable ops gate on
the one-time grant). This aligns the wallet/entitlement read with the
**meter path** (`JobChargeService.close`), which already gated on
`payg_subscription_id`.

Handles both Stripe cancel modes: "cancel at period end" keeps the sub
`active` (id stays set) until `.deleted` fires at period end → access
through the paid period; immediate cancel fires `.deleted` now → flips
to free now.

**No data migration / backfill** — already-cancelled teams have
`payg_subscription_id = NULL`, so they flip to free as soon as this
ships (within the 30s billing-cache TTL).

## Tests

Adds `TeamBillingServiceTest` — the `subscribed` computation previously
had **no** unit coverage (which is how this shipped). Covers: subscribed
iff subscription id present; **cancelled team (customer id remains,
subscription id null) ≠ subscribed** + free grant survives;
no-subscription/no-customer; no extension row.

`:saas:test` + `:saas:spotlessCheck` green; coverage gates met.

## Not included (optional hardening, can fast-follow)

- Cross-check the synced `stripe.subscriptions.status` to guard a
*missed* `.deleted` webhook leaving `payg_subscription_id` stale.
- Push cache-invalidation from the webhook (currently ≤30s TTL
staleness).
2026-06-11 17:26:58 +01:00
ConnorYoh f16ca4795c fe(payg): remove em dashes from Plan page copy (#6610)
## What

Removes all em dash (`—`) characters from the **user-facing text** on
the Plan page (PAYG section), replacing them with colons, commas, or
restructured punctuation so the copy reads naturally.

## Changes

- `frontend/editor/public/locales/en-GB/translation.toml` — all `payg.*`
strings (this is what actually renders on the page)
- `PaygFree.tsx` — `t()` default fallbacks + the `{" — "}` JSX
benefit-list separators (now `{": "}`)
- `Payg.tsx` — `t()` default fallback for the editor-plan body

## Notes

- The en-dash range separator (`{{start}} – {{end}}`) in the
billing-period string is intentionally **kept** — only em dashes were
targeted.
- JSDoc / code comments containing em dashes were **left unchanged**,
since they aren't rendered text on the page.
<img width="990" height="502" alt="image"
src="https://github.com/user-attachments/assets/13d89b0f-007c-4b4c-b72d-1d912f968bc7"
/>
2026-06-11 16:50:27 +01:00
James Brunton d52c7ced7c Improvements to Stirling Engine to prepare for SaaS release (#6603)
# Description of Changes
- Use pool for postgres connections
- Add ability to require user ID to be set on API calls to the engine
- Add process-wide concurrency cap on AI access (in addition to existing
user caps)
- Allow number of workers (threads) to be specified for stirling engine
- Update env var names to reflect that the DB is not just for RAG
2026-06-11 16:31:35 +01:00
James Brunton 606964ee52 Fix Teams and MCP settings pages (#6605)
# Description of Changes
Remove the Pro guards from the Team settings page and also fix the
styling of the MCP settings screen (the code sections were black text on
black background in light mode)
2026-06-11 16:26:02 +01:00
ConnorYohandReece cf513c255b PAYG: pay-as-you-go billing — metered automation/AI/API + one-time free grant (#6589)
## Summary

Pay-as-you-go (PAYG) billing for Stirling-PDF SaaS. Manual PDF editing
stays free forever; only **automation, AI, and API** usage is metered.
Every team gets a **one-time lifetime free grant** (default 500 PDFs)
before any billing; past that, a team adds a card and pays per metered
document, with a self-set monthly spending cap.

This branch combines and supersedes the in-flight BE (#6574) and FE
(#6579) work plus the SaaS edge functions (Stirling-PDF-SaaS PR, now on
`v3`), hardened into a single reviewable feature after a pre-merge
dead-code/security review.

## Billing model

- **Always free:** manual / JWT web-tool usage is `BYPASSED` — never
metered, no matter where it's triggered.
- **Billable categories:** `AUTOMATION`, `AI`, `API`.
- **One-time lifetime free grant** (`pricing_policy.free_tier_units`,
default 500): never resets, survives subscribing. It gates unsubscribed
teams (billable API calls hard-stop with a 402 once exhausted) and
decides the free-vs-paid split of every job.
- **Subscribed:** paid documents (beyond the grant) are metered to a
Stripe Billing Meter; an optional monthly spending cap degrades billable
categories when reached.
- **Dedup:** the same file pushed through several steps within a
workflow window counts **once** (lineage join), so API/AI chaining on
one file isn't double-charged.

## What's included

**Database** — Flyway migrations `V11`→`V21` with matching Supabase
twins: pricing policy + per-team sidecar (`payg_team_extensions`:
subscription id, Stripe customer, free-grant counter), append-only
`wallet_ledger`, shadow charges, subscription-state RPCs (`V14`), audit
logs (`V15`), billing category (`V16`), one-time lifetime free grant
(`V19`), launch-grant seed (`V20`), drop of the unused
`wallet_category_summary` view (`V21`).

**Charge pipeline** — `PaygChargeInterceptor` (open/join a process,
split the free grant, write the ledger DEBIT), `JobChargeService`
(consume the grant under a row lock, restore it on a first-step refund,
meter only the paid portion on completion), `StaleJobCloser` fallback
(idempotent close → meter).

**Entitlement** — `EntitlementService` (per-team cached snapshot:
grant-gated for free teams, monthly-cap-gated for subscribed) +
`EntitlementGuard` (401 `SIGNUP_REQUIRED` / 402 `FEATURE_DEGRADED` /
`PAYG_LIMIT_REACHED`).

**Metering** — `PaygMeterReportingService` writes a durable
`payg_meter_event_log` row around every POST to the `meter-payg-units`
edge fn (pending → posted/failed); `PaygMeterReconcileScheduler` retries
unposted events under the same idempotency key inside Stripe's 24h dedup
window.

**Billing facts** — `TeamBillingService` reads the synced `stripe.*`
mirror (subscription window, per-document rate; the unsubscribed-team
estimate resolves the rate by Price `lookup_key = plan:processor`).

**Wallet API** — `PaygWalletController`: `GET /api/v1/payg/wallet`,
`PATCH /api/v1/payg/cap`.

**Frontend** — PAYG Plan page (two-card free layout + subscribed views),
`useWallet`, upgrade modal with lazy-loaded Stripe Embedded Checkout and
a shared `SpendCapControl`, customer-portal link, 402/401 interceptor
toast, en-GB i18n. (Per-member usage shows each teammate's spend; the
activity feed is behind a flag until polished.)

**SaaS edge functions** (`Stirling-PDF-SaaS` `v3`) —
`create-checkout-session`, `create-payg-team-subscription`,
`create-customer-portal-session`, `meter-payg-units`,
`payg-subscription-webhook`, `stripe-sync`, plus the stripe-sync
`migrate` + scoped-`backfill` scripts. All price lookup is DB-driven (no
`STRIPE_PAYG_PRICE_ID_*` env vars).

## Release prerequisites (prod)

1. Apply Flyway migrations (`V11`→`V21`) and the Supabase migration
twins.
2. Stripe Sync Engine: run `stripe-sync:migrate`, then a **scoped**
backfill — `product`, `price`, `customer`, `subscription` only (not
`all`, which rate-limits).
3. Register 2 PAYG webhook endpoints (each its own signing secret):
`stripe-sync` (product/price/customer/subscription `.*`) and
`payg-subscription-webhook` (`customer.subscription.created`/`.deleted`
drive state; `.updated` + `invoice.*` observed). Keep the legacy
`stripe-webhook` only if credits/self-hosted flows still run.
4. Stripe Billing Meter: `event_name = payg_doc_units`, value key
`processed_documents`.
5. Env: `PAYG_METER_ENDPOINT` + `SUPABASE_EDGE_FUNCTION_SECRET`
(backend); the webhook signing secrets (edge fns). The default pricing
policy must point at the PAYG Stripe Price(s); `V20` seeds
`free_tier_units = 500`.

## Testing

- `:saas:test` green, `:saas:spotlessCheck` clean, edge-fn Deno tests
green, FE saas typecheck clean (the remaining errors are pre-existing
`proprietary/*` + `prototypes/*`, untouched here). Cucumber shadow-mode
suite + CI workflow included.

## Pre-merge review

An independent dead-code/security pass came back **clean on security**
(team-derived authz / no IDOR, leader-only cap mutation, no
billing-category downgrade, dev/mock hooks gated to
`import.meta.env.DEV` + `/dev/`, no secrets/injection, fail-open
metering by design). The dead/unwired code it flagged has been removed
in this branch (unenforced sub-cap control, an unused JDBC DAO + its
view, dead methods).

## Follow-ups (tracked, not blocking)

- **Enforce per-member sub-caps** — the control was removed because it
read for display but never gated a request; the per-member usage display
and `cap_units` column are retained for when enforcement is wired.
- **API/AI chaining billing model + `ProcessType` enum** — confirm
same-file dedup covers API chaining; define per-tool AI charging; decide
whether the unused enum values stay.
- **Activity feed** — hidden behind a flag until the meter-event surface
is polished.

---------

Co-authored-by: Reece <reece@stirlingpdf.com>
2026-06-11 15:56:01 +01:00
Reece Browne 11ab762f57 feat(policies): config refinements + new-version output (post-#6598) (#6604)
Follow-up to #6598 (squash-merged into `SaaS`). These are the policy
refinements made after that merge, against the current `SaaS` tip.

## Changes
- **Simplify Security config + plain-language info buttons** — Redact
config reduced to the PII field; Sanitise has no config
(JavaScript-removal only) with a non-technical info button; per-tool
info buttons reworded to match the tool-steps style.
- **Hide 'Flatten PDF pages to images' from the watermark policy
config** — new `PolicyWatermarkConfig` wrapping the watermark settings
with the flatten checkbox gated off.
- **Flatten-to-image on by default for redact + watermark** — both
normalise `convertPDFToImage: true` on mount.
- **Self-heal a stale backing folder** — `ensurePolicyFolder` recreates
a backing folder whose `folderId` no longer resolves (preferring the
backend's stored automation), instead of hanging Edit Settings on a
permanent "Loading…".
- **Version the input file on 'new version' output mode** — completed
runs whose policy output mode is `new_version` replace the input file
with a versioned child (origin tool `automate`) rather than adding a
separate file; falls back to a new file if the input is gone.
`outputMode` is plumbed through `PolicyState`, the local-cache default,
and backend reconciliation.

## Verification
- `typecheck:proprietary` + `typecheck:core` clean
- policy + hooks vitest: 17 passing
- eslint + prettier clean on all changed files
2026-06-11 14:45:22 +01:00
James Brunton 68e031ac55 Policies tidying (#6587)
# Description of Changes
* Improve typing of API (breaking change but unreleased, frontend also
updated in this PR)
* Add ownership concept to policies
* De-AI the comments
* Update the `task dev:saas` rule to spawn the engine as well
2026-06-11 13:20:01 +01:00
Anthony Stirling c722b9f6ad fix: MCP copy buttons read as proper buttons in dark mode
Subtle/gray compact buttons rendered as low-contrast floating text;
use the default variant (adaptive surface+border) idle, light teal when
copied.
2026-06-10 17:26:43 +01:00
Anthony Stirling 36c68fb69e fix: doubled base path in mobile-scanner QR URL
A configured frontendUrl/server_url already includes the subpath (e.g.
/bpp), but the code also applied withBasePath, producing /bpp/bpp/...
Append the route directly to a configured URL; reserve withBasePath for
the bare-origin fallback. Matches the ShareFileModal convention.
2026-06-10 17:21:42 +01:00
Anthony Stirling d3c359f923 reword MCP usage tip to reference the API and Automation 2026-06-10 16:38:16 +01:00
Anthony Stirling 4947ab12fd remove the 'What your assistant can do' tool-category badges from MCP section 2026-06-10 16:33:48 +01:00
Reece Browne 8dde4262ec feat(policies): backend-driven policy enforcement (frontend) (#6598)
## Summary
Adds the **Policies** feature (proprietary, behind the
`POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed
tool pipeline on documents, docked in the right tool sidebar alongside
Tools.

## Highlights
- **Policy catalog** — 5 categories; **Security** is wired (redact PII +
sanitize), the others are marked "Coming soon".
- **Backend as source of truth** — policies persist via the Policies
engine (`/api/v1/policies`), one policy per category, with a local cache
+ offline fallback.
- **Auto-run** — enabled policies run on every uploaded file: dispatch →
poll → import outputs into the workspace.
- **Security redact config** — PII preset dropdown + custom word/regex
entry + advanced options; tool params map to the backend endpoint
fields.
- **Activity feed** with retry on failures; **file badges** showing
which policies ran on a file (sidebar + files page), tinted to the
policy colour.
- Reuses the **Watched Folders** engine for each policy's backing
folder; policy-owned folders are filtered out of the Watched Folders UI.

## Notes
- Gated by `POLICIES_ENABLED` (true in proprietary, false in core) —
unreachable in the open-source build.
- Frontend-only diff; depends on the backend Policies engine and the
merged Watched Folders feature.
2026-06-10 15:57:08 +01:00
James Brunton ebc28b0a14 Add team settings to SaaS (#6601)
# Description of Changes
Add team settings UI to SaaS, which is currently only available in
desktop. It'd be nice to refactor this so they're more shared, but
they're slightly different so needs to be done with some care. Leaving
for followup work.
2026-06-10 15:54:18 +01:00
Anthony Stirling 56862cc1d3 Merge branch 'main' into SaaS 2026-06-10 15:51:43 +01:00
Anthony Stirling d6306f51e1 fix: no blue disc behind the sidebar profile picture
Keep the colored background only for the initials fallback; a real
photo fills the circle with a transparent backing.
2026-06-10 15:05:29 +01:00
Anthony Stirling 9a1804ce04 Merge branch 'main' into SaaS 2026-06-10 14:58:44 +01:00
Anthony Stirling be0db3fd8a fix: show profile picture in the FileSidebar bottom bar
The home page's bottom-left settings button is FileSidebar's bottom
bar, which hardcoded an initials circle - the avatar work in
useConfigButtonIcon only affects the QuickAccessBar rail, which the
home page doesn't render. Add a layered useProfilePictureUrl hook
(core stub returns null; saas returns the auth context URL) and render
the picture inside the existing avatar circle, falling back to the
initial when absent or on image load failure.
2026-06-10 14:49:16 +01:00
Anthony Stirling 90bda6b4b4 fix: User principal was discarded by the resource server re-authentication
The saas chain authenticates bearer requests twice:
SupabaseAuthenticationFilter builds an EnhancedJwtAuthenticationToken
with the resolved User principal, but BearerTokenAuthenticationFilter
(oauth2ResourceServer) then re-authenticates the same token through the
static toAuthentication converter and overwrites the SecurityContext
with a token whose principal is the raw Jwt - so storage endpoints kept
returning 401 "Unsupported user principal" despite the principal fix.

Carry the User across in the converter: when the context already holds
an EnhancedJwtAuthenticationToken for the same subject with a User
principal, attach that User to the converter-built token. No extra DB
lookups; anonymous sessions and API-key auth unchanged. Covered by new
unit tests (carry, no-context, subject mismatch).
2026-06-10 14:37:12 +01:00
Anthony Stirling 5b412c0fed cleanup: trim oversized comments across recent SaaS fixes
Reduce multi-paragraph comment blocks to short two-line notes and drop
history-style references; no behaviour changes.
2026-06-10 14:30:29 +01:00
Anthony Stirling bf18af4708 fix: show user avatar on the home page settings button
The bottom-left settings button and the settings page both read
profilePictureUrl, but only the settings page had a fallback (initials
avatar) - the button silently fell back to a gear. The URL itself was
usually null because fetchProfilePicture raced the background OAuth
avatar sync with a fixed 500ms delay and never retried, and a missing
bucket object simply resolved to null.

- useConfigButtonIcon: fall back to the same initials avatar as the
  settings page instead of the gear when no picture URL is available.
- UseSession: fetch the profile picture when syncOAuthAvatar settles
  (init and SIGNED_IN) instead of after an arbitrary 500ms.
- fetchProfilePicture: when the bucket copy is missing, fall back to
  the OAuth provider's own photo URL so the picture shows immediately
  on first login - unless the user explicitly uploaded/removed a
  picture (metadata source 'upload'), preserving the remove flow.
2026-06-10 14:25:19 +01:00
Anthony StirlingandClaude Opus 4.8 d29059e6fb fix: storage APIs 401'd valid Supabase sessions (principal type mismatch)
FileStorageService.requireAuthenticatedUser and
FolderService.requireAuthenticatedUser authorize via
'principal instanceof User', but EnhancedJwtAuthenticationToken extends
JwtAuthenticationToken whose principal is the decoded Jwt - so every
/api/v1/storage/* request 401'd for JWT users AFTER Spring Security had
already authenticated them. This persistent 401-with-valid-session was
the trigger feeding the frontend login loop.

Attach the filter-resolved local User as the token principal for full
accounts (User implements UserDetails, matching the form-login
convention every shared instanceof check expects). Anonymous sessions
keep the raw Jwt principal, preserving their existing exclusions. All
other principal consumers verified safe: AuthenticationUtils checks
instanceof User first, extractSupabaseId/CreditController/Team
SecurityExpressions switch on the authentication type, not the
principal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:44:22 +01:00
Anthony StirlingandClaude Opus 4.8 e7bbbb4702 fix: make the 401 login redirect loop structurally impossible
Audit of every code path that can produce the login->/->login cycle
found the observed loop was one instance of a repeatable class: any
automatic API call that persistently 401s while the Supabase session is
valid triggers httpErrorHandler's hard redirect to /login, which sees
the valid session and bounces back. Close the class, not just the
instance:

- saas apiClient: a 401 that survives a refresh-and-retry means the
  backend rejected a valid token (authz bug / wrong origin), not an
  expired session - never redirect to /login for it. Also fix the stale
  publicEndpoints entry ('endpoints-enabled' matched nothing; the real
  routes are endpoints-availability and endpoint-enabled).
- httpErrorHandler: sessionStorage loop breaker - if a 401 redirect
  already fired within 10s, suppress the repeat instead of cycling.
- Guard the remaining unflagged automatic callers: /api/v1/credits
  (fires on session init and TOKEN_REFRESHED), endpoints-availability
  (fires on app load), and ui-data/login (auto-called when a stale
  stirling_jwt is present).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:44:21 +01:00
Anthony StirlingandClaude Opus 4.8 247ef6313c fix: stop login loop caused by unguarded folder-sync 401
The deployed app looped /login -> / -> /login forever: Login sees a
valid Supabase session and navigates to /, the global FolderProvider
pulls GET /api/v1/storage/folders, the backend rejects it with 401, and
the global error handler hard-redirects back to /login?from=/bpp.

fileSyncService's /api/v1/storage/files pull already opts out via
suppressErrorToast + skipAuthRedirect, so its 401 fails silently;
folderSyncService.list() passed neither flag, so its 401 fell through to
the redirect. Add the same flags - FolderContext.pullFromServer already
handles 4xx locally (flips serverReachable, suppresses the banner).

Note: the underlying 401 on /api/v1/storage/* with a valid session is a
backend/deployment issue (storage endpoints rejecting the Supabase
token); this change makes the frontend resilient so it degrades to
"folder sync unavailable" instead of an auth loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:13:20 +01:00
Anthony StirlingandClaude Opus 4.8 7f7c865888 fix: stop unauthenticated storage calls on /login + fix subpath manifest 404
Two issues seen on the hosted /bpp login screen:

1. GET /api/v1/storage/folders fired (and 401'd) on the login page. The
   global FolderProvider pulls from the server whenever
   appConfig.storageEnabled is true, with no auth gate, so it hits the
   authenticated storage API before the user has signed in. Skip the pull
   on auth routes (/login, /signup, /auth/*, /invite, /reset-password),
   mirroring the existing LicenseContext / AppConfigContext guards. Tests
   wrap FolderProvider in MemoryRouter (now uses useLocation).

2. manifest.json and modern-logo/favicon.ico 404'd from the domain root
   instead of /bpp/. vite base for RUN_SUBPATH deploys was "/bpp" with no
   trailing slash, so <base href="/bpp"> made the browser resolve relative
   links against the parent (root). Use "/bpp/"; getBasePath() strips the
   trailing slash, so BASE_PATH, routing and asset URLs are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:56:48 +01:00
Anthony Stirling 2101b4028c Merge branch 'main' into SaaS 2026-06-10 11:42:25 +01:00
Anthony StirlingandClaude Opus 4.8 06476ea69e fix(saas): collapse duplicate Supabase client to one GoTrueClient
The console warned "Multiple GoTrueClient instances detected in the same
browser context" and storage endpoints (/api/v1/storage/folders,
/files) kept 401ing even after a successful token refresh.

Cause: the SaaS bundle instantiated TWO Supabase clients on the same
sb-<ref>-auth-token storage key. :saas/auth/supabase.ts creates the
primary client (used by UseSession + apiClient), while billing /
licensing / user-management code imports @app/services/supabaseClient,
which fell through to :proprietary/services/supabaseClient.ts and called
createClient() again. Each client runs its own autoRefreshToken timer,
so they rotate the refresh token out from under each other → "Already
Used" refresh failures and spurious 401s, plus a residual /login flash.

Add a :saas override of @app/services/supabaseClient that re-exports the
single instance from @app/auth/supabase. The path mapping
(@app/* → src/saas/* → src/proprietary/* → src/core/*) now resolves
every consumer to the same client, so the :proprietary createClient() is
never bundled in the SaaS build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:26:27 +01:00
Anthony StirlingandClaude Opus 4.8 1135bd9b63 fix(saas): stop login→logout→login bounce on cold load
On returning to the app with an expired Supabase access token, bootstrap
requests fired with the stale token and 401'd before Supabase finished
refreshing. The global 401 handler then hard-redirected to
/login?from=… (a full window.location navigation), and once the refresh
landed the app sent the user straight back in — the login/logout/login
flicker.

Two holes in the SaaS apiClient response interceptor caused it:

1. "public" endpoints (e.g. /api/v1/config/app-config) skipped the
   refresh-and-retry path. The backend 401s any expired Bearer token
   regardless of route, so those bootstrap calls 401'd and fell through
   to handleHttpError, which redirected to /login. Now public endpoints
   also refresh-and-retry, and a 401 on a public endpoint sets
   skipAuthRedirect so it can never trigger the global login redirect.

2. Concurrent 401s each called supabase.auth.refreshSession()
   independently. Supabase rotates the refresh token on first use, so
   the racing refreshes failed with "Invalid Refresh Token: Already
   Used" and bounced the app even though the session was recoverable.
   Refreshes are now de-duplicated through a single in-flight promise.

Existing apiClient unit tests (refresh-and-retry on protected 401, bare
/login redirect on genuine refresh failure) are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:13:28 +01:00
Anthony Stirling bbfe29c2ef Merge remote-tracking branch 'origin/main' into SaaS
# Conflicts:
#	frontend/editor/src/core/components/shared/AppConfigModal.tsx
2026-06-10 10:51:06 +01:00
Anthony Stirling 92376b7382 fix: prettier format on AppConfigModal 2026-06-08 21:28:13 +01:00
Anthony Stirling 1d5ce8a1d2 chore: shorten verbose block comments across SaaS branch 2026-06-08 18:38:00 +01:00
Anthony Stirling 8b2baaf0a0 Merge remote-tracking branch 'origin/saas-docker-split' into SaaS 2026-06-08 18:10:02 +01:00
Anthony Stirling d9651f7065 fix(engine): match Dockerfile layout to root Taskfile dir: engine 2026-06-08 18:02:02 +01:00
Anthony Stirling 4cd03be87a fix: send Supabase token on raw fetch in SaaS chat 2026-06-08 16:41:09 +01:00
Anthony Stirling 02d923f378 chore: remove env var debug from vite.config 2026-06-08 16:29:47 +01:00
Anthony Stirling e7d3430134 merge: pull latest main into SaaS 2026-06-08 16:28:14 +01:00
Anthony Stirling 4b2be58fab debug: fuzzy-match env var names that look like VITE_API_BASE_URL 2026-06-08 16:18:19 +01:00
Anthony Stirling 290c8c2c8b debug: enumerate VITE_/RUN_ env var names in build log 2026-06-08 16:06:56 +01:00
Anthony Stirling 90d6ecd7e1 debug: log env vars at build, write build-info.txt with masked markers 2026-06-08 15:55:40 +01:00
Anthony Stirling a0b7daca52 trigger: rebuild after VITE_API_BASE_URL scope fix 2026-06-08 13:37:30 +01:00
Anthony Stirling 0b575ed841 fix: respect BASE_PATH in AI chat fetch and pdfjs worker assets 2026-06-06 21:21:24 +01:00
Anthony Stirling 940cb2fc44 chore: trigger Cloudflare deploy 2026-06-06 19:26:08 +01:00
Anthony Stirling 9da0a0d020 fix: respect BASE_PATH in redirects, comparisons, and cookie consent paths 2026-06-06 19:20:07 +01:00
Anthony Stirling 0b944a29a7 Prefer Maven Central over jboss/shibboleth mirrors for resilience 2026-06-03 09:10:08 +01:00
Anthony Stirling 58aeba2bf7 Add backend-only and SaaS-aware frontend Dockerfiles 2026-06-03 09:03:58 +01:00
3651 changed files with 477664 additions and 118397 deletions
@@ -0,0 +1,97 @@
---
name: feature-walkthrough
description: >-
Explain the full logic and process of the current branch end-to-end so someone
with no prior knowledge of the task can understand, review, and reproduce it.
Scopes the change from the branch diff, traces the flow across every layer it
touches (frontend tool/hook/component, Java controller/service/endpoint, Python
engine, config, i18n, tests), and produces a self-contained walkthrough document
with Mermaid diagrams (sequence/flow/architecture), annotated file map with
clickable references, before/after behavior, screenshots where a UI is involved,
a "try it locally" section, and edge cases/risks. Use when asked for a feature or
branch walkthrough, "explain what this branch does", a design/logic writeup, PR
reviewer onboarding, or a hand-off doc. Pass --html to also emit a rendered HTML
version; --no-screens to skip screenshots.
argument-hint: "[branch-or-area] [--html] [--no-screens]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# Feature / Branch Walkthrough
Turn the current branch into a walkthrough a newcomer can follow. Audience:
**someone who has never seen this task**. Explain the *why*, the *flow*, and *how to
try it* - not just a diff summary.
`$ARGUMENTS` may name a branch or area to focus on; default is the current branch
vs `main`. Flags: `--html` (also emit a rendered HTML twin), `--no-screens`.
## Process
### 1. Scope the change
- `git log --oneline main..HEAD` and `git diff --stat main...HEAD` for the shape.
- Read the PR description / commit messages for stated intent. Do **not** invent
history or motivation that isn't evidenced (state current behavior in present tense).
- Classify touched files by layer:
- **Frontend**: tools (`frontend/editor/src/core/components/tools/*` or `.../core/tools/*`),
hooks (`core/hooks/tools/*`, `useToolOperation`), contexts, routes, i18n
(`public/locales/en-US`).
- **Java backend**: controllers (`.../controller/api/...`), services, models, config.
- **Engine**: `engine/src/stirling/{agents,contracts,api,services}`.
- **Config / build / docker / tests.**
### 2. Trace the flow end-to-end
Follow one real path from user action to result. For a typical PDF tool that's:
UI control → `useToolOperation` hook → `POST /api/v1/...` → Spring controller →
service (PDFBox / LibreOffice / engine call) → response → review panel → download.
Read the actual files so the narrative is true to the code, and collect the exact
file:line anchors you'll cite.
### 3. Draw the diagrams (Mermaid)
Pick what fits; usually 2-3 of:
- **Sequence diagram** - request/response across frontend → backend → engine.
- **Flowchart** - the core decision/branching logic of the feature.
- **Architecture/component** - new pieces and how they wire to existing ones.
- **State** - if the feature has modes/steps.
Keep nodes labeled in plain language. Validate the Mermaid parses before shipping.
### 4. Screenshots (unless --no-screens)
If a UI is involved, capture key states with the stubbed Playwright harness
(see the **ui-walkthrough** skill and `files-page-screenshots.spec.ts` for the
pattern) or, for before/after, capture `main` then the branch. Drop PNGs in
`walkthrough/<feature>/` and reference them from the doc. For backend-only
changes, show request/response examples (curl + JSON) instead.
### 5. Write the walkthrough
Create `walkthrough/<feature>/FEATURE-WALKTHROUGH.md` with:
1. **TL;DR** - what the branch does and who it's for, in 3-4 sentences.
2. **Problem & approach** - what wasn't possible before; the chosen solution.
3. **Architecture diagram** + 1-paragraph orientation.
4. **End-to-end flow** - the sequence diagram + a numbered walk of each step,
each citing the real file (clickable `path:line`).
5. **Key files** - annotated map (path → one line on its role).
6. **Logic deep-dive** - the flowchart + prose for the non-obvious decisions.
7. **Behavior** - before vs after; screenshots or request/response examples.
8. **Try it locally** - exact steps (`task dev` / `task dev:all`, the route to
open or the curl to run, any env like `DOCKER_ENABLE_SECURITY` or a test
license key). Make it copy-pasteable.
9. **Edge cases, risks, follow-ups** - what's untested, known limits, gotchas.
Markdown is the primary deliverable - it renders with diagrams in GitHub PRs and
IDEs, no build step, ideal for review.
### 6. If `--html`
Also emit `walkthrough/<feature>/walkthrough.html`: the same content with Mermaid
rendered via `mermaid.initialize({startOnLoad:true})` (script from CDN; note in
the file that rendering diagrams needs network, the `.md` is the offline copy) and
screenshots inline. Keep it self-contained otherwise.
### 7. Deliver
Give the doc path and a short chat summary. Offer to `SendUserFile` it.
## Principles
- **True to the code.** Every claim traces to a file you read; cite `path:line`.
No fabricated migration/version history.
- **Newcomer-first.** Define repo-specific terms (FileContext, `useToolOperation`,
the `@app/*` layer cascade, stubbed vs live tests) on first use.
- **Show, don't assert.** Prefer a diagram + a real example over adjectives.
- Don't commit the `walkthrough/` output unless asked.
+137
View File
@@ -0,0 +1,137 @@
---
name: pr-quiz
description: >-
Quiz the PR author on their own branch before they request review, to prove they
actually understand the change - especially code an AI wrote for them. Scopes the
branch diff vs its base, reads the changed code, then asks graded questions about
what changed, why, how it works, what it could break, and which edge cases it must
handle. Presents all questions first, waits for the author's answers, then grades
each honestly against the real code (Correct / Partial / Incorrect with the true
answer and file:line), scores it, and gives a readiness verdict that names the
areas to re-study before asking humans to review. Use when asked to quiz me on my
PR/branch, "test my understanding before review", a self-check gate before opening
a PR, or before requesting reviewers. Administered as an interactive
multiple-choice quiz (clickable options) by default; pass --free-text for
written answers, --questions N to set count, --save to write a scorecard.
argument-hint: "[branch-or-base-ref] [--questions N] [--free-text] [--save]"
allowed-tools: Bash, Read, Grep, Glob, Write, AskUserQuestion
---
# PR Quiz
Test whether the **author** genuinely understands their own branch before they ask
other people to spend time reviewing it. This is a self-check gate: the point is to
catch changes - often AI-written - that the author would not be able to explain or
defend in review. Be a fair but honest examiner, not a pushover.
`$ARGUMENTS` may name a base ref or branch to diff against; default is this branch
vs where it forked from the main line. Flags:
- `--questions N` - target N questions (else scale to diff size, see below).
- `--free-text` - administer as a written numbered list instead of the default
interactive multiple-choice.
- `--save` - also write a scorecard file after grading.
## Integrity rules (read first - the whole skill depends on these)
1. **Present every question before revealing any answer.** Ask, then wait. Never
show the answer key alongside the questions.
2. **Do not give hints or the answer while the quiz is open.** If the author asks
"what's the answer?" or "is it X?" before committing, decline warmly and tell
them to give their best answer first - guessing is part of the signal.
3. **Grade truthfully.** Vague, hand-wavy, or "the AI did it" non-answers are
Partial or Incorrect, not Correct. Do not inflate the score to be nice; a false
pass defeats the entire purpose.
4. **Ground everything in code you actually read.** Every question and every model
answer must trace to a real line in the diff. Cite `path:line`. No trivia
("how many lines?"), no invented behavior.
5. **Credit real understanding.** If the author explains it correctly in their own
words, mark it Correct even if worded differently than your key.
## Process
### 1. Scope the change (silently)
- Find the base. Prefer the fork point off the main line so the quiz covers only
this branch's work:
```bash
git fetch -q origin 2>/dev/null; \
BASE=$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main); \
git diff --stat "$BASE"...HEAD
```
If `$ARGUMENTS` names a ref, diff against that instead.
- If the diff is empty, stop and say there's nothing to quiz on.
- Read commit messages / PR description for the *stated* intent, but verify it
against the actual diff - a mismatch is itself a good question.
### 2. Understand the code well enough to examine on it
Read the full diff plus enough surrounding context and related files to answer
every question you plan to ask. You cannot grade understanding you don't have.
Note the non-obvious parts: the design decisions, the risky lines, the edge cases,
the cross-file ripples, and anything that violates or upholds repo conventions
(for this repo e.g. `@app/*` import layering, all file ops via FileContext,
Jackson 3 / Spring Boot 4 APIs, engine typed-contract boundaries).
### 3. Build the question set
Scale count to the change unless `--questions N` is given:
small (< ~50 changed lines) 3-4, medium 5-8, large 9-12. Cap at 12.
Draw from these categories - weight toward the ones the diff actually exercises:
- **Intent** - what problem this solves; why it was needed now.
- **Mechanism** - how a specific non-trivial piece actually works ("walk me
through what `foo()` does when called with X").
- **Decisions & alternatives** - why this approach over an obvious alternative;
what a reviewer would reasonably push back on.
- **Blast radius** - what else this touches or could break; what you'd retest.
- **Edge cases** - inputs/states the change must handle (null, empty, large,
concurrent, error paths).
- **Conventions & correctness** - does it follow the repo's rules; is there a
latent bug the author should be able to spot.
Prefer questions the author can only answer if they read and understood the code.
Keep a private answer key with `path:line` for each - do **not** show it yet.
### 4. Administer the quiz
- **Default (multiple choice):** use the `AskUserQuestion` tool. Per question write
3-4 options where **every** option is independently plausible - each distractor a
real-but-wrong reading of the code, not filler. Two hard rules so the answer
can't be spotted by shape rather than knowledge:
- **Randomise the correct option's position** across questions - never default
it to first. Spread it roughly evenly over the slots.
- **Keep all options the same depth and length.** Do not describe the correct
one more fully than the distractors - a longer or more-detailed option is a
dead giveaway. Trim the right answer or flesh out the wrong ones until a
reader can't tell them apart by size.
The tool caps a call at 4 questions, so ask in batches of 4 - but run them as
one continuous flow: fire the next batch immediately after the previous
returns, with no narration ("Round 2 of 3") and no grading between batches.
The author always has an "Other" free-text escape, which is fine.
- **`--free-text`:** present all questions in one numbered list, then say
"Answer in one reply; number your answers. I won't grade until you're done."
Wait for the author's answers.
- Do not proceed to grading until every answer is in.
### 5. Grade
For each question, in order:
- Verdict: **Correct** / **Partial** / **Incorrect**.
- The model answer in one or two sentences, citing the real `path:line`.
- One line on the gap when Partial/Incorrect - what they missed and where to look.
Then a **Score** (e.g. 6/8, counting Partial as half) and a one-line summary of
the pattern (e.g. "solid on intent, shaky on the error paths").
### 6. Readiness verdict
End with a clear call:
- **Ready for review** - understanding is sound; note anything to mention to
reviewers proactively.
- **Study first** - list the specific files/concepts to re-read before requesting
review, each as a clickable `path:line`. Be concrete: "re-read the null handling
in X before you send this out."
Keep it honest - if they'd get grilled in review on something, say so now.
### 7. If `--save`
Write `pr-quiz/<branch>-scorecard.md`: the questions, their answers, your grades
and model answers, the score, and the verdict. Don't commit it unless asked.
## Principles
- **The author is the examinee, not the collaborator.** During the quiz you withhold
answers; you're measuring them, not helping them pass.
- **A failed quiz is a successful outcome** - it caught a gap before a human's time
was spent. Frame it that way, not as a scolding.
- **True to the code.** Every question, answer, and grade traces to a line you read.
- **Terse and direct** in chat - the questions and the verdict, minimal preamble.
+122
View File
@@ -0,0 +1,122 @@
---
name: ui-before-after
description: >-
Analyse a branch or PR and automatically capture before/after screenshots of
every UI surface its changes touch, then pixel-diff the pairs to surface what
actually changed and assemble PR-ready before/after montage images. Generic and
diff-driven: it derives the capture targets from the diff (changed tools/routes →
URLs) instead of hand-listing screens, captures "before" from the base branch and
"after" from the head, then keeps only the views that visually differ. Each
comparison is auto-cropped to the region that actually changed (the bounding box of
differing pixels), falling back to the full page only when the change spans most of
it. Use for before/after shots, a visual diff of a branch/PR, "screenshots for the
PR description", "show what changed in the UI", or a side-by-side of UI changes.
Takes a PR number/URL (resolved via gh) or a branch; defaults to the current branch
vs its base. Flags: --scope <selector>, --base <ref|merge-base>, --theme
light|dark|both, --all (capture every route, not just changed), --no-autocrop,
--pagewide <n>, --threshold <n>.
argument-hint: "[PR# | PR-url | branch] [--scope <sel>] [--base <ref>] [--theme both] [--all] [--no-autocrop]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# UI Before / After (generic visual diff)
Point it at a branch or PR; it figures out which UI changed, screenshots every
affected surface **before** (base) and **after** (head), pixel-diffs the pairs, and
montages the ones that actually changed into images for the PR description.
`$ARGUMENTS`: a PR number/URL, a branch, or nothing (current branch vs base).
By default it captures the full viewport and auto-crops each comparison to the region
that changed. Flags: `--scope <css>` (narrow the *capture* to a container, e.g.
`[data-sidebar="tool-panel"]`, when you already know where the change is),
`--no-autocrop` (keep full frames), `--pagewide <fraction>` (above this share of the
page, skip cropping; default 0.6), `--base <ref|merge-base>`,
`--theme light|dark|both`, `--all` (walk every route, not just changed),
`--threshold <fraction>` (diff sensitivity, default 0.001).
Shares the capture harness with **ui-walkthrough** - read its SKILL.md for the
stubbed-Playwright setup, worktree node_modules + `generate-icons`, the
stale-`:5173` gotcha, and the dark-mode init-script. Bundled helpers:
[capture-spec.template.ts](capture-spec.template.ts), [diff-shots.mjs](diff-shots.mjs),
[montage-template.html](montage-template.html), [shoot-sections.mjs](shoot-sections.mjs).
## Process
### 1. Resolve target + base
```
gh pr view <pr> --json number,title,headRefName,baseRefName,url,files # PR
# or branch: base = merge-base(main, HEAD); head = HEAD
gh pr diff <pr> --name-only # or: git diff --name-only <base>...HEAD
```
### 2. Derive capture targets from the diff (the "analyse" step - no hand-listing)
Map changed frontend files to URLs generically:
- **Tools**: a changed `components/tools/<toolDir>/…` or `hooks/tools/<tool>/…`
toolId → URL via the repo's own rule `getToolUrlPath` in
[toolsTaxonomy.ts:200](frontend/editor/src/core/data/toolsTaxonomy.ts): `/` + the
id kebab-cased (`addPageNumbers``/add-page-numbers`).
- **Pages/routes**: changed `filesPage/*``/files`, etc.
- `--all`: enumerate every tool in the registry instead of just changed ones.
Write `frontend/editor/screenshots/ui-diff/targets.json` =
`[{ "id":"compress", "url":"/compress", "name":"Compress" }]`. This is what makes
it generic - the spec never names a tool.
### 3. Capture AFTER (head) then BEFORE (base)
Copy [capture-spec.template.ts](capture-spec.template.ts) →
`src/core/tests/stubbed/ui-before-after.spec.ts` (it loops `targets.json`, seeds a
sample PDF so file-dependent panels render, navigates to each URL, and screenshots
the full viewport - or the `--scope` container if given). Ensure the harness is ready
(node_modules + icons).
```
# after = current head
cd frontend/editor && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# before = base, in an isolated worktree (copy the spec + targets.json in)
git worktree add ../ba-base origin/<baseRefName> # or the merge-base
# set up its frontend, copy spec + screenshots/ui-diff/targets.json across, then:
cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# copy its screenshots/ui-diff/before/ back next to after/. Repeat with
# PR_SHOT_THEME=dark if --theme includes dark. Remove worktree when done.
```
### 4. Auto-diff (surface what changed)
```
cd frontend/editor && node <skill>/diff-shots.mjs \
screenshots/ui-diff/before screenshots/ui-diff/after screenshots/ui-diff
```
Produces `diff-report.json` classifying each view `unchanged | changed | added |
removed`. For each changed view it computes the bounding box of differing pixels and
writes cropped `__before_crop.png` / `__after_crop.png` / `__diff.png` to that region
(+ padding) - **unless** the change covers more than `--pagewide` of the frame, where
it keeps the full frame (`pageWide:true`). Drop `unchanged` - that's the noise the
user doesn't want.
### 5. Montage the changes
Build the manifest from the non-unchanged entries (group by tab/tool; each becomes a
state row with before/after). For changed views use the cropped `cropBefore` /
`cropAfter` from `diff-report.json` (tight on the affected region; full frame when
`pageWide`); `added`/`removed` render the "not present" placeholder. Fill
[montage-template.html](montage-template.html) (replace the `window.__BA__` data
block; base64-inline the PNGs for portability), then render one PNG per section with
[shoot-sections.mjs](shoot-sections.mjs). Optionally include the `__diff.png` overlay
as a third column.
### 6. Deliver
Output the `montage_<tab>.png` files + a short summary (N changed / added / removed,
M unchanged skipped) and a paste-ready Markdown block. GitHub has no PR-body image
API, so tell the user to drag the PNGs into the description. Do **not** post to the
PR.
## Gotchas
- Two installs (base worktree + head); junction main's node_modules only if its deps
match that ref, else `npm ci` (see ui-walkthrough's stale-dep note).
- A view that errors on one side (refactored/removed) → that side is missing; the
diff marks it added/removed rather than failing the run.
- Pixel diff needs equal dimensions, so capture at a fixed viewport (the template
does); a view whose size changed is reported as "changed (dimensions differ)",
uncropped.
- Auto-crop uses a single bounding box, so two far-apart changes give one large crop
(or trip `--pagewide`); narrow with `--scope` if that happens.
- `getToolUrlPath` is the source of truth for tool URLs - use it, don't guess slugs.
- Don't commit `screenshots/`, the throwaway spec, or the base worktree.
@@ -0,0 +1,67 @@
// Generic before/after capturer. NOT app-specific: it walks a targets.json that
// the ui-before-after skill generates from the branch/PR diff, so nothing here is
// hand-listed. Copy to src/core/tests/stubbed/ui-before-after.spec.ts, then run
// once per (side, theme):
// PR_SHOT_SIDE=after PR_SHOT_THEME=light \
// npx playwright test --project=stubbed ui-before-after.spec.ts
//
// targets.json shape: [{ "id":"compress", "url":"/compress", "name":"Compress",
// "needsFile": true }]
import { test } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
const SIDE = process.env.PR_SHOT_SIDE ?? "after";
const THEME = process.env.PR_SHOT_THEME ?? "light";
// Capture the full viewport by default so the affected region is in frame
// wherever it is; diff-shots.mjs crops each comparison to what actually changed.
// Set PR_SHOT_SCOPE to a selector to narrow the capture to one container.
const SCOPE = process.env.PR_SHOT_SCOPE ?? "";
const ROOT = path.resolve(process.cwd(), "screenshots", "ui-diff");
const OUT = path.join(ROOT, SIDE);
// A tiny sample PDF so file-dependent tool panels render. Point at a real fixture.
const SAMPLE_PDF = process.env.PR_SHOT_SAMPLE ?? "src/core/tests/test-fixtures/sample.pdf";
type Target = { id: string; url: string; name?: string; needsFile?: boolean };
const targets: Target[] = JSON.parse(fs.readFileSync(path.join(ROOT, "targets.json"), "utf-8"));
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 }, seedJwt: true });
async function applyTheme(page: Page): Promise<void> {
if (THEME !== "dark") return;
await page.addInitScript(() => {
localStorage.setItem("mantine-color-scheme", "dark");
localStorage.setItem("mantine-color-scheme-value", "dark");
});
await page.emulateMedia({ colorScheme: "dark" });
}
async function seedFile(page: Page): Promise<void> {
if (!fs.existsSync(SAMPLE_PDF)) return;
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("files-button").click().catch(() => {});
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF).catch(() => {});
await page.locator(".file-sidebar-file-item").first().isVisible({ timeout: 8_000 }).catch(() => {});
}
for (const t of targets) {
// One test per target so a single failure doesn't drop the rest.
test(`${SIDE}/${THEME} ${t.id}`, async ({ page }) => {
fs.mkdirSync(OUT, { recursive: true });
await applyTheme(page);
if (t.needsFile !== false) await seedFile(page);
await page.goto(t.url, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(400); // settle Mantine portals/transitions
const shot = path.join(OUT, `${t.id}__${THEME}.png`);
if (SCOPE) {
const scope = page.locator(SCOPE).first();
if (await scope.isVisible({ timeout: 8_000 }).catch(() => false)) {
await scope.screenshot({ path: shot });
return;
}
}
// Full viewport (fixed size → stable dimensions for pixel diffing).
await page.screenshot({ path: shot });
});
}
@@ -0,0 +1,106 @@
// Auto-diff before/ vs after/ screenshots, classify each as
// unchanged | changed | added | removed, and CROP each changed pair to the
// affected region (bounding box of differing pixels + padding) - unless the
// change spans most of the page, in which case the full frame is kept.
// Run from frontend/editor (so deps resolve):
// node <skill>/diff-shots.mjs <beforeDir> <afterDir> [outDir]
// Env:
// DIFF_THRESHOLD min fraction of differing pixels to count as changed (default 0.001)
// DIFF_PAD padding px around the affected region (default 24)
// DIFF_PAGEWIDE if affected bbox area / image area exceeds this, keep full frame (default 0.6)
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
const require = createRequire(path.join(process.cwd(), "noop.js"));
const pm = require("pixelmatch");
const pixelmatch = pm.default || pm;
const { PNG } = require("pngjs");
const beforeDir = path.resolve(process.argv[2]);
const afterDir = path.resolve(process.argv[3]);
const outDir = path.resolve(process.argv[4] || afterDir);
const THRESHOLD = Number(process.env.DIFF_THRESHOLD ?? "0.001");
const PAD = Number(process.env.DIFF_PAD ?? "24");
const PAGEWIDE = Number(process.env.DIFF_PAGEWIDE ?? "0.6");
const read = (p) => PNG.sync.read(fs.readFileSync(p));
const isShot = (f) => f.endsWith(".png") && !/__(diff|before_crop|after_crop)\.png$/.test(f);
const list = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter(isShot) : []);
const names = [...new Set([...list(beforeDir), ...list(afterDir)])].sort();
fs.mkdirSync(outDir, { recursive: true });
function cropPNG(src, x, y, w, h) {
const out = new PNG({ width: w, height: h });
PNG.bitblt(src, out, x, y, w, h, 0, 0);
return out;
}
const writePNG = (p, png) => fs.writeFileSync(p, PNG.sync.write(png));
// Bounding box of differing pixels using a diff mask (alpha>0 where changed).
function changedBBox(before, after, w, h) {
const mask = new PNG({ width: w, height: h });
pixelmatch(before.data, after.data, mask.data, w, h, { threshold: 0.1, diffMask: true });
let minX = w, minY = h, maxX = -1, maxY = -1, count = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (mask.data[(y * w + x) * 4 + 3] > 0) {
count++;
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
}
}
}
return maxX < 0 ? null : { minX, minY, maxX, maxY, count };
}
const report = [];
for (const name of names) {
const id = name.replace(/\.png$/, "");
const bp = path.join(beforeDir, name), ap = path.join(afterDir, name);
const hasB = fs.existsSync(bp), hasA = fs.existsSync(ap);
if (hasB && !hasA) { report.push({ id, status: "removed", before: bp }); continue; }
if (!hasB && hasA) { report.push({ id, status: "added", after: ap }); continue; }
const before = read(bp), after = read(ap);
if (before.width !== after.width || before.height !== after.height) {
report.push({ id, status: "changed", note: "dimensions differ", before: bp, after: ap });
continue;
}
const w = after.width, h = after.height;
const overlay = new PNG({ width: w, height: h });
const px = pixelmatch(before.data, after.data, overlay.data, w, h, { threshold: 0.1 });
const ratio = px / (w * h);
if (ratio <= THRESHOLD) { report.push({ id, status: "unchanged", ratio: Number(ratio.toFixed(5)), before: bp, after: ap }); continue; }
const box = changedBBox(before, after, w, h);
// Pad + clamp the affected region.
const x = Math.max(0, box.minX - PAD), y = Math.max(0, box.minY - PAD);
const x2 = Math.min(w, box.maxX + 1 + PAD), y2 = Math.min(h, box.maxY + 1 + PAD);
const bw = x2 - x, bh = y2 - y;
const pageWide = (bw * bh) / (w * h) > PAGEWIDE;
const entry = { id, status: "changed", ratio: Number(ratio.toFixed(5)), before: bp, after: ap, pageWide };
if (pageWide) {
// Change spans most of the page - keep the full frame, full overlay.
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, overlay);
entry.diff = dp;
} else {
entry.bbox = { x, y, w: bw, h: bh };
const cb = path.join(outDir, `${id}__before_crop.png`); writePNG(cb, cropPNG(before, x, y, bw, bh));
const ca = path.join(outDir, `${id}__after_crop.png`); writePNG(ca, cropPNG(after, x, y, bw, bh));
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, cropPNG(overlay, x, y, bw, bh));
entry.cropBefore = cb; entry.cropAfter = ca; entry.diff = dp;
}
report.push(entry);
}
fs.writeFileSync(path.join(outDir, "diff-report.json"), JSON.stringify(report, null, 2));
const changed = report.filter((r) => r.status !== "unchanged");
console.log(`diffed ${report.length} view(s): ${changed.length} changed/added/removed, ${report.length - changed.length} unchanged`);
for (const r of changed) {
const tail = r.status !== "changed" ? ""
: r.pageWide ? " (page-wide → full frame)"
: ` (${(r.ratio * 100).toFixed(2)}%, cropped to ${r.bbox.w}×${r.bbox.h})`;
console.log(` ${r.status.padEnd(9)} ${r.id}${tail}${r.note ? " - " + r.note : ""}`);
}
@@ -0,0 +1,48 @@
"""Build EXAMPLE.html from montage-template.html using REAL files-page shots as
stand-in before/after pairs (layout demo, not an actual PR diff). Inlines PNGs as
data URIs so the HTML is portable. Run: python make_example.py"""
import base64
import json
import pathlib
import re
HERE = pathlib.Path(__file__).parent
SHOTS = pathlib.Path(
r"C:\Users\systo\git\Stirling-PDFNew\.claude\worktrees\kind-faraday-522a30"
r"\frontend\editor\screenshots\files-page"
)
def uri(fname):
p = SHOTS / fname
return "data:image/png;base64," + base64.b64encode(p.read_bytes()).decode() if p.exists() else None
data = {
"pr": "DEMO",
"title": "EXAMPLE — before/after montage (layout demo, real Files-page shots; not a real PR diff)",
"base": "main", "head": "demo-branch",
"cropSelector": "[data-sidebar=\"tool-panel\"] (real runs crop to the side; these demo shots are full-page)",
"tabs": [
{"id": "files", "title": "Files page", "ctx": "Each row = one flow state; left = base branch, right = this PR.",
"states": [
{"name": "Empty folder", "before": uri("01_empty_state_ctas.png"), "after": uri("02_empty_state_storage_off.png")},
{"name": "Files + details panel", "before": uri("03_subtoolbar_with_files.png"), "after": uri("06_details_panel_save_to_server.png")},
{"name": "Delete folder confirm", "before": None, "after": uri("19_delete_folder_dialog.png"), "note": "New in this PR"},
]},
{"id": "move", "title": "Move-to-folder dialog",
"states": [
{"name": "Dialog opened", "before": uri("07_move_dialog_collapsed.png"), "after": uri("08_move_dialog_create_folder_expanded.png")},
{"name": "After folder created", "before": None, "after": uri("08b_move_dialog_after_create_folder.png"), "note": "New flow"},
]},
],
}
tpl = (HERE / "montage-template.html").read_text(encoding="utf-8")
out = re.sub(
r"/\*__DATA__\*/.*?/\*__END__\*/",
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
tpl, count=1, flags=re.S,
)
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
print("wrote", HERE / "EXAMPLE.html", "(", (HERE / "EXAMPLE.html").stat().st_size // 1024, "KB )")
@@ -0,0 +1,106 @@
<!doctype html>
<!--
Before/After montage for a PR description. The ui-before-after skill replaces
the JSON in the window.__BA__ data block below with the captured manifest, then
screenshots each .tab-section (id="section-<tabId>") into a PNG to drag into the
PR description. Self-contained; images may be relative paths or data URIs.
Data shape:
{
"pr":"6552","title":"...","base":"main","head":"feat/x",
"cropSelector":"[data-sidebar=\"tool-panel\"]",
"tabs":[
{ "id":"sign","title":"Sign tool","states":[
{"name":"Initial","before":"before/sign__initial.png","after":"after/sign__initial.png"},
{"name":"Cert selected","before":null,"after":"after/sign__cert.png","note":"New in this PR"}
]}
]
}
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Before / After</title>
<style>
:root { --bg:#ffffff; --ink:#0b0c0e; --muted:#6b7280; --line:#e5e7eb;
--before:#6b7280; --after:#1f883d; --frame:#f3f4f6; --note:#b45309; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink);
font:14px/1.5 -apple-system,"Segoe UI",Roboto,system-ui,sans-serif; }
.wrap { max-width:1100px; margin:0 auto; padding:24px; }
.doc-head { margin-bottom:8px; }
.doc-head h1 { font-size:18px; margin:0 0 2px; }
.doc-head .sub { color:var(--muted); font-size:12.5px; }
.legend { display:flex; gap:14px; align-items:center; margin:10px 0 4px; font-size:12px; color:var(--muted); }
.chip { font-size:10px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
padding:2px 8px; border-radius:999px; color:#fff; }
.chip.before { background:var(--before); } .chip.after { background:var(--after); }
.tab-section { border:1px solid var(--line); border-radius:14px; padding:18px 18px 8px;
margin:18px 0; background:var(--bg); }
.tab-section > h2 { font-size:16px; margin:0 0 2px; }
.tab-section > .ctx { color:var(--muted); font-size:12px; margin-bottom:14px; }
.state { margin-bottom:18px; }
.state .name { font-weight:600; font-size:13.5px; margin-bottom:8px; display:flex; gap:8px; align-items:center; }
.state .name .note { font-weight:500; color:var(--note); font-size:12px; }
.pair { display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:start; }
.cell { border:1px solid var(--line); border-radius:10px; overflow:hidden; background:var(--frame); }
.cell .cap { display:flex; align-items:center; gap:8px; padding:7px 10px; border-bottom:1px solid var(--line);
background:var(--bg); }
.cell .cap .meta { color:var(--muted); font-size:11px; }
.cell img { display:block; width:100%; height:auto; background:#fff; }
.cell.empty .ph { display:flex; align-items:center; justify-content:center; height:160px; color:var(--muted);
font-size:12.5px; text-align:center; padding:0 16px; }
.single .pair { grid-template-columns:1fr; }
.empty-doc { color:var(--muted); padding:40px; text-align:center; }
@media (max-width:760px){ .pair{ grid-template-columns:1fr; } }
</style>
</head>
<body>
<div class="wrap" id="root"></div>
<script id="data">
window.__BA__ = /*__DATA__*/{"pr":"","title":"No data","base":"","head":"","cropSelector":"","tabs":[]}/*__END__*/;
</script>
<script>
(function(){
var D = window.__BA__ || { tabs: [] };
var root = document.getElementById("root");
function el(html){ var t=document.createElement("template"); t.innerHTML=html.trim(); return t.content.firstChild; }
function esc(s){ return (s==null?"":String(s)).replace(/[&<>]/g, function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;"}[c];}); }
function cell(kind, src){
if (src) {
return '<div class="cell"><div class="cap"><span class="chip '+kind+'">'+kind+'</span></div>'+
'<img src="'+esc(src)+'" alt="'+kind+'"/></div>';
}
return '<div class="cell empty"><div class="cap"><span class="chip '+kind+'">'+kind+'</span>'+
'<span class="meta">not present</span></div><div class="ph">No '+kind+' screenshot for this state</div></div>';
}
var head = '<div class="doc-head"><h1>'+esc(D.title || ("PR #"+D.pr))+'</h1>'+
'<div class="sub">Before / after &nbsp;·&nbsp; base <code>'+esc(D.base)+'</code> → head <code>'+esc(D.head)+'</code>'+
(D.cropSelector ? ' &nbsp;·&nbsp; cropped to <code>'+esc(D.cropSelector)+'</code>' : '')+'</div></div>'+
'<div class="legend"><span class="chip before">Before</span> base branch'+
'<span class="chip after">After</span> this PR</div>';
root.appendChild(el('<div>'+head+'</div>'));
if (!D.tabs || !D.tabs.length){ root.appendChild(el('<div class="empty-doc">No tabs captured yet.</div>')); return; }
D.tabs.forEach(function(tab){
var states = (tab.states||[]).map(function(s){
var onlyOne = (!s.before || !s.after);
return '<div class="state'+(onlyOne?' ':'')+'">'+
'<div class="name">'+esc(s.name)+(s.note?'<span class="note">'+esc(s.note)+'</span>':'')+'</div>'+
'<div class="pair">'+cell("before", s.before)+cell("after", s.after)+'</div></div>';
}).join("");
var sec = '<section class="tab-section" id="section-'+esc(tab.id)+'">'+
'<h2>'+esc(tab.title)+'</h2>'+
(tab.ctx?'<div class="ctx">'+esc(tab.ctx)+'</div>':'')+
states+'</section>';
root.appendChild(el(sec));
});
})();
</script>
</body>
</html>
@@ -0,0 +1,25 @@
// Render each .tab-section of a montage HTML into its own PNG (the PR-ready image).
// Run from frontend/editor (so @playwright/test resolves):
// node <skill>/shoot-sections.mjs <montage.html> <outDir>
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createRequire } from "node:module";
const require = createRequire(path.join(process.cwd(), "noop.js"));
const { chromium } = require("@playwright/test");
const htmlPath = path.resolve(process.argv[2]);
const outDir = path.resolve(process.argv[3] || path.dirname(htmlPath));
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 1200 }, deviceScaleFactor: 2 });
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
await page.waitForTimeout(250); // let images/fonts paint
const ids = await page.$$eval(".tab-section", (els) => els.map((e) => e.id));
if (!ids.length) { console.error("no .tab-section found"); process.exit(1); }
for (const id of ids) {
const name = id.replace(/^section-/, "");
await page.locator("#" + id).screenshot({ path: path.join(outDir, `montage_${name}.png`) });
console.log("wrote montage_" + name + ".png");
}
await browser.close();
+120
View File
@@ -0,0 +1,120 @@
---
name: ui-walkthrough
description: >-
Full UI investigation of the current branch's feature. Enumerates every view
and state (empty, populated, loading, error, each dialog/menu/panel, responsive
breakpoints, light + dark + RTL), captures them with the stubbed Playwright
harness, assembles a single-image HTML walkthrough with a global light/dark
toggle slider, then runs two review passes: visual/consistency (alignment,
spacing, professionalism, dark/light parity, contrast, truncation) and
UX/ease-of-use (flow, discoverability, affordances, empty/error states,
expectations). Use when asked for a UI walkthrough, screenshot review, design
or QA pass, "find anywhere to make it easier/better for users", or before
merging frontend work. Pass --fix to auto-apply safe frontend fixes and
re-capture; --theme to limit themes; --no-rtl to skip RTL.
argument-hint: "[feature/area] [--fix] [--theme light|dark|both] [--no-rtl] [--breakpoints]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# UI Walkthrough
Produce a reviewable HTML walkthrough of a feature's UI in every state and theme,
then critique it. Optionally auto-fix and re-capture.
`$ARGUMENTS` may name the feature/area to focus on. If empty, scope from the
current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
`--no-rtl`, `--breakpoints` (also capture phone/narrow widths).
## What this repo gives you (use it, don't reinvent)
- **Stubbed Playwright project** = backend-free screenshots via `page.route()` mocks.
Reference implementation: `frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts`.
It already shows the light / **dark** / **RTL** passes, JWT seeding, IndexedDB
seeding, and dumping PNGs to a `screenshots/<area>/` folder. Copy its shape.
- Helpers: `frontend/editor/src/core/tests/helpers/ui-helpers.ts`
(`uploadFiles`, `openSettings`, `waitForModalOpen`, `dismissTourTooltip`, …)
and the `stub-test-base` fixtures (`autoGoto`, `seedJwt`, `viewport`).
- Config: `frontend/editor/playwright.config.ts` (run from `frontend/editor/`).
- Report template: [report-template.html](report-template.html) - self-contained,
one big image at a time, a global light/dark slider that flips every shot,
thumbnail rail, prev/next + arrow keys, and a Findings tab.
## Process
### 1. Scope the feature
- If `$ARGUMENTS` is empty: `git diff --name-only main...HEAD` and read the PR/commits.
Identify changed pages, tools (`core/components/tools/<tool>` or `core/tools/<tool>`),
dialogs, panels, and routes.
- Enumerate **every view and state** to capture, e.g.:
empty / populated / loading / error / disabled; each dialog, menu, popover, tooltip;
each tab or step; selection + multi-select; success/result panel; and (if relevant)
permission/role variants. Write the list down before capturing - it's the report's spine.
### 2. Prepare the harness (worktree-safe)
Worktrees have no `node_modules` and no generated icons. From repo root:
```
cd frontend && npm ci # or junction main's node_modules (see memory)
cd frontend/editor && node scripts/generate-icons.js
```
Kill any stale dev server first (it serves old modules):
`Get-NetTCPConnection -LocalPort 5173 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`
### 3. Write the capture spec
Create `frontend/editor/src/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
- stub the APIs it needs, drive the UI to that state, wait on a real locator
(not a fixed sleep), `await settle(page)` for Mantine portals, then
`page.screenshot({ path: shotPath("NN_name_<theme>") })`.
- Capture each view in **light and dark** (and RTL unless `--no-rtl`). Reuse the
`enableDarkMode` / `enableRtl` init-script pattern from the reference spec
(`localStorage["mantine-color-scheme"]="dark"` + `emulateMedia({colorScheme:"dark"})`).
- Name shots `NN_<view>_<theme>.png` so light/dark pair up by suffix.
- Prefer **stable test-ids** over translated accessible names (RTL/i18n breaks text locators).
Run it: `cd frontend/editor && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
Add `--project=stubbed-firefox`/`-webkit` only if cross-browser layout matters.
### 4. Build the report
- Copy `report-template.html` to `screenshots/<feature>/walkthrough.html` (so the
relative `screenshots/...` image paths resolve, or rewrite paths to sit beside it).
- Build the manifest and inject it: replace the JSON between the
`/*__DATA__*/``/*__END__*/` markers with one `views[]` entry per view
(`{id,title,light,dark,viewport,notes}`) and an empty `findings` object you'll
fill in step 5. Keep `light`/`dark` as relative paths.
- The toggle slider answers the "one big image + flip light/dark for all" request:
it shows a single large screenshot, and switching the slider re-themes every view.
### 5. Review pass 1 - visual & consistency
Open each screenshot (Read the PNG) and judge against the others:
alignment & spacing rhythm, control placement, button hierarchy, typography,
**light/dark parity** (contrast, invisible borders, washed-out text, wrong tokens),
truncation/overflow, RTL mirroring, focus states, icon consistency, professional polish.
Record each issue as a finding `{severity:high|med|low, view, title, detail, fix}`.
### 6. Review pass 2 - UX & ease of use
Walk the flow as a first-time user: discoverability, number of steps, affordance
clarity, empty-state guidance, error recovery, destructive-action confirmation,
defaults, loading feedback, mobile reachability, accessible names, and whether the
UI matches user expectations for this kind of tool. Record findings the same way.
Write both finding lists into the report's `findings.visual` / `findings.ux`,
and add short per-view `notes`. Re-inject the manifest.
### 7. If `--fix`
Only safe, self-contained frontend fixes (spacing, alignment, tokens, missing
dark-mode colors, labels, aria, obvious copy). For each: edit the component/CSS,
mark the finding `fixed:true` with what changed, then **re-run the spec** to
re-capture the affected shots and regenerate the report. Run `task frontend:check`.
Leave anything risky or ambiguous as a finding, not a change.
### 8. Deliver
Tell the user the report path and give a tight chat summary: N views ×
themes captured, top findings by severity, and (if `--fix`) what changed.
Optionally `SendUserFile` the `walkthrough.html`.
## Gotchas
- Stale `:5173` server serves old bundles - kill it before capturing (see step 2).
- Missing `material-symbols-icons.json` → blank app → every shot times out. Run
`generate-icons.js` first.
- `await settle(page)` before shots or portals/transitions tear mid-capture.
- Don't commit the generated `screenshots/` or the throwaway spec unless asked.
@@ -0,0 +1,116 @@
"""Build a self-contained EXAMPLE.html from report-template.html with mock
light/dark screenshots, so the viewer + global theme slider can be demoed
without a real capture run. Run: python make_example.py"""
import base64
import json
import pathlib
import re
HERE = pathlib.Path(__file__).parent
def svg(bg, fg, panel, accent, muted, label, kind):
"""A simple fake 'screen' SVG: title bar, sidebar, content varies by kind."""
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">',
f'<rect width="1600" height="900" fill="{bg}"/>',
# top bar
f'<rect width="1600" height="64" fill="{panel}"/>',
f'<circle cx="40" cy="32" r="12" fill="{accent}"/>',
f'<rect x="64" y="24" width="160" height="16" rx="6" fill="{muted}"/>',
f'<rect x="1430" y="20" width="130" height="24" rx="12" fill="{accent}"/>',
# left sidebar
f'<rect x="0" y="64" width="220" height="836" fill="{panel}"/>',
]
for i in range(6):
y = 100 + i * 56
parts.append(f'<rect x="24" y="{y}" width="172" height="32" rx="8" fill="{bg}"/>')
if kind == "empty":
parts += [
f'<rect x="700" y="360" width="200" height="120" rx="16" fill="none" stroke="{muted}" stroke-width="3" stroke-dasharray="10 8"/>',
f'<rect x="690" y="510" width="220" height="44" rx="10" fill="{accent}"/>',
f'<text x="800" y="600" fill="{muted}" font-family="sans-serif" font-size="26" text-anchor="middle">{label}</text>',
]
elif kind == "form":
for i in range(4):
y = 140 + i * 90
parts.append(f'<rect x="280" y="{y}" width="160" height="16" rx="6" fill="{muted}"/>')
parts.append(f'<rect x="280" y="{y+26}" width="900" height="44" rx="8" fill="{panel}" stroke="{muted}" stroke-width="1"/>')
parts.append(f'<rect x="280" y="560" width="200" height="50" rx="10" fill="{accent}"/>')
parts.append(f'<text x="800" y="850" fill="{muted}" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>')
else: # dialog
parts += [
f'<rect width="1600" height="900" fill="{fg}" opacity="0.45"/>',
f'<rect x="520" y="280" width="560" height="360" rx="18" fill="{panel}"/>',
f'<rect x="556" y="320" width="280" height="22" rx="8" fill="{fg}"/>',
f'<rect x="556" y="372" width="488" height="14" rx="6" fill="{muted}"/>',
f'<rect x="556" y="398" width="420" height="14" rx="6" fill="{muted}"/>',
f'<rect x="820" y="560" width="110" height="44" rx="9" fill="{bg}" stroke="{muted}"/>',
f'<rect x="946" y="560" width="98" height="44" rx="9" fill="{accent}"/>',
f'<text x="800" y="700" fill="#fff" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>',
]
parts.append("</svg>")
return "".join(parts)
def data_uri(s):
return "data:image/svg+xml;base64," + base64.b64encode(s.encode()).decode()
LIGHT = dict(bg="#ffffff", fg="#111418", panel="#f1f3f6", accent="#2f6fed", muted="#c2c8d0")
DARK = dict(bg="#16181c", fg="#000000", panel="#1f232a", accent="#5b8cff", muted="#3a414b")
def pair(kind, label):
return (
data_uri(svg(LIGHT["bg"], LIGHT["fg"], LIGHT["panel"], LIGHT["accent"], LIGHT["muted"], label, kind)),
data_uri(svg(DARK["bg"], DARK["fg"], DARK["panel"], DARK["accent"], DARK["muted"], label, kind)),
)
views = []
for idx, (kind, title, label) in enumerate([
("empty", "Empty state", "Drop a PDF to start"),
("form", "Tool options panel", "Compress options"),
("dialog", "Confirm dialog", "Replace original file?"),
], start=1):
light, dark = pair(kind, label)
views.append({
"id": f"{idx:02d}_{kind}",
"title": title,
"light": light,
"dark": dark,
"viewport": "1600x900",
"notes": ["This is mock data to demo the viewer."],
})
data = {
"feature": "EXAMPLE - Compress PDF (mock data)",
"branch": "demo",
"generated": "example",
"views": views,
"findings": {
"visual": [
{"severity": "high", "view": "03_dialog", "title": "Dialog buttons too close",
"detail": "Cancel/Confirm have only 8px gap; easy to misclick.",
"fix": "Increase gap to var(--mantine-spacing-md)."},
{"severity": "low", "view": "02_form", "title": "Field labels low contrast in dark mode",
"detail": "Muted token fails WCAG AA on the dark panel.",
"fix": "Use --mantine-color-dimmed instead of a hard-coded grey."},
],
"ux": [
{"severity": "med", "view": "01_empty", "title": "Primary CTA below the dropzone",
"detail": "Users expect the action button adjacent to the dropzone.",
"fix": "Move the button directly under the dashed zone."},
],
},
}
tpl = (HERE / "report-template.html").read_text(encoding="utf-8")
out = re.sub(
r"/\*__DATA__\*/.*?/\*__END__\*/",
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
tpl, count=1, flags=re.S,
)
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
print("wrote", (HERE / "EXAMPLE.html"))
@@ -0,0 +1,298 @@
<!doctype html>
<!--
UI Walkthrough report template (self-contained, works from file://).
The ui-walkthrough skill replaces the JSON in the window.__WALKTHROUGH__ data
block below with the captured manifest. Do not add external CDN deps - it must open offline.
Data shape:
{
"feature": "Compress PDF tool",
"branch": "claude/...",
"generated": "2026-06-21",
"views": [
{ "id": "01_empty", "title": "Empty state",
"light": "screenshots/compress/01_empty_light.png",
"dark": "screenshots/compress/01_empty_dark.png",
"viewport": "1600x900",
"notes": ["Heading is centered", "Primary CTA below the fold on mobile"] }
],
"findings": {
"visual": [ { "severity":"high", "view":"01_empty", "title":"...", "detail":"...", "fix":"..." } ],
"ux": [ { "severity":"med", "view":"03_dialog", "title":"...", "detail":"...", "fix":"..." } ]
}
}
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UI Walkthrough</title>
<style>
:root {
--bg: #f6f7f9; --panel: #ffffff; --panel-2: #f0f2f5; --text: #1a1b1e;
--muted: #6b7280; --border: #e2e5ea; --accent: #2f6fed; --accent-weak: #e8f0fe;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);
--hi: #d92d20; --med: #d98e00; --low: #2f6fed; --stage: #0b0c0e;
}
html[data-theme="dark"] {
--bg: #0d0e10; --panel: #16181c; --panel-2: #1d2024; --text: #e6e8eb;
--muted: #9aa3ad; --border: #2a2e35; --accent: #5b8cff; --accent-weak: #1a2336;
--shadow: 0 1px 3px rgba(0,0,0,.5), 0 8px 24px rgba(0,0,0,.4); --stage: #000;
}
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.5 -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
background: var(--bg); color: var(--text); }
header { display: flex; align-items: center; gap: 16px; padding: 12px 20px;
background: var(--panel); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 5; }
header h1 { font-size: 15px; margin: 0; font-weight: 650; }
header .sub { color: var(--muted); font-size: 12px; }
.spacer { flex: 1; }
.counter { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
.tabs { display: flex; gap: 4px; }
.tab { border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; }
.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Light/Dark slider */
.theme-toggle { display: flex; align-items: center; gap: 9px; user-select: none; }
.theme-toggle .lbl { font-size: 12px; color: var(--muted); }
.theme-toggle .lbl.on { color: var(--text); font-weight: 600; }
.switch { position: relative; width: 52px; height: 28px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; inset: 0; cursor: pointer; background: var(--panel-2);
border: 1px solid var(--border); border-radius: 999px; transition: .2s; }
.slider:before { content: ""; position: absolute; height: 20px; width: 20px; left: 3px; top: 3px;
background: #fbbf24; border-radius: 50%; transition: .2s; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
.switch input:checked + .slider { background: var(--accent); }
.switch input:checked + .slider:before { transform: translateX(24px); background: #c7d2fe; }
main { display: grid; grid-template-columns: 240px 1fr; height: calc(100vh - 53px); }
.rail { border-right: 1px solid var(--border); overflow-y: auto; background: var(--panel); padding: 8px; }
.rail .group-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
color: var(--muted); padding: 10px 8px 4px; }
.thumb { display: flex; gap: 9px; align-items: center; padding: 7px; border-radius: 8px;
cursor: pointer; border: 1px solid transparent; }
.thumb:hover { background: var(--panel-2); }
.thumb.active { background: var(--accent-weak); border-color: var(--accent); }
.thumb img { width: 64px; height: 40px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); background: var(--stage); }
.thumb .t { font-size: 12.5px; line-height: 1.3; }
.thumb .badge { font-size: 10px; color: var(--muted); }
.thumb .dot { width: 7px; height: 7px; border-radius: 50%; margin-left: auto; flex: none; }
.stagewrap { display: flex; flex-direction: column; min-width: 0; }
.stage { flex: 1; display: flex; align-items: center; justify-content: center; padding: 22px;
background: var(--stage); position: relative; min-height: 0; }
.stage img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px;
box-shadow: 0 4px 30px rgba(0,0,0,.4); background: #fff; }
html[data-theme="dark"] .stage img { background: #16181c; }
.nav-btn { position: absolute; top: 50%; transform: translateY(-50%); width: 42px; height: 42px;
border-radius: 50%; border: 1px solid var(--border); background: var(--panel);
color: var(--text); cursor: pointer; font-size: 18px; opacity: .85; }
.nav-btn:hover { opacity: 1; } .nav-btn.prev { left: 16px; } .nav-btn.next { right: 16px; }
.nav-btn:disabled { opacity: .25; cursor: default; }
.missing { color: var(--muted); font-size: 13px; text-align: center; }
.detail { border-top: 1px solid var(--border); background: var(--panel); padding: 14px 20px;
max-height: 38vh; overflow-y: auto; }
.detail h2 { margin: 0 0 4px; font-size: 15px; }
.detail .meta { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
.notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
.notes li { display: flex; gap: 8px; align-items: flex-start; }
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 7px; border-radius: 999px;
color: #fff; flex: none; margin-top: 1px; }
.sev.high { background: var(--hi); } .sev.med { background: var(--med); } .sev.low { background: var(--low); }
.finding .fix { color: var(--muted); font-size: 12.5px; }
.finding .fix b { color: var(--text); font-weight: 600; }
/* Summary tab */
.summary { padding: 20px 28px; overflow-y: auto; }
.summary h2 { font-size: 16px; margin: 22px 0 8px; }
.summary .empty { color: var(--muted); }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 14px; margin-bottom: 8px; box-shadow: var(--shadow); }
.card .head { display: flex; gap: 8px; align-items: center; }
.card a { color: var(--accent); text-decoration: none; cursor: pointer; }
.hide { display: none !important; }
kbd { font: 11px ui-monospace, monospace; background: var(--panel-2); border: 1px solid var(--border);
border-radius: 4px; padding: 1px 5px; }
</style>
</head>
<body>
<header>
<div>
<h1 id="feature-title">UI Walkthrough</h1>
<div class="sub" id="feature-sub"></div>
</div>
<div class="spacer"></div>
<div class="tabs">
<button class="tab active" data-tab="viewer">Walkthrough</button>
<button class="tab" data-tab="summary">Findings</button>
</div>
<div class="counter" id="counter"></div>
<label class="theme-toggle" title="Toggle light / dark for every screenshot">
<span class="lbl" id="lbl-light">Light</span>
<span class="switch"><input type="checkbox" id="theme-switch" /><span class="slider"></span></span>
<span class="lbl" id="lbl-dark">Dark</span>
</label>
</header>
<main id="viewer-pane">
<aside class="rail" id="rail"></aside>
<section class="stagewrap">
<div class="stage">
<button class="nav-btn prev" id="prev" aria-label="Previous">&#8249;</button>
<img id="stage-img" alt="" />
<div class="missing hide" id="missing"></div>
<button class="nav-btn next" id="next" aria-label="Next">&#8250;</button>
</div>
<div class="detail">
<h2 id="view-title"></h2>
<div class="meta" id="view-meta"></div>
<ul class="notes" id="view-notes"></ul>
</div>
</section>
</main>
<section class="summary hide" id="summary-pane"></section>
<script id="data">
window.__WALKTHROUGH__ = /*__DATA__*/{"feature":"No data","branch":"","generated":"","views":[],"findings":{"visual":[],"ux":[]}}/*__END__*/;
</script>
<script>
(function () {
var D = window.__WALKTHROUGH__ || { views: [], findings: { visual: [], ux: [] } };
var views = D.views || [];
var state = { i: 0, theme: localStorage.getItem("ui-wt-theme") || "light", tab: "viewer" };
var $ = function (id) { return document.getElementById(id); };
function sevClass(s) { return s === "high" ? "high" : s === "med" || s === "medium" ? "med" : "low"; }
function applyChrome() {
document.documentElement.setAttribute("data-theme", state.theme);
$("theme-switch").checked = state.theme === "dark";
$("lbl-light").classList.toggle("on", state.theme === "light");
$("lbl-dark").classList.toggle("on", state.theme === "dark");
}
function srcFor(v) { return state.theme === "dark" ? (v.dark || v.light) : (v.light || v.dark); }
function findingsForView(id) {
var all = (D.findings && D.findings.visual || []).concat(D.findings && D.findings.ux || []);
return all.filter(function (f) { return f.view === id; });
}
function renderRail() {
var rail = $("rail");
rail.innerHTML = "";
if (!views.length) { rail.innerHTML = '<div class="group-label">No views captured</div>'; return; }
views.forEach(function (v, idx) {
var fs = findingsForView(v.id);
var worst = fs.some(function (f){return sevClass(f.severity)==="high";}) ? "var(--hi)"
: fs.some(function (f){return sevClass(f.severity)==="med";}) ? "var(--med)"
: fs.length ? "var(--low)" : "transparent";
var el = document.createElement("div");
el.className = "thumb" + (idx === state.i ? " active" : "");
el.innerHTML = '<img src="' + srcFor(v) + '" alt="" />' +
'<div><div class="t">' + (v.title || v.id) + '</div>' +
'<div class="badge">' + (v.viewport || "") + '</div></div>' +
'<span class="dot" style="background:' + worst + '"></span>';
el.onclick = function () { state.i = idx; render(); };
rail.appendChild(el);
});
}
function render() {
applyChrome();
if (!views.length) {
$("missing").classList.remove("hide"); $("stage-img").classList.add("hide");
$("missing").textContent = "No screenshots in this report yet.";
$("counter").textContent = ""; return;
}
var v = views[state.i];
var src = srcFor(v);
var img = $("stage-img");
if (src) {
img.classList.remove("hide"); $("missing").classList.add("hide");
img.src = src; img.alt = v.title || v.id;
} else {
img.classList.add("hide"); $("missing").classList.remove("hide");
$("missing").textContent = "No " + state.theme + " screenshot for this view.";
}
$("counter").textContent = (state.i + 1) + " / " + views.length;
$("view-title").textContent = v.title || v.id;
$("view-meta").textContent = [v.viewport, state.theme + " mode"].filter(Boolean).join(" · ");
var notes = $("view-notes"); notes.innerHTML = "";
var fs = findingsForView(v.id);
(v.notes || []).forEach(function (n) {
var li = document.createElement("li"); li.textContent = "· " + n; notes.appendChild(li);
});
fs.forEach(function (f) {
var li = document.createElement("li"); li.className = "finding";
li.innerHTML = '<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
'<span><b>' + (f.title || "") + '</b> — ' + (f.detail || "") +
(f.fix ? ' <span class="fix"><b>Fix:</b> ' + f.fix + '</span>' : '') + '</span>';
notes.appendChild(li);
});
$("prev").disabled = state.i === 0;
$("next").disabled = state.i === views.length - 1;
renderRail();
}
function renderSummary() {
var pane = $("summary-pane");
function block(title, arr) {
var h = '<h2>' + title + ' (' + arr.length + ')</h2>';
if (!arr.length) return h + '<div class="empty">None found.</div>';
return h + arr.map(function (f) {
return '<div class="card"><div class="head">' +
'<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
'<b>' + (f.title || "") + '</b>' +
(f.view ? ' <a data-jump="' + f.view + '">' + f.view + '</a>' : '') + '</div>' +
'<div style="margin-top:6px">' + (f.detail || "") + '</div>' +
(f.fix ? '<div class="finding" style="margin-top:6px"><span class="fix"><b>Fix:</b> ' + f.fix + '</span></div>' : '') +
'</div>';
}).join("");
}
pane.innerHTML = block("Visual & consistency", (D.findings && D.findings.visual) || []) +
block("UX & ease of use", (D.findings && D.findings.ux) || []);
pane.querySelectorAll("[data-jump]").forEach(function (a) {
a.onclick = function () {
var id = a.getAttribute("data-jump");
var idx = views.findIndex(function (v) { return v.id === id; });
if (idx >= 0) { state.i = idx; setTab("viewer"); }
};
});
}
function setTab(t) {
state.tab = t;
document.querySelectorAll(".tab").forEach(function (b) { b.classList.toggle("active", b.dataset.tab === t); });
$("viewer-pane").classList.toggle("hide", t !== "viewer");
$("summary-pane").classList.toggle("hide", t !== "summary");
if (t === "viewer") $("viewer-pane").style.display = "grid";
if (t === "summary") renderSummary();
}
// wiring
$("feature-title").textContent = D.feature || "UI Walkthrough";
$("feature-sub").textContent = [D.branch, D.generated].filter(Boolean).join(" · ");
$("theme-switch").onchange = function () {
state.theme = this.checked ? "dark" : "light";
localStorage.setItem("ui-wt-theme", state.theme);
render();
};
$("prev").onclick = function () { if (state.i > 0) { state.i--; render(); } };
$("next").onclick = function () { if (state.i < views.length - 1) { state.i++; render(); } };
document.addEventListener("keydown", function (e) {
if (state.tab !== "viewer") return;
if (e.key === "ArrowLeft") $("prev").click();
if (e.key === "ArrowRight") $("next").click();
if (e.key.toLowerCase() === "t") $("theme-switch").click();
});
document.querySelectorAll(".tab").forEach(function (b) { b.onclick = function () { setTab(b.dataset.tab); }; });
render();
})();
</script>
</body>
</html>
+4 -4
View File
@@ -20,8 +20,8 @@ set -e
# - To build the project, use:
# ./gradlew build
#
# - For running pre-commit hooks (if configured), use:
# pre-commit run --all-files
# - To run the lint/format/secret checks, use:
# task pre-commit
#
# Make sure you are in the project root directory after this script executes.
# =============================================================================
@@ -70,6 +70,6 @@ echo ""
echo " To build the project: "
echo -e "\e[34m gradle build\e[0m"
echo ""
echo " To run pre-commit hooks (if configured):"
echo -e "\e[34m pre-commit run --all-files -c .pre-commit-config.yaml\e[0m"
echo " To run the lint/format/secret checks:"
echo -e "\e[34m task pre-commit\e[0m"
echo "=================================================================="
-1
View File
@@ -27,7 +27,6 @@ node_modules/
**/node_modules/
frontend/node_modules/
frontend/editor/dist/
frontend/dist-portal/
frontend/editor/playwright-report/
.npm/
.yarn/
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.12.0
pkgver=2.14.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.12.0
pkgver=2.14.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+52 -8
View File
@@ -1,16 +1,35 @@
# CI routing infra. Editing the top-level router (build.yml) or this filter
# config re-runs every area's jobs, so every job-gating filter below includes
# *ci. That makes a change to how jobs are dispatched actually exercise those
# jobs (self-testing), instead of a router edit only matching the project filter.
ci: &ci
- .github/workflows/build.yml
- .github/config/.files.yaml
build: &build
- *ci
- build.gradle
- app/(common|core|proprietary)/build.gradle
- gradle/spotless.gradle
- app/(common|core|proprietary|saas)/build.gradle
- Taskfile.yml
- .taskfiles/backend.yml
- .github/workflows/check-licence.yml
openapi: &openapi
- *ci
- *build
- app/(common|core|proprietary)/src/main/java/**
- app/(common|core|proprietary|saas)/src/main/java/**
- .github/workflows/check-openapi.yml
docker-base: &docker-base
- docker/base/Dockerfile
# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch
# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a
# Dockerfile itself changes, not on every code PR.
dockerfiles: &dockerfiles
- docker/**/Dockerfile*
docker: &docker
- docker/embedded/Dockerfile
- docker/embedded/Dockerfile.fat
@@ -23,13 +42,11 @@ docker: &docker
- *docker-base
project: &project
- app/(common|core|proprietary)/src/(main|test)/java/**
- *ci
- app/(common|core|proprietary|saas)/src/(main|test)/java/**
- *build
- "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
- "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
- exampleYmlFiles/**
- gradle/**
- libs/**
- "testing/**/!(requirements*.txt|requirements*.in)*"
- *docker
- *docker-base
- gradle.properties
@@ -45,8 +62,11 @@ project: &project
- .taskfiles/docker.yml
- scripts/db-migration/**
- .github/workflows/db-migration-test.yml
- .github/workflows/docker-compose-tests.yml
- .github/workflows/test-build-docker.yml
frontend: &frontend
- *ci
- frontend/**
- .github/workflows/testdriver.yml
- testing/**
@@ -63,10 +83,15 @@ frontend: &frontend
- Taskfile.yml
- .taskfiles/frontend.yml
- .taskfiles/e2e.yml
- .github/workflows/frontend-validation.yml
- .github/workflows/frontend-a11y.yml
- .github/workflows/e2e-stubbed.yml
- .github/workflows/e2e-live.yml
# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
# job on changes to any of these.
tauri: &tauri
- *ci
- frontend/editor/src-tauri/**
- frontend/editor/src/desktop/**
- frontend/editor/tsconfig.desktop.vite.json
@@ -81,12 +106,29 @@ tauri: &tauri
# the engine validation job on changes to engine sources or to the Java
# tool surfaces it generates models from.
engine: &engine
- *ci
- engine/**
- app/(common|core|proprietary)/src/main/java/**
- app/(common|core|proprietary|saas)/src/main/java/**
- .github/workflows/ai-engine.yml
- Taskfile.yml
- .taskfiles/engine.yml
# Files that can make the committed generated API models (frontend tool API
# types + engine tool models) go stale: the Java tool surfaces they derive from,
# the generators, the generated files themselves (to catch a hand-edit), and the
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
generated-models: &generated-models
- *ci
- *openapi
- frontend/editor/scripts/generate-tool-api-types.mts
- frontend/editor/src/core/types/toolApiTypes.ts
- engine/scripts/generate_tool_models.py
- engine/src/stirling/models/tool_models.py
- .taskfiles/frontend.yml
- .taskfiles/engine.yml
- .github/workflows/check-generated-models.yml
licenses-frontend: &licenses-frontend
- ".github/workflows/frontend-backend-licenses-update.yml"
- "frontend/package.json"
@@ -100,6 +142,7 @@ licenses-backend: &licenses-backend
# Files that can affect premium / enterprise behaviour. Gate the enterprise
# Playwright job on changes to any of these on PRs.
proprietary: &proprietary
- *ci
- app/proprietary/**
- frontend/editor/src/proprietary/**
- frontend/editor/src/core/tests/enterprise/**
@@ -114,4 +157,5 @@ proprietary: &proprietary
- configs/settings.yml.template
- build.gradle
- app/proprietary/build.gradle
- gradle/spotless.gradle
- .github/workflows/build-enterprise.yml
+8 -1
View File
@@ -63,6 +63,7 @@ labels:
files:
- 'app/core/src/main/resources/static/.*'
- 'app/proprietary/src/main/resources/static/.*'
- 'app/saas/src/main/resources/static/.*'
- 'frontend/**'
- 'frontend/.*'
- 'frontend/**/.*'
@@ -83,6 +84,7 @@ labels:
- 'app/common/src/main/java/.*.java'
- 'app/proprietary/src/main/java/.*.java'
- 'app/core/src/main/java/.*.java'
- 'app/saas/src/main/java/.*.java'
- label: 'Back End'
files:
@@ -90,6 +92,9 @@ labels:
- 'app/core/src/main/java/stirling/software/SPDF/controller/.*'
- 'app/core/src/main/resources/settings.yml.template'
- 'app/core/src/main/resources/application.properties'
- 'app/proprietary/src/main/resources/application-proprietary.properties'
- 'app/saas/src/main/resources/application-dev.properties'
- 'app/saas/src/main/resources/application-saas.properties'
- 'app/core/src/main/resources/banner.txt'
- 'app/core/src/main/resources/static/python/png_to_webp.py'
- 'app/core/src/main/resources/static/python/split_photos.py'
@@ -153,11 +158,12 @@ labels:
- 'app/common/src/test/.*'
- 'app/proprietary/src/test/.*'
- 'app/core/src/test/.*'
- 'app/saas/src/test/.*'
- 'testing/.*'
- '.github/workflows/scorecards.yml'
- 'exampleYmlFiles/test_cicd.yml'
- label: 'Github'
- label: 'GitHub'
files:
- '.github/.*'
@@ -171,3 +177,4 @@ labels:
- 'app/common/build.gradle'
- 'app/proprietary/build.gradle'
- 'app/core/build.gradle'
- 'app/saas/build.gradle'
+11 -7
View File
@@ -5,6 +5,7 @@
# the GitHub Action https://github.com/marketplace/actions/github-labeler.
- name: "Licenses"
color: "EDEDED"
description: "Issues or pull requests related to licenses"
from_name: "licenses"
- name: "Back End"
color: "20CE6C"
@@ -146,21 +147,21 @@
description: "Changes that do not affect the meaning of the code (formatting, etc.)"
- name: "admin"
color: "195055"
- name: "codex"
color: "ededed"
description: null
- name: "Github"
- name: "GitHub"
color: "0052CC"
- name: "github_actions"
color: "000000"
description: "Pull requests that update GitHub Actions code"
description: "Issues or pull requests related to GitHub configuration and integrations"
from_name: "Github"
- name: "needs-changes"
color: "A65A86"
description: "Pull requests that require changes before they can be merged"
- name: "on-hold"
color: "2526F9"
- name: "python"
color: "2b67c6"
description: "Pull requests that update Python code"
- name: "engine"
color: "2b67c6"
description: "Issues or pull requests related to the engine"
- name: "size:L"
color: "eb9500"
description: "This PR changes 100-499 lines ignoring generated files."
@@ -201,3 +202,6 @@
- name: "license-review-required"
color: "EDEDED"
description: "This PR requires a license review"
- name: "has conflicts"
color: "D93F0B"
description: "Pull request has merge conflicts with the base branch"
+365 -352
View File
@@ -1,101 +1,116 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile --allow-unsafe --generate-hashes --output-file='.github\scripts\requirements_dev.txt' --strip-extras '.github\scripts\requirements_dev.in'
#
# WARNING: pip install will require the following package to be hashed.
# Consider using a hashable URL like https://github.com/jazzband/pip-tools/archive/SOMECOMMIT.zip
# CVE-2025-6176 mitigation: pin brotli to a specific commit
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
# via
# -r .github/scripts/requirements_dev.in
# fonttools
cffi==2.0.0 \
--hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \
--hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \
--hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \
--hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \
--hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \
--hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \
--hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \
--hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \
--hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \
--hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \
--hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \
--hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \
--hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \
--hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \
--hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \
--hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \
--hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \
--hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \
--hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \
--hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \
--hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \
--hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \
--hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \
--hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \
--hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \
--hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \
--hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \
--hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \
--hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \
--hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \
--hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \
--hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \
--hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \
--hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \
--hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \
--hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \
--hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \
--hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \
--hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \
--hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \
--hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \
--hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \
--hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \
--hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \
--hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \
--hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \
--hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \
--hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \
--hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \
--hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \
--hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \
--hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \
--hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \
--hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \
--hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \
--hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \
--hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \
--hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \
--hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \
--hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \
--hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \
--hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \
--hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \
--hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \
--hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \
--hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \
--hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \
--hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \
--hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \
--hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \
--hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \
--hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \
--hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \
--hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \
--hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \
--hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \
--hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \
--hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \
--hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \
--hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \
--hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \
--hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \
--hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \
--hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
cffi==2.1.0 \
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
--hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
--hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
--hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
--hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
--hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
--hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
--hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
--hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
--hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
--hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
--hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
--hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
--hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
--hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
--hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
--hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
--hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
--hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
--hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
--hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
--hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
--hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
--hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
--hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
--hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
--hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
--hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
--hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
--hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
--hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
--hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
--hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
--hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
--hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
--hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
--hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
--hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
--hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
--hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
--hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
--hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
--hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
--hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
--hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
--hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
--hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
--hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
--hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
--hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
--hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
--hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
--hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
--hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
--hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
--hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
--hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
--hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
--hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
--hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
--hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
--hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
--hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
--hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
--hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
--hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
--hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
--hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
--hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
--hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
--hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
--hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
--hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
--hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
--hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
--hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
--hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
--hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
--hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
--hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
--hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
--hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
--hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
--hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
--hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
--hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
--hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
--hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
--hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
--hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
--hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
--hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
--hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
--hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
--hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
--hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
--hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
# via weasyprint
cfgv==3.5.0 \
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
@@ -105,67 +120,67 @@ cssselect2==0.9.0 \
--hash=sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563 \
--hash=sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb
# via weasyprint
distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
distlib==0.4.3 \
--hash=sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b \
--hash=sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed
# via virtualenv
filelock==3.29.0 \
--hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \
--hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258
filelock==3.30.0 \
--hash=sha256:1774e682dbe443bd60f9609162fc596e2c80dc84ffc2957068953406d0520090 \
--hash=sha256:40632998f0772e64183bb819f086a1b9def6be1090cf1dcb9d45f46806ef279b
# via
# python-discovery
# virtualenv
fonttools==4.62.1 \
--hash=sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04 \
--hash=sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a \
--hash=sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9 \
--hash=sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392 \
--hash=sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82 \
--hash=sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d \
--hash=sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b \
--hash=sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e \
--hash=sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416 \
--hash=sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae \
--hash=sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069 \
--hash=sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9 \
--hash=sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7 \
--hash=sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed \
--hash=sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800 \
--hash=sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e \
--hash=sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9 \
--hash=sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b \
--hash=sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1 \
--hash=sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe \
--hash=sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7 \
--hash=sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd \
--hash=sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056 \
--hash=sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23 \
--hash=sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae \
--hash=sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260 \
--hash=sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974 \
--hash=sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87 \
--hash=sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24 \
--hash=sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53 \
--hash=sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936 \
--hash=sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14 \
--hash=sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42 \
--hash=sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c \
--hash=sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1 \
--hash=sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca \
--hash=sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c \
--hash=sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d \
--hash=sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a \
--hash=sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782 \
--hash=sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c \
--hash=sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a \
--hash=sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79 \
--hash=sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3 \
--hash=sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7 \
--hash=sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d \
--hash=sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2 \
--hash=sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4 \
--hash=sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68 \
--hash=sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca
fonttools==4.63.0 \
--hash=sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69 \
--hash=sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c \
--hash=sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac \
--hash=sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096 \
--hash=sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d \
--hash=sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68 \
--hash=sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616 \
--hash=sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78 \
--hash=sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f \
--hash=sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b \
--hash=sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b \
--hash=sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02 \
--hash=sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d \
--hash=sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f \
--hash=sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8 \
--hash=sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272 \
--hash=sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49 \
--hash=sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419 \
--hash=sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001 \
--hash=sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03 \
--hash=sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196 \
--hash=sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9 \
--hash=sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e \
--hash=sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5 \
--hash=sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007 \
--hash=sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380 \
--hash=sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8 \
--hash=sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27 \
--hash=sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40 \
--hash=sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e \
--hash=sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0 \
--hash=sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263 \
--hash=sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb \
--hash=sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94 \
--hash=sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b \
--hash=sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6 \
--hash=sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579 \
--hash=sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4 \
--hash=sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59 \
--hash=sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0 \
--hash=sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e \
--hash=sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be \
--hash=sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd \
--hash=sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18 \
--hash=sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22 \
--hash=sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0 \
--hash=sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b \
--hash=sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b \
--hash=sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af \
--hash=sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745
# via weasyprint
identify==2.6.19 \
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
@@ -175,193 +190,190 @@ nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
# via pre-commit
numpy==2.4.4 \
--hash=sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed \
--hash=sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50 \
--hash=sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959 \
--hash=sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827 \
--hash=sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd \
--hash=sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233 \
--hash=sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc \
--hash=sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b \
--hash=sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7 \
--hash=sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e \
--hash=sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a \
--hash=sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d \
--hash=sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3 \
--hash=sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e \
--hash=sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb \
--hash=sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a \
--hash=sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0 \
--hash=sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e \
--hash=sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113 \
--hash=sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103 \
--hash=sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93 \
--hash=sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af \
--hash=sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5 \
--hash=sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7 \
--hash=sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392 \
--hash=sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c \
--hash=sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4 \
--hash=sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40 \
--hash=sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf \
--hash=sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44 \
--hash=sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b \
--hash=sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5 \
--hash=sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e \
--hash=sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74 \
--hash=sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0 \
--hash=sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e \
--hash=sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec \
--hash=sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015 \
--hash=sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d \
--hash=sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d \
--hash=sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842 \
--hash=sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150 \
--hash=sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8 \
--hash=sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a \
--hash=sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed \
--hash=sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f \
--hash=sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008 \
--hash=sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e \
--hash=sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0 \
--hash=sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e \
--hash=sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f \
--hash=sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a \
--hash=sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40 \
--hash=sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7 \
--hash=sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83 \
--hash=sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d \
--hash=sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c \
--hash=sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871 \
--hash=sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502 \
--hash=sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252 \
--hash=sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8 \
--hash=sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115 \
--hash=sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f \
--hash=sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e \
--hash=sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d \
--hash=sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0 \
--hash=sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119 \
--hash=sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e \
--hash=sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db \
--hash=sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121 \
--hash=sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d \
--hash=sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e
numpy==2.4.6 \
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
--hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
--hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
--hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
--hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
--hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
--hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
--hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
--hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
--hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
--hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
--hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
--hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
--hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
--hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
--hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
--hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
--hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
--hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
--hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
--hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
--hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
--hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
--hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
--hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
--hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
--hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
--hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
--hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
--hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
--hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
--hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
--hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
--hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
--hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
--hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
--hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
--hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
--hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
--hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
--hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
--hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
--hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
--hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
--hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
--hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
--hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
--hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
--hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
--hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
--hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
--hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
--hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
--hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
--hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
--hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
--hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
--hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
--hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
--hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
--hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
--hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
--hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
--hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
--hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
--hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
--hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
# via opencv-python-headless
opencv-python-headless==4.13.0.92 \
--hash=sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22 \
--hash=sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e \
--hash=sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209 \
--hash=sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c \
--hash=sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb \
--hash=sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6 \
--hash=sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b \
--hash=sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d
opencv-python-headless==5.0.0.93 \
--hash=sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f \
--hash=sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4 \
--hash=sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f \
--hash=sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00 \
--hash=sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e \
--hash=sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4 \
--hash=sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c \
--hash=sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9 \
--hash=sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37
# via -r .github/scripts/requirements_dev.in
pdf2image==1.17.0 \
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
# via -r .github/scripts/requirements_dev.in
pillow==12.2.0 \
--hash=sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9 \
--hash=sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5 \
--hash=sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987 \
--hash=sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9 \
--hash=sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b \
--hash=sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f \
--hash=sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd \
--hash=sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e \
--hash=sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e \
--hash=sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe \
--hash=sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795 \
--hash=sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601 \
--hash=sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1 \
--hash=sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed \
--hash=sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea \
--hash=sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5 \
--hash=sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97 \
--hash=sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453 \
--hash=sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98 \
--hash=sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa \
--hash=sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b \
--hash=sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d \
--hash=sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705 \
--hash=sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8 \
--hash=sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024 \
--hash=sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0 \
--hash=sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286 \
--hash=sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150 \
--hash=sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2 \
--hash=sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3 \
--hash=sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b \
--hash=sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f \
--hash=sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463 \
--hash=sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940 \
--hash=sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166 \
--hash=sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed \
--hash=sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f \
--hash=sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795 \
--hash=sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780 \
--hash=sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7 \
--hash=sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1 \
--hash=sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5 \
--hash=sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295 \
--hash=sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b \
--hash=sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354 \
--hash=sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60 \
--hash=sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65 \
--hash=sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005 \
--hash=sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c \
--hash=sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be \
--hash=sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5 \
--hash=sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06 \
--hash=sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae \
--hash=sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c \
--hash=sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c \
--hash=sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612 \
--hash=sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e \
--hash=sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab \
--hash=sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808 \
--hash=sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f \
--hash=sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e \
--hash=sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909 \
--hash=sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec \
--hash=sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe \
--hash=sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50 \
--hash=sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4 \
--hash=sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f \
--hash=sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff \
--hash=sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5 \
--hash=sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb \
--hash=sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414 \
--hash=sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1 \
--hash=sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032 \
--hash=sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76 \
--hash=sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136 \
--hash=sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e \
--hash=sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c \
--hash=sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3 \
--hash=sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea \
--hash=sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f \
--hash=sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104 \
--hash=sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176 \
--hash=sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24 \
--hash=sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3 \
--hash=sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4 \
--hash=sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed \
--hash=sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43 \
--hash=sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421 \
--hash=sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7 \
--hash=sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06 \
--hash=sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5
pillow==12.3.0 \
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
--hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
--hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
--hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
--hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
--hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
--hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
--hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
--hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
--hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
--hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
--hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
--hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
--hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
--hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
--hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
--hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
--hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
--hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
--hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
--hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
--hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
--hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
--hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
--hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
--hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
--hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
--hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
--hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
--hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
--hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
--hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
--hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
--hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
--hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
--hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
--hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
--hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
--hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
--hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
--hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
--hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
--hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
--hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
--hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
--hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
--hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
--hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
--hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
--hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
--hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
--hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
--hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
--hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
--hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
--hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
--hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
--hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
--hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
--hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
--hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
--hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
--hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
--hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
--hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
--hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
--hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
--hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
--hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
--hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
--hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
--hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
--hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
--hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
--hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
--hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
--hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
--hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
--hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
--hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
--hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
--hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
--hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
--hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
--hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
--hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
--hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
# via
# -r .github/scripts/requirements_dev.in
# pdf2image
# weasyprint
platformdirs==4.9.6 \
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
platformdirs==4.10.0 \
--hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \
--hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a
# via
# python-discovery
# virtualenv
@@ -381,9 +393,9 @@ pyphen==0.17.2 \
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
--hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3
# via weasyprint
python-discovery==1.2.2 \
--hash=sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb \
--hash=sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a
python-discovery==1.4.4 \
--hash=sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3 \
--hash=sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe
# via virtualenv
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
@@ -470,17 +482,17 @@ tinyhtml5==2.1.0 \
--hash=sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67 \
--hash=sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a
# via weasyprint
unoserver==3.6 \
--hash=sha256:25c360fa194396a89cb79b4edd2735f8e4f0fd8531e59db3952114585bd7df05 \
--hash=sha256:e446bcb3638c51880f002aaeecab1cf74dfa9df81035f027f7ff2e081b6d7015
unoserver==3.7 \
--hash=sha256:b05f9578506ac7374ae1b314c3a79528636c542ac78220a9ce99110584ca424b \
--hash=sha256:fc44e6808071c9d2957e705ecf1742cea8a582aa5d5cc23babf36bb332ec6e8e
# via -r .github/scripts/requirements_dev.in
virtualenv==21.2.4 \
--hash=sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac \
--hash=sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada
virtualenv==21.6.1 \
--hash=sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128 \
--hash=sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b
# via pre-commit
weasyprint==68.1 \
--hash=sha256:4dc3ba63c68bbbce3e9617cb2226251c372f5ee90a8a484503b1c099da9cf5be \
--hash=sha256:d3b752049b453a5c95edb27ce78d69e9319af5a34f257fa0f4c738c701b4184e
weasyprint==69.0 \
--hash=sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6 \
--hash=sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c
# via -r .github/scripts/requirements_dev.in
webencodings==0.5.1 \
--hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
@@ -489,27 +501,28 @@ webencodings==0.5.1 \
# cssselect2
# tinycss2
# tinyhtml5
zopfli==0.4.1 \
--hash=sha256:02086247dd12fda929f9bfe8b3962b6bcdbfc8c82e99255aebcf367867cf0760 \
--hash=sha256:07a5cdc5d1aaa6c288c5d9f5a5383042ba743641abf8e2fd898dcad622d8a38e \
--hash=sha256:27823dc1161a4031d1c25925fd45d9868ec0cbc7692341830a7dcfa25063662c \
--hash=sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707 \
--hash=sha256:4238d4d746d1095e29c9125490985e0c12ffd3654f54a24af551e2391e936d54 \
--hash=sha256:5a4c22b6161f47f5bd34637dbaee6735abd287cd64e0d1ce28ef1871bf625f4b \
--hash=sha256:84a31ba9edc921b1d3a4449929394a993888f32d70de3a3617800c428a947b9b \
--hash=sha256:a899eca405662a23ae75054affa3517a060362eae1185d3d791c86a50153c4dd \
--hash=sha256:a93c2ecafff372de6c0aa2212eff18a75f6c71a100372fee7b4b129cc0b6f9a7 \
--hash=sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7 \
--hash=sha256:d7bcee1b189d64ec33d1e05cfa1b6a1268c29329c382f6ca1bd6245b04925c57 \
--hash=sha256:fdfb7ce9f5de37a5b2f75dd2642fd7717956ef2a72e0387302a36d382440db07
zopfli==0.4.3 \
--hash=sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d \
--hash=sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef \
--hash=sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3 \
--hash=sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6 \
--hash=sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc \
--hash=sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42 \
--hash=sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e \
--hash=sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073 \
--hash=sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357 \
--hash=sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe \
--hash=sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94 \
--hash=sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206 \
--hash=sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e
# via fonttools
# The following packages are considered to be unsafe in a requirements file:
pip==26.0.1 \
--hash=sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b \
--hash=sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8
pip==26.1.2 \
--hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \
--hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605
# via -r .github/scripts/requirements_dev.in
setuptools==82.0.1 \
--hash=sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9 \
--hash=sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb
setuptools==83.0.0 \
--hash=sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef \
--hash=sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3
# via -r .github/scripts/requirements_dev.in
@@ -1 +0,0 @@
pre-commit
-121
View File
@@ -1,121 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
#
cfgv==3.5.0 \
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
# via pre-commit
distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv
filelock==3.29.0 \
--hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \
--hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258
# via
# python-discovery
# virtualenv
identify==2.6.19 \
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
--hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842
# via pre-commit
nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
# via pre-commit
platformdirs==4.9.6 \
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
# via
# python-discovery
# virtualenv
pre-commit==4.6.0 \
--hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \
--hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b
# via -r .github/scripts/requirements_pre_commit.in
python-discovery==1.2.2 \
--hash=sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb \
--hash=sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a
# via virtualenv
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via pre-commit
virtualenv==21.2.4 \
--hash=sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac \
--hash=sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada
# via pre-commit
+4 -4
View File
@@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
@@ -8,7 +8,7 @@ tomli-w==1.2.0 \
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
# via -r .github/scripts/requirements_sync_readme.in
tomlkit==0.14.0 \
--hash=sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680 \
--hash=sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064
tomlkit==0.15.0 \
--hash=sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738 \
--hash=sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3
# via -r .github/scripts/requirements_sync_readme.in
+111 -37
View File
@@ -23,13 +23,9 @@ permissions:
pull-requests: write
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
check-pr:
if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch'
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
outputs:
should_deploy: ${{ steps.decide.outputs.should_deploy }}
is_fork: ${{ steps.resolve.outputs.is_fork }}
@@ -101,8 +97,8 @@ jobs:
echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT
deploy-v2-pr:
needs: [pick, check-pr]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
needs: check-pr
runs-on: ubuntu-latest
if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true')
# Concurrency control - only one deployment per PR at a time
concurrency:
@@ -112,10 +108,10 @@ jobs:
contents: read
issues: write
pull-requests: write
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
# Single source of truth for whether this preview embeds the admin portal:
# drives the image build-arg and the deployment comment.
BUILD_PORTAL: "true"
steps:
- name: Harden Runner
@@ -187,12 +183,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Fetch full history for commit hash detection
- name: Set up Depot CLI
if: env.USE_DEPOT == 'true'
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
- name: Set up Docker Buildx
if: env.USE_DEPOT != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Get version number
@@ -237,21 +228,9 @@ jobs:
echo "Image needs to be built"
fi
- name: Build and push V2 image (Depot)
if: env.USE_DEPOT == 'true' && steps.check-image.outputs.exists == 'false'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push V2 image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && steps.check-image.outputs.exists == 'false'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build and push V2 image
if: steps.check-image.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/embedded/Dockerfile
@@ -259,7 +238,9 @@ jobs:
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: VERSION_TAG=v2-alpha
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
platforms: linux/amd64
- name: Set up SSH
@@ -290,6 +271,7 @@ jobs:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
@@ -333,9 +315,70 @@ jobs:
# Set port for output
echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT
# ---- Storybook preview (only when this PR touches stories/.storybook) ----
# Runs inside the same approved-contributor-gated deploy job, so it deploys
# under the exact same access rules as the app preview.
- name: Detect Storybook changes
id: sb-changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
list-files: json
filters: |
storybook:
- 'frontend/**/*.stories.@(ts|tsx|mdx)'
- 'frontend/**/*.mdx'
- 'frontend/.storybook/**'
- name: Set up Node.js for Storybook
if: steps.sb-changes.outputs.storybook == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task for Storybook
if: steps.sb-changes.outputs.storybook == 'true'
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build and deploy Storybook
id: storybook
if: steps.sb-changes.outputs.storybook == 'true'
env:
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
VPS_USER: ${{ secrets.NEW_VPS_USERNAME }}
run: |
set -euo pipefail
# `prepare` generates the icon set stories import (not committed).
task frontend:prepare
task frontend:storybook:build
PR=${{ needs.check-pr.outputs.pr_number }}
# Served at the ROOT of its own port so Storybook's global MSW worker
# (/mockServiceWorker.js) resolves. Port = PR + 20000 (bijective, offset
# from the app preview's bare-PR-number port).
SB_PORT=$((PR + 20000))
DIR=/stirling/SB-PR-$PR
tar czf storybook.tgz -C frontend/storybook-static .
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
storybook.tgz "$VPS_USER@$VPS_HOST:/tmp/storybook-$PR.tgz"
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T \
"$VPS_USER@$VPS_HOST" << ENDSSH
set -e
rm -rf "$DIR" && mkdir -p "$DIR"
tar xzf /tmp/storybook-$PR.tgz -C "$DIR"
rm -f /tmp/storybook-$PR.tgz
docker rm -f storybook-pr-$PR 2>/dev/null || true
docker run -d --name storybook-pr-$PR --restart unless-stopped \
-p $SB_PORT:80 -v "$DIR":/usr/share/nginx/html:ro nginx:alpine
ENDSSH
echo "url=http://$VPS_HOST:$SB_PORT/" >> "$GITHUB_OUTPUT"
- name: Post V2 deployment URL to PR
if: success()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
SB_URL: ${{ steps.storybook.outputs.url }}
SB_FILES: ${{ steps.sb-changes.outputs.storybook_files }}
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -359,12 +402,40 @@ jobs:
}
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
// Only mention the portal when this image actually embeds it.
// Use the direct IP URL - the SSL hostname isn't supported yet.
const withPortal = "${{ env.BUILD_PORTAL }}" === "true";
const portalNote = withPortal
? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n`
: ``;
// Storybook preview: only present when this PR changed stories/config.
const sbUrl = process.env.SB_URL;
let storybookNote = "";
if (sbUrl) {
const files = JSON.parse(process.env.SB_FILES || "[]");
const stories = files.filter((f) => /\.stories\.(ts|tsx|mdx)$/.test(f));
const config = files.filter((f) => f.startsWith("frontend/.storybook/"));
const shorten = (f) =>
f.replace(/^frontend\/editor\/src\//, "").replace(/^frontend\//, "");
const storyList = stories.map((f) => `- \`${shorten(f)}\``).join("\n");
const configList = config.map((f) => `- \`${shorten(f)}\``).join("\n");
const summary =
`${stories.length} stor${stories.length === 1 ? "y" : "ies"} changed` +
(config.length ? ` (+${config.length} config file${config.length === 1 ? "" : "s"})` : "");
storybookNote =
`📚 **Storybook:** [${sbUrl}](${sbUrl})\n\n` +
`<details>\n<summary>${summary}</summary>\n\n` +
(storyList ? `**Stories**\n${storyList}\n\n` : "") +
(configList ? `**Config**\n${configList}\n` : "") +
`</details>\n\n`;
}
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with embedded architecture has been deployed!\n\n` +
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
portalNote +
storybookNote +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** for approved V2 contributors.`;
@@ -377,8 +448,7 @@ jobs:
cleanup-v2-deployment:
if: github.event.action == 'closed'
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
@@ -460,7 +530,11 @@ jobs:
else
echo "V2 PR directory not found, nothing to clean up"
fi
# Remove this PR's Storybook preview (container + files), if any.
docker rm -f storybook-pr-${{ github.event.pull_request.number }} 2>/dev/null || true
rm -rf /stirling/SB-PR-${{ github.event.pull_request.number }}
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
@@ -34,12 +34,8 @@ permissions:
pull-requests: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
check-comment:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
permissions:
issues: write
if: |
@@ -179,15 +175,11 @@ jobs:
}
deploy-pr:
needs: [pick, check-comment]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
needs: check-comment
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
@@ -220,12 +212,12 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Run Gradle Command
run: |
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
@@ -240,12 +232,7 @@ jobs:
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
STIRLING_PDF_DESKTOP_UI: false
- name: Set up Depot CLI
if: env.USE_DEPOT == 'true'
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
- name: Set up Docker Buildx
if: env.USE_DEPOT != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker Hub
@@ -254,23 +241,8 @@ jobs:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push PR-specific image (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
build-args: |
VERSION_TAG=alpha
PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }}
platforms: linux/amd64
- name: Build and push PR-specific image (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build and push PR-specific image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/embedded/Dockerfile
@@ -283,20 +255,9 @@ jobs:
PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }}
platforms: linux/amd64
- name: Build and push engine image (Depot)
if: env.USE_DEPOT == 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: ./engine
file: ./engine/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:engine-pr-${{ needs.check-comment.outputs.pr_number }}
platforms: linux/amd64
- name: Build and push engine image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build and push engine image
if: needs.check-comment.outputs.enable_prototypes == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./engine
file: ./engine/Dockerfile
@@ -510,8 +471,7 @@ jobs:
handle-label-commands:
if: ${{ github.event.issue.pull_request != null }}
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
+17 -20
View File
@@ -2,8 +2,8 @@ name: _runner-pick
# Tiny reusable workflow that classifies the trigger as either a "fork PR
# from an untrusted contributor" or a "trusted commit" so downstream jobs
# can pick a runner class without each one duplicating the 200-char gate
# expression in their own `runs-on:`.
# can trust-gate (skip secret-dependent jobs on forks) without each one
# duplicating the gate expression.
#
# Caller pattern:
#
@@ -13,12 +13,12 @@ name: _runner-pick
#
# real-work:
# needs: pick
# runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
# if: needs.pick.outputs.is_fork != 'true'
# steps: [...]
#
# Output:
# is_fork: "true" when the trigger is a pull_request from a fork or an
# untrusted author_association, "false" otherwise.
# Outputs:
# is_fork: "true" when the trigger is a pull_request from a fork or an
# untrusted author_association, "false" otherwise.
on:
workflow_call:
@@ -50,21 +50,18 @@ jobs:
AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }}
run: |
set -eu
if [ -z "${PR_NUMBER:-}" ]; then
# Not a pull_request event at all (push, schedule, workflow_dispatch,
# workflow_call from a non-PR trigger) -> trusted by default.
echo "is_fork=false" >> "$GITHUB_OUTPUT"
exit 0
is_fork=false
elif [ "${HEAD_REPO_FORK}" = "true" ]; then
is_fork=true
else
case "${AUTHOR_ASSOC}" in
OWNER|MEMBER|COLLABORATOR) is_fork=false ;;
*) is_fork=true ;;
esac
fi
if [ "${HEAD_REPO_FORK}" = "true" ]; then
echo "is_fork=true" >> "$GITHUB_OUTPUT"
exit 0
fi
case "${AUTHOR_ASSOC}" in
OWNER|MEMBER|COLLABORATOR)
echo "is_fork=false" >> "$GITHUB_OUTPUT"
;;
*)
echo "is_fork=true" >> "$GITHUB_OUTPUT"
;;
esac
echo "is_fork=${is_fork}" >> "$GITHUB_OUTPUT"
+7 -103
View File
@@ -1,9 +1,9 @@
name: AI Engine CI
# Validates the Python AI engine: regenerates tool models and runs the
# engine quality gate (lint, type-check, format-check, tests). Called from
# build.yml on PRs and merge_group; also runs directly on push to main as
# a post-merge safety net.
# Runs the engine quality gate (lint, type-check, format-check, tests). Called
# from build.yml on PRs and merge_group; also runs directly on push to main as
# a post-merge safety net. Freshness of the generated tool_models.py is checked
# by the shared check-generated-models workflow.
on:
workflow_call:
push:
@@ -18,8 +18,6 @@ jobs:
permissions:
contents: read
pull-requests: write
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -30,107 +28,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
cache-suffix: ai-engine
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Regenerate tool models
run: task engine:tool-models
- name: Verify tool models are up to date
id: tool-models-check
continue-on-error: true
run: git diff --exit-code engine/src/stirling/models/tool_models.py
- name: Comment on tool models check failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- tool-models-check -->';
const body = [
marker,
'### Tool Models Check Failed',
'',
'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
'',
'Run `task engine:tool-models` to regenerate, then commit the updated file.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if tool models check failed
if: steps.tool-models-check.outcome == 'failure'
run: |
echo "============================================"
echo " Tool Models Check Failed"
echo "============================================"
echo ""
echo "The generated engine/src/stirling/models/tool_models.py"
echo "is out of date with the Java OpenAPI spec and will"
echo "need to be regenerated before it can be merged in."
echo ""
echo "Run 'task engine:tool-models' to regenerate, then"
echo "commit the updated file."
echo "============================================"
exit 1
- name: Remove tool models check comment on success
if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- tool-models-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Quality-check engine
id: engine-check
+6 -12
View File
@@ -19,14 +19,8 @@ permissions:
pull-requests: write
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
build:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
@@ -47,7 +41,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -56,13 +50,13 @@ jobs:
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@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
cache-disabled: true
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check Java formatting (Spotless)
# Runs once per matrix combination - pick the cheapest leg
# (core - no proprietary, no saas) so we don't wait for the
@@ -247,7 +241,7 @@ jobs:
# so skip it for merge_group runs and workflow_dispatch.
if: github.event_name == 'pull_request'
id: jacoco
uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2
uses: madrapps/jacoco-report@e51ce1f46f7f8b5331593f935e59cbaf44b84920 # v1.8.0
with:
paths: |
${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml
+27 -19
View File
@@ -2,7 +2,7 @@ name: Enterprise E2E (Playwright)
# Enterprise Playwright suite — exercises premium-key gated features (audit,
# teams, analytics) plus full OAuth + SAML logins via the Keycloak compose
# stacks under testing/compose. Slow and secret-gated, so it runs in three
# stacks under testing/compose. Slow and secret-gated, so it runs in four
# situations:
#
# - PRs that touch proprietary / premium / SSO compose / enterprise tests
@@ -12,28 +12,14 @@ name: Enterprise E2E (Playwright)
# - on a nightly cron schedule (catches Keycloak image drift, license
# expiry, upstream proprietary changes),
# - manual workflow_dispatch.
#
# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot).
on:
workflow_call:
inputs:
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
required: false
type: string
default: "8"
push:
branches: ["main"]
schedule:
- cron: "0 4 * * *"
workflow_dispatch:
inputs:
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
required: false
type: string
default: "8"
# No `concurrency:` block here on purpose. When this workflow is called via
# workflow_call from build.yml, ${{ github.workflow }}/event_name/pr_number
@@ -52,13 +38,16 @@ jobs:
playwright-e2e-enterprise:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
# so the suite can't boot premium and would fail. See the header comment.
# GitHub reports the skipped reusable workflow as success.
if: needs.pick.outputs.is_fork != 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
env:
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
PREMIUM_ENABLED: "true"
SYSTEM_ENABLEANALYTICS: "false"
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -78,7 +67,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Build frontend (needed for playwright's vite preview webServer)
@@ -165,6 +154,8 @@ jobs:
wait_for_backend
- name: Run enterprise OAuth Playwright tests
id: oauth-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-oauth.json
run: task e2e:enterprise -- --grep "OAuth"
- name: Stop backend + tear down OAuth Keycloak
if: always()
@@ -238,6 +229,8 @@ jobs:
wait_for_backend
- name: Run enterprise SAML Playwright tests
id: saml-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-saml.json
run: task e2e:enterprise -- --grep "SAML"
- name: Stop backend + tear down SAML Keycloak
if: always()
@@ -268,6 +261,8 @@ jobs:
wait_for_backend
- name: Run enterprise feature Playwright tests
id: feature-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-feature.json
run: task e2e:enterprise -- --grep "Enterprise license"
- name: Print backend log on failure
if: failure()
@@ -280,10 +275,23 @@ jobs:
run: |
source /tmp/helpers.sh
stop_backend
- name: Flag flaky tests
# Runs regardless of the test outcomes: a flaky test (passed on retry)
# leaves its step green, so this is the only place it surfaces. Merges
# all three phase reports (some may be absent if an earlier phase hard-
# failed and skipped the rest). Emits ::warning:: annotations + a job
# summary; never fails the job.
if: always()
working-directory: frontend
run: >
npx tsx editor/scripts/report-flaky-tests.mts
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-enterprise-${{ github.run_id }}
path: frontend/editor/playwright-report/
path: frontend/playwright-report/
retention-days: 7
+39 -1
View File
@@ -41,8 +41,10 @@ jobs:
openapi: ${{ steps.changes.outputs.openapi }}
frontend: ${{ steps.changes.outputs.frontend }}
docker-base: ${{ steps.changes.outputs.docker-base }}
dockerfiles: ${{ steps.changes.outputs.dockerfiles }}
tauri: ${{ steps.changes.outputs.tauri }}
engine: ${{ steps.changes.outputs.engine }}
generated-models: ${{ steps.changes.outputs.generated-models }}
proprietary: ${{ steps.changes.outputs.proprietary }}
steps:
- name: Harden the runner (Audit all outbound calls)
@@ -97,6 +99,17 @@ jobs:
uses: ./.github/workflows/frontend-validation.yml
secrets: inherit
# Advisory: deliberately NOT in all-checks-passed. It reports on the stories a
# branch touches so a regression is visible in review, but a browser scan is
# too new here to block merges on. Promote it once its pass/fail proves stable.
frontend-a11y:
if: needs.files-changed.outputs.frontend == 'true'
needs: [files-changed]
permissions:
contents: read
uses: ./.github/workflows/frontend-a11y.yml
secrets: inherit
playwright-e2e:
if: needs.files-changed.outputs.frontend == 'true'
needs: [files-changed]
@@ -147,11 +160,11 @@ jobs:
permissions:
contents: read
packages: read
id-token: write
uses: ./.github/workflows/test-build-docker.yml
secrets: inherit
with:
docker-base-changed: ${{ needs.files-changed.outputs.docker-base }}
dockerfiles-changed: ${{ needs.files-changed.outputs.dockerfiles }}
tauri-build:
if: needs.files-changed.outputs.tauri == 'true'
@@ -161,6 +174,12 @@ jobs:
pull-requests: write
uses: ./.github/workflows/tauri-build.yml
secrets: inherit
# 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: windows-macos
sign: false
ai-engine:
if: needs.files-changed.outputs.engine == 'true'
@@ -171,6 +190,20 @@ jobs:
uses: ./.github/workflows/ai-engine.yml
secrets: inherit
# The generated frontend types and engine tool models are both derived from
# the Java OpenAPI spec. This job regenerates and diffs them; it boots the
# backend, so it is gated on the narrow generated-models filter (spec source,
# generators, generated files, generation tasks) rather than the broad
# 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]
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/check-generated-models.yml
secrets: inherit
pre-commit:
needs: [files-changed]
permissions:
@@ -202,6 +235,9 @@ jobs:
contents: read
uses: ./.github/workflows/coverage-aggregate.yml
secrets: inherit
with:
frontend-validation-result: ${{ needs.frontend-validation.result }}
playwright-e2e-live-result: ${{ needs.playwright-e2e-live.result }}
# Single status check that branch protection should mark as required.
# Succeeds when every upstream job is either `success` or `skipped` (path-
@@ -225,6 +261,7 @@ jobs:
- test-build-docker-images
- tauri-build
- ai-engine
- generated-models
- pre-commit
- dependency-review
runs-on: ubuntu-latest
@@ -250,6 +287,7 @@ jobs:
test-build-docker-images=${{ needs.test-build-docker-images.result }}
tauri-build=${{ needs.tauri-build.result }}
ai-engine=${{ needs.ai-engine.result }}
generated-models=${{ needs.generated-models.result }}
pre-commit=${{ needs.pre-commit.result }}
dependency-review=${{ needs.dependency-review.result }}
run: |
@@ -0,0 +1,147 @@
name: Check generated models
# Verifies the committed generated API models are still in sync with the Java
# OpenAPI spec: the frontend tool API types
# (frontend/editor/src/core/types/toolApiTypes.ts) and the engine tool
# models (engine/src/stirling/models/tool_models.py). Regenerates both with the
# single top-level `task tool-models` and fails if either committed file is
# out of date. Called from build.yml when the backend Java, frontend, or engine
# changes; also runs on push to main as a post-merge safety net.
on:
workflow_call:
push:
branches: [main]
permissions:
contents: read
jobs:
generated-models:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
cache-suffix: generated-models
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
# Rebuilds the OpenAPI spec from the current Java and regenerates both the
# frontend types and the engine tool models from it.
- name: Regenerate generated models
run: task tool-models
- name: Verify generated models are up to date
id: models-check
continue-on-error: true
run: |
git diff --exit-code \
frontend/editor/src/core/types/toolApiTypes.ts \
engine/src/stirling/models/tool_models.py
- name: Comment on generated models check failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.models-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- generated-models-check -->';
const body = [
marker,
'### Generated Models Check Failed',
'',
'The generated `frontend/editor/src/core/types/toolApiTypes.ts` and/or `engine/src/stirling/models/tool_models.py` are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
'',
'Run `task tool-models` to regenerate both, then commit the updated files.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if generated models check failed
if: steps.models-check.outcome == 'failure'
run: |
echo "============================================"
echo " Generated Models Check Failed"
echo "============================================"
echo ""
echo "The generated frontend API types and/or engine tool"
echo "models are out of date with the Java OpenAPI spec and"
echo "will need to be regenerated before they can be merged in."
echo ""
echo "Run 'task tool-models' to regenerate both, then"
echo "commit the updated files."
echo "============================================"
exit 1
- name: Remove generated models check comment on success
if: steps.models-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- generated-models-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
+4 -6
View File
@@ -11,8 +11,6 @@ permissions:
jobs:
check-licence:
runs-on: ubuntu-latest
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -29,7 +27,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -38,13 +36,13 @@ jobs:
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@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
cache-disabled: true
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check licenses for compatibility
run: task backend:licenses:check
env:
+5 -11
View File
@@ -10,14 +10,8 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
check-generate-openapi-docs:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -34,7 +28,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -43,13 +37,13 @@ jobs:
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@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
cache-disabled: true
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Generate OpenAPI documentation
run: task backend:swagger
env:
+1 -1
View File
@@ -196,7 +196,7 @@ jobs:
core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
+23 -16
View File
@@ -13,17 +13,24 @@ name: Aggregate backend coverage
# producers themselves
on:
workflow_call:
inputs:
frontend-validation-result:
description: Result of the frontend-validation producer job
required: false
type: string
default: skipped
playwright-e2e-live-result:
description: Result of the playwright-e2e-live producer job
required: false
type: string
default: skipped
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
aggregate:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden Runner
@@ -40,7 +47,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -49,13 +56,13 @@ jobs:
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@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.3.1
gradle-version: 9.6.1
cache-disabled: true
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
@@ -196,9 +203,9 @@ jobs:
# --------------------------------------------------------------
- name: Download vitest coverage artifact
# frontend-validation uploads as `frontend-coverage`. Tolerate
# absence so a backend-only PR still produces the matrix with
# just backend rows populated.
if: always()
# 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
with:
name: frontend-coverage
@@ -206,12 +213,12 @@ jobs:
continue-on-error: true
- name: Download Playwright frontend coverage artifact
# e2e-live uploads as `playwright-frontend-coverage-<run_id>`.
# Same tolerance as vitest - matrix script handles missing inputs.
if: always()
# 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
with:
name: playwright-frontend-coverage-${{ github.run_id }}
name: playwright-frontend-coverage
path: matrix-inputs/playwright/
continue-on-error: true
+4 -10
View File
@@ -12,15 +12,9 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
migration-test:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
runs-on: ubuntu-latest
timeout-minutes: 30
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -37,7 +31,7 @@ jobs:
distribution: temurin
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -46,9 +40,9 @@ jobs:
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@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
cache-disabled: true
# No `-PnoSpotless` here yet because the upstream cache layer matches the
+7 -50
View File
@@ -10,21 +10,11 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
deploy-v2-on-push:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
concurrency:
group: deploy-v2-push-V2
cancel-in-progress: true
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
@@ -35,12 +25,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Depot CLI
if: env.USE_DEPOT == 'true'
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
- name: Set up Docker Buildx
if: env.USE_DEPOT != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Get commit hashes for frontend and backend
@@ -105,23 +90,9 @@ jobs:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push frontend image (Depot)
if: env.USE_DEPOT == 'true' && steps.check-frontend.outputs.exists == 'false'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./docker/frontend/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push frontend image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build and push frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/frontend/Dockerfile
@@ -134,23 +105,9 @@ jobs:
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push backend image (Depot)
if: env.USE_DEPOT == 'true' && steps.check-backend.outputs.exists == 'false'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./docker/backend/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push backend image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build and push backend image
if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/backend/Dockerfile
@@ -1,136 +0,0 @@
name: Docker Compose Cucumber tests (saas / PAYG)
# Self-contained CI job for the PAYG shadow-mode cucumber scenarios.
# Triggers only on PAYG-relevant paths so we don't add CI minutes to every PR
# that doesn't touch the saas flavour.
#
# Companion to `docker-compose-tests.yml` (which runs against the
# proprietary-flavour stack and skips features/payg via behave.ini's
# exclude_re). Kept as a separate workflow so the saas matrix can fail and
# succeed independently without touching the main cucumber harness.
on:
pull_request:
paths:
- "app/saas/**"
- "testing/cucumber/features/payg/**"
- "testing/cucumber/features/steps/payg_step_definitions.py"
- "testing/cucumber/requirements.txt"
- "testing/compose/docker-compose-saas.yml"
- "testing/compose/payg/**"
- "testing/test-payg.sh"
- ".github/workflows/docker-compose-tests-saas.yml"
push:
branches: [main]
paths:
- "app/saas/**"
- "testing/cucumber/features/payg/**"
- "testing/cucumber/features/steps/payg_step_definitions.py"
- "testing/cucumber/requirements.txt"
- "testing/compose/docker-compose-saas.yml"
- "testing/compose/payg/**"
- "testing/test-payg.sh"
- ".github/workflows/docker-compose-tests-saas.yml"
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
docker-compose-tests-saas:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
actions: write
contents: read
checks: write
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
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 dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-saas-${{ 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@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
cache-disabled: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Expose GitHub runtime for Buildx cache
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
# No "Install Docker Compose" step: Ubuntu runners ship with `docker compose`
# v2 (built into the Docker CLI). test-payg.sh uses the v2 form throughout
# (`docker compose …`, no hyphen), so the legacy v1 `docker-compose` binary
# isn't needed. Avoids a `curl | sudo install` without checksum verification
# (Aikido flagged this when copy-pasted from docker-compose-tests.yml).
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Pip requirements
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
- name: Run PAYG Cucumber Tests
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
run: |
chmod +x ./testing/test-payg.sh
./testing/test-payg.sh
- name: Dump saas container logs on failure
if: failure()
run: |
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 500 stirling-pdf-saas || true
docker compose -f testing/compose/docker-compose-saas.yml logs --tail 200 postgres-saas || true
- name: Upload PAYG Cucumber Report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: payg-cucumber-report
path: testing/cucumber/report-payg.html
retention-days: 7
if-no-files-found: warn
- name: PAYG Cucumber Test Report
if: always()
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: PAYG Cucumber Tests
path: testing/cucumber/junit-payg/*.xml
reporter: java-junit
fail-on-error: false
+13 -16
View File
@@ -11,28 +11,17 @@ on:
required: false
type: string
default: "false"
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 4 because bench showed 16 was within noise of 4."
required: false
type: string
default: "4"
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
docker-compose-tests:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '4') }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: read
checks: write
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
@@ -50,7 +39,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -59,16 +48,24 @@ jobs:
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@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
cache-disabled: true
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
# container builder can't see that store, so skip it here and let
# `docker buildx build` fall back to the default docker driver, which
# resolves the local base. The gha cache backend is also skipped (its
# runtime token isn't exposed) since the docker driver can't use it.
- name: Set up Docker Buildx
if: inputs.docker-base-changed != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
- name: Expose GitHub runtime for Buildx cache
if: inputs.docker-base-changed != 'true'
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
- name: Install Docker Compose
@@ -77,7 +74,7 @@ jobs:
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
+37 -17
View File
@@ -5,23 +5,13 @@ name: Playwright E2E (live backend)
# server.
on:
workflow_call:
inputs:
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
required: false
type: string
default: "8"
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
playwright-e2e-live:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Harden Runner
@@ -42,7 +32,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Build frontend (production bundle for vite preview)
@@ -62,7 +52,17 @@ jobs:
# .test-state/playwright/coverage-pw/ for the post-process step
# to aggregate. Chromium-only - other engines silently skip.
PW_COVERAGE: "1"
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:live
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Generate JaCoCo report from e2e:live .exec
if: always()
id: live-coverage
@@ -84,7 +84,7 @@ jobs:
fi
- name: Set up Python for coverage summary
if: always() && steps.live-coverage.outputs.report == 'true'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage summary
@@ -124,7 +124,7 @@ jobs:
# a summary even on backend failure, as long as some Playwright
# tests ran far enough to dump V8 coverage.
if: always()
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
@@ -169,7 +169,7 @@ jobs:
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-frontend-coverage-${{ github.run_id }}
name: playwright-frontend-coverage
path: |
.test-state/playwright/coverage-pw-summary/
.test-state/playwright/coverage-pw/
@@ -188,10 +188,30 @@ jobs:
name: backend-log-live-${{ github.run_id }}
path: .test-state/playwright/backend.log
retention-days: 7
- name: Upload Playwright report
- name: List Playwright output locations (debug)
if: always()
run: |
echo "::group::Playwright output dirs"
# Playwright anchors its default outputDir + HTML report to the
# nearest package.json, which is frontend/ (frontend/editor has
# none), so artifacts land under frontend/, not frontend/editor/.
ls -la frontend/playwright-report 2>/dev/null \
|| echo "no playwright-report at frontend/"
ls -la frontend/test-results 2>/dev/null \
|| echo "no test-results at frontend/"
find . -name node_modules -prune -o -name 'trace.zip' -print 2>/dev/null || true
echo "::endgroup::"
- name: Upload Playwright report + traces
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-live-${{ github.run_id }}
path: frontend/editor/playwright-report/
# test-results/ holds the per-test trace.zip (with browser console
# logs) + screenshots/video; playwright-report/ is the HTML report.
# Both live under frontend/ (Playwright anchors them to the nearest
# package.json, which is frontend/; frontend/editor has none).
path: |
frontend/playwright-report/
frontend/test-results/
retention-days: 7
if-no-files-found: warn
+14 -13
View File
@@ -5,23 +5,13 @@ name: Playwright E2E (stubbed)
# mocks API responses in the browser.
on:
workflow_call:
inputs:
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 8 to match the other playwright workflows; bench showed flat scaling above 8."
required: false
type: string
default: "8"
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
playwright-e2e:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -36,7 +26,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Build frontend (production bundle for vite preview)
@@ -44,11 +34,22 @@ jobs:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run stubbed E2E tests (chromium)
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:stubbed -- --workers=3
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-stubbed-${{ github.run_id }}
path: frontend/editor/playwright-report/
path: frontend/playwright-report/
retention-days: 7
+60
View File
@@ -0,0 +1,60 @@
name: Frontend a11y regression gate
# Reusable workflow called from build.yml when frontend sources change.
#
# Scans the stories this branch touches in real Chromium and runs axe against
# each. Existing violations are grandfathered in .storybook/a11y-baseline.json;
# the check fails on a NEW violation — a story breaking a rule it wasn't already
# breaking — or on a story that fails to render at all.
#
# Only changed stories, because a full sweep is ~30 minutes: far too slow to sit
# in front of every merge. The whole suite is scanned nightly instead
# (nightly.yml), which catches anything a branch didn't touch.
#
# Advisory for now: this is not in build.yml's all-checks-passed list, so a
# failure reports without blocking. Promote it once a few weeks of runs show the
# pass/fail is stable.
on:
workflow_call:
permissions:
contents: read
jobs:
frontend-a11y:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Need the base branch too, to diff against it.
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: a11y gate (changed stories)
run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }}
- name: Upload scan reports
# The reports carry the offending selector and help text for each
# violation; without them a red run can only be understood by
# reproducing the whole scan locally.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: a11y-scan-${{ github.run_id }}
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
@@ -19,13 +19,9 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
files-changed:
name: detect what files changed
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
@@ -48,8 +44,8 @@ jobs:
generate-frontend-license-report:
if: needs.files-changed.outputs.licenses-frontend == 'true'
name: Generate Frontend License Report
needs: [pick, files-changed]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
@@ -97,7 +93,14 @@ jobs:
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Generate frontend license report (Push only)
if: github.event_name == 'push'
env:
PR_IS_FORK: "false"
run: task frontend:licenses:generate
- name: Generate frontend license report (internal PR)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
env:
@@ -292,7 +295,10 @@ jobs:
base: main
title: "Update Frontend 3rd Party Licenses"
body: ${{ env.PR_BODY }}
labels: Licenses,github-actions,frontend
labels: |
Licenses
github-actions
Front End
draft: false
delete-branch: true
sign-commits: true
@@ -311,15 +317,13 @@ jobs:
generate-backend-license-report:
if: needs.files-changed.outputs.licenses-backend == 'true'
needs: [pick, files-changed]
needs: files-changed
name: Generate Backend License Report
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write # Required for enabling automerge
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -347,12 +351,13 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check licenses and generate report
id: license-check
run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
@@ -512,7 +517,10 @@ jobs:
base: main
title: "Update Backend 3rd Party Licenses"
body: ${{ env.PR_BODY }}
labels: Licenses,github-actions,backend
labels: |
Licenses
github-actions
Back End
delete-branch: true
sign-commits: true
+3 -7
View File
@@ -11,12 +11,8 @@ permissions:
pull-requests: write
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
frontend-validation:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -31,7 +27,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Quality-check frontend
id: frontend-check
run: task frontend:check:all
@@ -121,7 +117,7 @@ jobs:
run: task frontend:test:coverage
- name: Set up Python for coverage summary
if: always()
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage summary
+38 -28
View File
@@ -36,13 +36,9 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
determine-matrix:
if: ${{ vars.CI_PROFILE != 'lite' }}
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }}
@@ -61,7 +57,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
@@ -71,12 +67,12 @@ jobs:
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Get version number
id: versionNumber
run: |
@@ -112,10 +108,8 @@ jobs:
fi
build-jars:
needs: [pick, determine-matrix]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
needs: determine-matrix
runs-on: ubuntu-latest
strategy:
matrix:
variant:
@@ -146,9 +140,9 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -159,7 +153,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build JAR
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
@@ -195,7 +189,6 @@ jobs:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -250,12 +243,12 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
# Build the universal JRE before desktop:prepare so the jlink:runtime
# task short-circuits on its `test -d runtime/jre` status check.
@@ -510,6 +503,7 @@ jobs:
# cargo output unsigned, so checking it produces false negatives.
- name: Verify Windows Code Signature
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
timeout-minutes: 15
shell: pwsh
run: |
$allSigned = $true
@@ -531,11 +525,26 @@ jobs:
# Extract MSI and verify the inner exe (the file that actually gets installed).
# This is the critical check - AV flags the installed exe at runtime.
# Use lessmsi, not `msiexec /a`: msiexec serializes on the global
# _MSIExecute mutex and hangs forever on hosted runners when another
# installer is busy. lessmsi reads MSI tables directly - no mutex, no service.
$msi = $msiFiles[0].FullName
$extractDir = Join-Path $env:RUNNER_TEMP "msi-verify"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
$proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow
if ($proc.ExitCode -eq 0) {
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
choco install lessmsi -y --no-progress --limit-output | Out-Null
# Bound the extraction and kill on hang (defence in depth over timeout-minutes).
$proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow
if (-not $proc.WaitForExit(120000)) {
try { $proc.Kill() } catch {}
Write-Host "[ERROR] MSI extraction timed out after 120s"
$allSigned = $false
} elseif ($proc.ExitCode -ne 0) {
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
$allSigned = $false
} else {
$innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1
if ($innerExe) {
$sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName
@@ -548,9 +557,6 @@ jobs:
Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI"
$allSigned = $false
}
} else {
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
$allSigned = $false
}
if (-not $allSigned) {
@@ -625,8 +631,8 @@ jobs:
retention-days: 1
collect-and-release:
needs: [pick, determine-matrix, build, build-jars]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
needs: [determine-matrix, build, build-jars]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
@@ -800,7 +806,11 @@ jobs:
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
# Don't regenerate/append notes on re-runs, and don't force this into the
# "Latest" slot - leave the release body and latest marker as they are.
generate_release_notes: false
append_body: false
make_latest: false
fail_on_unmatched_files: true
# Installers + updater payloads + manifest. .sig contents are embedded
# in latest.json so the .sig files themselves are not uploaded.
+65 -8
View File
@@ -13,13 +13,9 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
playwright-all-browsers:
name: Playwright (chromium + firefox + webkit)
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -37,10 +33,15 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install all Playwright browsers
run: task e2e:install
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run E2E tests (all browsers)
run: task e2e:cross-browser
@@ -48,6 +49,62 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-nightly-${{ github.run_id }}
path: frontend/editor/playwright-report/
name: playwright-report-nightly-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 14
# Whole-suite accessibility sweep. Pull requests only scan the stories they
# touch (frontend-a11y.yml) because a full pass takes ~30 minutes; this covers
# everything else, so a violation introduced by a change somewhere other than
# the story itself — a shared component, a theme token — still surfaces within
# a day.
a11y-all-stories:
name: a11y (every story)
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: a11y gate (every story)
run: task frontend:storybook:a11y
- name: Upload scan reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: a11y-scan-nightly-${{ github.run_id }}
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.
warm-tauri-cache:
name: Warm Tauri Rust cache
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/tauri-build.yml
with:
platform: all
sign: false
secrets: inherit
+159
View File
@@ -0,0 +1,159 @@
name: PR conflict labeler
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- edited
- ready_for_review
schedule:
- cron: "17 */6 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pr-conflict-labeler-${{ github.event.pull_request.number || 'all-open-prs' }}
cancel-in-progress: false
env:
CONFLICT_LABEL: "has conflicts"
jobs:
label-conflicts:
name: Label conflicted PRs
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Check out the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up stirling-bot token
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Apply conflict label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const conflictLabel = process.env.CONFLICT_LABEL;
const owner = context.repo.owner;
const repo = context.repo.repo;
const eventPullRequest = context.payload.pull_request;
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function getPullRequestWithMergeableState(pullNumber) {
for (let attempt = 1; attempt <= 6; attempt += 1) {
const { data: pull } = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
if (pull.mergeable !== null) {
return pull;
}
core.info(`PR #${pullNumber}: mergeable is not ready yet (attempt ${attempt}/6).`);
await sleep(5000);
}
const { data: pull } = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
return pull;
}
async function ensureConflictLabel() {
try {
await github.rest.issues.getLabel({
owner,
repo,
name: conflictLabel,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner,
repo,
name: conflictLabel,
color: 'D93F0B',
description: 'Pull request has merge conflicts with the base branch',
});
core.info(`Created '${conflictLabel}' label.`);
}
}
async function labelPullRequest(pull) {
const existingLabels = pull.labels.map((label) => label.name);
const hasConflictLabel = existingLabels.includes(conflictLabel);
const hasConflicts = pull.mergeable === false && pull.mergeable_state === 'dirty';
if (hasConflicts && !hasConflictLabel) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pull.number,
labels: [conflictLabel],
});
core.info(`Added '${conflictLabel}' to PR #${pull.number}.`);
return;
}
if (!hasConflicts && hasConflictLabel) {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pull.number,
name: conflictLabel,
});
core.info(`Removed '${conflictLabel}' from PR #${pull.number}.`);
return;
}
core.info(`PR #${pull.number}: no label change needed (mergeable=${pull.mergeable}, mergeable_state=${pull.mergeable_state}).`);
}
await ensureConflictLabel();
let pullNumbers;
if (eventPullRequest) {
pullNumbers = [eventPullRequest.number];
} else {
const pulls = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
pullNumbers = pulls.map((pull) => pull.number);
core.info(`Checking ${pullNumbers.length} open PR(s).`);
}
for (const pullNumber of pullNumbers) {
const pull = await getPullRequestWithMergeableState(pullNumber);
await labelPullRequest(pull);
}
+10 -24
View File
@@ -1,8 +1,7 @@
name: Pre-commit
# Runs `pre-commit run` for ruff / codespell / gitleaks / EOF / trailing-ws.
# Called from build.yml on PRs and merge_group; also runnable on demand via
# workflow_dispatch for manual local-equivalent linting.
# Runs the repo-wide lint/format/secret checks via `task pre-commit`.
# Called from build.yml on PRs and merge_group; also runnable on demand via workflow_dispatch.
on:
workflow_call:
workflow_dispatch:
@@ -13,10 +12,6 @@ permissions:
jobs:
pre-commit:
runs-on: ubuntu-latest
env:
# Prevents sdist builds → no tar extraction
PIP_ONLY_BINARY: ":all:"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -29,23 +24,14 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
python-version: 3.12
cache: "pip" # caching pip dependencies
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
enable-cache: true
cache-suffix: pre-commit
- name: Run Pre-Commit Hooks
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Run Pre-Commit
run: |
pre-commit run ruff --all-files -c .pre-commit-config.yaml
pre-commit run ruff-format --all-files -c .pre-commit-config.yaml
pre-commit run codespell --all-files -c .pre-commit-config.yaml
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
git diff --exit-code
- name: Run pre-commit checks
run: task pre-commit
+2 -2
View File
@@ -75,7 +75,7 @@ jobs:
- name: Generate tags for base image
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
@@ -85,7 +85,7 @@ jobs:
- name: Build and push base image
id: build-push-base
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: docker/base
+75 -13
View File
@@ -13,6 +13,11 @@ on:
required: false
type: boolean
default: true
build_engine:
description: "Build & push the stirling-pdf-engine image (plus the -docparse addon variant)."
required: false
type: boolean
default: false
force_unoserver_rebuild:
description: "Rebuild stirling-unoserver even if its source hash is unchanged."
required: false
@@ -51,6 +56,8 @@ jobs:
env:
RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }}
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
# Engine images are dispatch-only for now; flip the default once the addon stabilises.
RUN_ENGINE: ${{ github.event_name == 'workflow_dispatch' && inputs.build_engine }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -66,7 +73,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
@@ -76,16 +83,16 @@ jobs:
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
@@ -129,7 +136,7 @@ jobs:
- name: Generate tags for latest
id: meta
if: env.RUN_MAIN_APP == 'true'
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -139,13 +146,12 @@ jobs:
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testMain' }}
- name: Build and push Unified Dockerfile (latest variant)
id: build-push-latest
# Empty-tag guard: build-push-action errors when asked to push with no tags.
if: env.RUN_MAIN_APP == 'true' && steps.meta.outputs.tags != ''
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
@@ -155,9 +161,9 @@ jobs:
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# No BASE_VERSION pin: inherit the Dockerfile ARG default (single source of truth).
build-args: |
VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
BASE_VERSION=1.0.0
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
@@ -178,7 +184,7 @@ jobs:
- name: Generate tags for latest-fat
id: meta-fat
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
@@ -192,7 +198,7 @@ jobs:
- name: Build and push Unified Dockerfile (fat variant)
id: build-push-fat
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-fat.outputs.tags != ''
with:
builder: ${{ steps.buildx.outputs.name }}
@@ -220,9 +226,65 @@ jobs:
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
- name: Generate tags for engine
id: meta-engine
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
if: env.RUN_ENGINE == 'true'
with:
images: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-engine
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-engine
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
type=raw,value=latest
- name: Build and push engine image
id: build-push-engine
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
if: env.RUN_ENGINE == 'true' && steps.meta-engine.outputs.tags != ''
with:
builder: ${{ steps.buildx.outputs.name }}
context: ./engine
push: true
cache-from: type=gha,scope=stirling-pdf-engine
cache-to: type=gha,mode=max,scope=stirling-pdf-engine
tags: ${{ steps.meta-engine.outputs.tags }}
labels: ${{ steps.meta-engine.outputs.labels }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Generate tags for engine docparse addon
id: meta-engine-docparse
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
if: env.RUN_ENGINE == 'true'
with:
images: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-engine
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-engine
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-docparse
type=raw,value=latest-docparse
- name: Build and push engine docparse addon image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
if: env.RUN_ENGINE == 'true' && steps.meta-engine-docparse.outputs.tags != ''
with:
builder: ${{ steps.buildx.outputs.name }}
context: ./engine
push: true
cache-from: type=gha,scope=stirling-pdf-engine-docparse
cache-to: type=gha,mode=max,scope=stirling-pdf-engine-docparse
tags: ${{ steps.meta-engine-docparse.outputs.tags }}
labels: ${{ steps.meta-engine-docparse.outputs.labels }}
build-args: DOCPARSE=true
platforms: linux/amd64
provenance: true
sbom: true
- name: Generate tags for ultra-lite
id: meta-lite
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
@@ -236,7 +298,7 @@ jobs:
- name: Build and push Unified Dockerfile (ultra-lite variant)
id: build-push-lite
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-lite.outputs.tags != ''
with:
builder: ${{ steps.buildx.outputs.name }}
@@ -365,7 +427,7 @@ jobs:
- name: Build and push unoserver image
id: build-push-unoserver
if: env.RUN_UNOSERVER == 'true' && steps.unoserverDecision.outputs.mode != 'skip'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
egress-policy: audit
- name: 30 days stale issues
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
+4 -10
View File
@@ -22,15 +22,9 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
push:
if: ${{ vars.CI_PROFILE != 'lite' }}
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -46,9 +40,9 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
@@ -63,7 +57,7 @@ jobs:
SWAGGERHUB_USER: "Frooodle"
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
+95
View File
@@ -0,0 +1,95 @@
name: Sync Portal Docs
# Regenerates the portal Developer Docs manifest from the Stirling docs repo and
# opens a PR when it changes. Runs weekly, on manual dispatch, or when the docs
# repo fires a `docs-updated` repository_dispatch.
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
inputs:
ref:
description: "Docs repo ref (branch or tag) to sync from"
required: false
default: "main"
repository_dispatch:
types: [docs-updated]
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions:
contents: read
jobs:
sync:
name: Sync docs manifest
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
env:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Regenerate docs manifest
working-directory: frontend
env:
DOCS_REF: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'main' }}
GITHUB_TOKEN: ${{ steps.setup-bot.outputs.token }}
run: npm run docs:sync
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Sync portal docs from docs repo"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync-portal-docs
base: main
title: "Sync portal docs from docs repo"
body: |
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot].
Regenerates `frontend/editor/src/portal/generated/docsManifest.json`
from the Stirling docs repo via `npm run docs:sync`.
labels: |
Documentation
github-actions
Front End
add-paths: frontend/editor/src/portal/generated/docsManifest.json
delete-branch: true
sign-commits: true
+14 -4
View File
@@ -10,6 +10,7 @@ on:
- "app/common/build.gradle"
- "app/core/build.gradle"
- "app/proprietary/build.gradle"
- "gradle/spotless.gradle"
- "README.md"
- "frontend/editor/public/locales/*/translation.toml"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
@@ -51,22 +52,31 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Install Python dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
cache-suffix: sync-files
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Sync translation TOML files
run: |
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
- name: pre-commit run
- name: Sort translation TOML files
run: |
pre-commit run toml-sort-fix --all-files
task pre-commit:toml-sort FIX=1
- name: Commit translation files
run: |
+85 -28
View File
@@ -12,14 +12,24 @@ 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"
sign:
description: "Sign and notarize the bundles."
required: false
type: boolean
default: true
minimal:
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass. Used by PR builds."
required: false
type: boolean
default: false
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
@@ -28,6 +38,17 @@ on:
- windows
- macos
- linux
- windows-macos
sign:
description: "Sign and notarize the bundles."
required: false
default: true
type: boolean
minimal:
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass."
required: false
default: false
type: boolean
permissions:
contents: read
@@ -56,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
@@ -86,7 +108,6 @@ jobs:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -115,6 +136,20 @@ jobs:
toolchain: stable
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
# Cache the Cargo registry and compiled dependency crates so the build
# only recompiles the app crate. Written on main; PRs and the merge queue
# restore from it.
- name: Cache Rust build
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: frontend/editor/src-tauri
# Stable key shared across workflows so the nightly warmer.
# rust-cache still appends OS + rustc + Cargo.lock.
shared-key: tauri-${{ matrix.name }}
save-if: ${{ github.ref == 'refs/heads/main' }}
# Save the dependency cache even if a later step fails
cache-on-failure: true
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
@@ -134,12 +169,12 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
- name: Setup Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build universal macOS JRE
if: matrix.platform == 'macos-15'
@@ -163,7 +198,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -173,7 +208,7 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -208,7 +243,7 @@ jobs:
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
@@ -239,7 +274,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -260,7 +295,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -283,7 +318,7 @@ jobs:
ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Preflight smctl
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -296,7 +331,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" }
- name: Configure Windows code signing
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -315,7 +350,7 @@ jobs:
EOF
- name: Import release GPG signing key (Linux)
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
if: inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
run: |
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-secret-keys --keyid-format=long
@@ -332,7 +367,8 @@ jobs:
exit 1
fi
- name: Build Tauri app
- name: Build Tauri app (signed)
if: inputs.sign
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -361,20 +397,41 @@ jobs:
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here. AppImage runs in its own
# continue-on-error step below so its persistent linuxdeploy
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
- name: Build Tauri app (unsigned)
if: ${{ !inputs.sign }}
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: "0"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
CI: true
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
# AppImage is decoupled so its linuxdeploy run gets a fresh process
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
# Skipped on minimal smoke builds (flaky + slow, deb is enough to verify).
- name: Build Tauri app (Linux AppImage)
if: matrix.platform == 'ubuntu-22.04'
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
continue-on-error: true
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -389,7 +446,7 @@ jobs:
args: --bundles appimage
- name: Clear release GPG key from runner keyring (Linux)
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
env:
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
run: |
@@ -399,7 +456,7 @@ jobs:
fi
- name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15'
if: inputs.sign && matrix.platform == 'macos-15'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/editor/src-tauri/target
@@ -437,7 +494,7 @@ jobs:
# Verify the MSI AND the inner exe extracted from it are signed.
# The inner exe is what gets installed on users' machines and what AV scans.
- name: Verify Windows Code Signature
if: matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
shell: pwsh
run: |
$allSigned = $true
+38 -72
View File
@@ -12,19 +12,16 @@ on:
required: false
type: string
default: "false"
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
dockerfiles-changed:
description: "Whether any Dockerfile changed (forwarded from files-changed). Gates the slow arm64 build leg."
required: false
type: string
default: "8"
default: "false"
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
# TODO: extract a pre-matrix `prepare` job that runs once and produces
# shared artifacts for the three matrix entries below to consume:
# 1. `task backend:build` — currently runs 3× in parallel with
@@ -40,14 +37,7 @@ jobs:
# spring-security=true matrix entry if `task backend:build` and
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
test-build-docker-images:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
@@ -95,7 +85,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -104,13 +94,13 @@ jobs:
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@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
cache-disabled: true
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build application
run: task backend:build
env:
@@ -120,16 +110,10 @@ jobs:
DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: false
- name: Set up Depot CLI
if: env.USE_DEPOT == 'true'
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
- name: Set up QEMU
if: env.USE_DEPOT != 'true'
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
if: env.USE_DEPOT != 'true'
id: buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
@@ -146,32 +130,42 @@ jobs:
# GITHUB_EVENT_NAME is already provided by the runner.
env:
DOCKER_BASE_CHANGED: ${{ inputs.docker-base-changed }}
DOCKERFILES_CHANGED: ${{ inputs.dockerfiles-changed }}
run: |
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$DOCKER_BASE_CHANGED" = "true" ]; then
# Base Dockerfile changed: build against the locally-built base,
# which only exists for amd64.
echo "base_image=stirling-pdf-base:pr-test" >> "$GITHUB_OUTPUT"
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
else
elif [ "$DOCKERFILES_CHANGED" = "true" ]; then
# A Dockerfile changed: also verify the arm64 build (slow QEMU leg).
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
else
# No Dockerfile change: amd64 only. arm64 is exercised on the base
# image publish and on release, not on every code PR.
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
fi
- name: Build ${{ matrix.docker-rev }} (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./${{ matrix.docker-rev }}
push: false
platforms: ${{ steps.build-params.outputs.platforms }}
build-args: |
BASE_IMAGE=${{ steps.build-params.outputs.base_image }}
provenance: true
sbom: true
# Base-changed PRs build the embedded image with the local docker driver
# so the locally-built stirling-pdf-base:pr-test (in the daemon image
# store) resolves. A buildx container builder cannot see it and would try
# to pull it from a registry, which fails. Single-platform, no gha cache.
- name: Build ${{ matrix.docker-rev }} against local base (PR base change)
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
run: |
DOCKER_BUILDKIT=1 docker build \
--build-arg BASE_IMAGE=${{ steps.build-params.outputs.base_image }} \
--file ./${{ matrix.docker-rev }} \
--tag stirling-pdf-embedded:pr-test \
.
- name: Build ${{ matrix.docker-rev }} (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
# PRs that did NOT change the base use the buildx container builder
# (multi-platform + gha cache) against the published base image.
- name: Build ${{ matrix.docker-rev }}
if: inputs.docker-base-changed != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
@@ -198,14 +192,7 @@ jobs:
if-no-files-found: warn
test-build-unoserver-image:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -215,36 +202,15 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Depot CLI
if: env.USE_DEPOT == 'true'
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
- name: Set up QEMU
if: env.USE_DEPOT != 'true'
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
if: env.USE_DEPOT != 'true'
id: buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build docker/unoserver/Dockerfile (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./docker/unoserver/Dockerfile
push: false
load: true
platforms: linux/amd64
tags: stirling-unoserver:pr-test
provenance: false
sbom: false
- name: Build docker/unoserver/Dockerfile (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build docker/unoserver/Dockerfile
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
+10 -39
View File
@@ -20,19 +20,9 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
deploy:
if: ${{ vars.CI_PROFILE != 'lite' }}
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -49,9 +39,9 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.1
- name: Build with Gradle
run: ./gradlew build
@@ -61,12 +51,7 @@ jobs:
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true
- name: Set up Depot CLI
if: env.USE_DEPOT == 'true'
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
- name: Set up Docker Buildx
if: env.USE_DEPOT != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Get version number
@@ -81,21 +66,8 @@ jobs:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push test image (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64
- name: Build and push test image (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build and push test image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/embedded/Dockerfile
@@ -153,8 +125,7 @@ jobs:
files-changed:
if: always()
name: detect what files changed
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
frontend: ${{ steps.changes.outputs.frontend }}
@@ -174,8 +145,8 @@ jobs:
test:
if: needs.files-changed.outputs.frontend == 'true'
needs: [pick, deploy, files-changed]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
needs: [deploy, files-changed]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -208,8 +179,8 @@ jobs:
FORCE_COLOR: "3"
cleanup:
needs: [pick, deploy, test]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
needs: [deploy, test]
runs-on: ubuntu-latest
if: always()
steps:
+8
View File
@@ -46,6 +46,12 @@ app/core/storage/
# These are generated by npm build and should not be committed
app/core/src/main/resources/static/assets/
app/core/src/main/resources/static/index.html
# Prerendered per-route SPA pages (OG/social-preview), e.g. compress.html. api-landing.html is source.
app/core/src/main/resources/static/*.html
!app/core/src/main/resources/static/api-landing.html
!app/core/src/main/resources/static/mobile-upload.html
# Prerendered nested-route pages (e.g. settings/people.html)
app/core/src/main/resources/static/settings/
app/core/src/main/resources/static/locales/
app/core/src/main/resources/static/Login/
app/core/src/main/resources/static/classic-logo/
@@ -53,6 +59,8 @@ app/core/src/main/resources/static/modern-logo/
app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/og-metadata.json
app/core/src/main/resources/static/sw-folder-retry.js
app/core/src/main/resources/static/robots.txt
app/core/src/main/resources/static/pdfium/
app/core/src/main/resources/static/pdfjs/
+16 -1
View File
@@ -1,4 +1,4 @@
# PostHog project-level key phc_ prefix keys are public/client-side by design
# PostHog project-level key - phc_ prefix keys are public/client-side by design
# (PostHog client-side tracking embeds them in the browser bundle). Committed
# intentionally in #6150 so engine/.env has a working default, with real
# credentials overridden via engine/.env.local.
@@ -12,3 +12,18 @@ app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiK
testing/compose/docker-compose-keycloak-mcp.yml:generic-api-key:25
testing/compose/validate-mcp-apikey.sh:curl-auth-header:73
testing/compose/validate-mcp-test.sh:curl-auth-header:92
testing/compose/validate-mcp-test.sh:curl-auth-header:116
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
.github/workflows/tauri-build.yml:generic-api-key:402
+5
View File
@@ -0,0 +1,5 @@
{
"ignoredFiles": [
"frontend/editor/src-tauri/icons/icon.png"
]
}
+12 -50
View File
@@ -1,52 +1,14 @@
# The actual checks live in .taskfiles/pre-commit.yml (with helper scripts under
# scripts/pre-commit/) and are driven by Task. This hook just delegates to `task
# pre-commit` so the git pre-commit hook, CI and a manual `task pre-commit` all
# run the exact same thing. Requires `task` and `uv` on PATH. To auto-fix instead
# of only checking, run `task pre-commit:fix`.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.14
- repo: local
hooks:
- id: ruff
args:
- --fix
- --line-length=127
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- id: ruff-format
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- repo: https://github.com/codespell-project/codespell
rev: v2.4.2
hooks:
- id: codespell
args:
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
files: \.(html|css|js|py|md)$
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|frontend/editor/public/vendor|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.0
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: end-of-file-fixer
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
- id: trailing-whitespace
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
- repo: https://github.com/pappasam/toml-sort
rev: v0.24.4
hooks:
- id: toml-sort-fix
files: frontend/editor/public/locales/.*\.toml$
args: ['--in-place', '--all', '--ignore-case']
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
# rev: v16.21.1
# hooks:
# - id: stylelint
# additional_dependencies:
# - stylelint@16.21.1
# - stylelint-config-standard@38.0.0
# - "@stylistic/stylelint-plugin@3.1.3"
# files: \.(css)$
# args: [--fix]
- id: task-pre-commit
name: task pre-commit
entry: task pre-commit
language: system
pass_filenames: false
always_run: true
+25 -2
View File
@@ -18,17 +18,34 @@ version: '3'
tasks:
dev:
desc: "Start backend dev server"
cmds:
- task: dev:proprietary
vars:
PORT: '{{.PORT}}'
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
# `dotenv:` reads from the root Taskfile's directory (".") because this
# subtaskfile is included with `dir: .`. Local overrides in
# .env.proprietary.local win over the committed .env.proprietary defaults.
dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
@@ -50,9 +67,15 @@ tasks:
PORT: '{{.PORT | default "8080"}}'
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
PROFILES: '{{.PROFILES | default "dev"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
env:
SERVER_PORT: '{{.PORT}}'
STIRLING_FLAVOR: saas
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
platforms: [windows]
+48 -9
View File
@@ -5,6 +5,11 @@ vars:
# NoClassDefFoundError: jdk/dynalink/Namespace at runtime in get-info-on-pdf and verify-pdf
JLINK_MODULES: "java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported,jdk.dynalink"
# Minimum Java major the bundled JRE must be. Keep in sync with build.gradle
# `modernJavaVersion` - the app JAR is compiled for this, so an older runtime
# fails at launch with UnsupportedClassVersionError. Enforced by jlink:verify.
REQUIRED_JAVA: "25"
# Override via JPDFIUM_PLATFORMS env (csv of platform keys, or 'all').
JPDFIUM_PLATFORMS:
sh: |
@@ -102,6 +107,20 @@ tasks:
jlink:
desc: "Build backend JAR and create JLink runtime for Tauri"
deps: [jlink:jar, jlink:runtime]
# Runs after the runtime is in place. Lives here (not in jlink:runtime's
# cmds) so it still fires when jlink:runtime short-circuits on its `status:`
# check and reuses an existing runtime/jre - that reuse path is exactly how
# a stale, too-old JRE slips through.
cmds:
- task: jlink:verify
jlink:verify:
desc: "Fail the build if the bundled JRE is older than the app JAR requires"
dir: editor
env:
REQUIRED_JAVA: "{{.REQUIRED_JAVA}}"
cmds:
- node scripts/verify-bundled-jre.mjs src-tauri/runtime/jre/release
jlink:jar:
desc: "Build backend JAR for Tauri bundling (host-OS natives only by default)"
@@ -127,15 +146,35 @@ tasks:
cmds:
- rm -rf runtime/jre
- mkdir -p runtime
- |
JLINK_COMPRESS="$(jlink --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
jlink \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
# Pin jlink to JAVA_HOME so the bundled JRE matches the JDK the build
# uses. Bare `jlink` on PATH can resolve to an older system Java (the
# ubuntu runner ships Java 11), producing a runtime jlink:verify rejects.
#
# jdk.crypto.mscapi (the Windows certificate store / SunMSCAPI provider, used by
# hardware-backed cert signing) is a Windows-only module - it only exists in a Windows
# JDK's jmods, so it is added on Windows only or jlink fails to resolve it elsewhere.
- cmd: |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}},jdk.crypto.mscapi \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
platforms: [windows]
- cmd: |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
platforms: [linux, darwin]
# jlink emits its files mode 444 (read-only). Tauri's build-script
# resource copier preserves source permissions when staging
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
+5
View File
@@ -20,6 +20,11 @@ tasks:
cmds:
- docker build -t stirling-pdf-ultra-lite -f {{.EMBEDDED_DIR}}/Dockerfile.ultra-lite .
build:backend:
desc: "Build backend-only Docker image (no embedded frontend)"
cmds:
- docker build -t stirling-pdf-backend -f docker/backend/Dockerfile .
build:frontend:
desc: "Build frontend-only Docker image"
cmds:
+3 -16
View File
@@ -1,12 +1,5 @@
version: '3'
vars:
# Engine-specific names to avoid overriding the root Taskfile's FIND_FREE_PORT_*
# vars (Task merges included-file vars into the global scope).
# Paths are relative to the engine/ include dir.
ENGINE_FIND_FREE_PORT_SH: "bash ../scripts/find-free-port.sh"
ENGINE_FIND_FREE_PORT_PS: "powershell -NoProfile -File ../scripts/find-free-port.ps1"
tasks:
install:
desc: "Install engine dependencies"
@@ -36,14 +29,11 @@ tasks:
ignore_error: true
dir: src
vars:
# When PORT is provided (e.g. from dev:all), use it directly.
# When running standalone, probe for a free port starting at 5001.
PORT:
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
PORT: '{{.PORT | default "5001"}}'
env:
PYTHONUNBUFFERED: "1"
cmds:
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}}
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
dev:
desc: "Start engine dev server with hot reload"
@@ -51,10 +41,7 @@ tasks:
ignore_error: true
dir: src
vars:
# When PORT is provided (e.g. from dev:all), use it directly.
# When running standalone, probe for a free port starting at 5001.
PORT:
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
PORT: '{{.PORT | default "5001"}}'
env:
PYTHONUNBUFFERED: "1"
cmds:
+174 -31
View File
@@ -40,6 +40,21 @@ tasks:
cmds:
- node editor/scripts/generate-icons.js
prepare:og:
internal: true
run: when_changed
desc: "Regenerate OG/social-preview metadata from the tool registry"
cmds:
- node editor/scripts/generate-og-metadata.mjs
sources:
- editor/src/core/types/toolId.ts
- editor/src/core/utils/urlMapping.ts
- editor/src/core/data/useTranslatedToolRegistry.tsx
- editor/public/og_images/*.png
generates:
- editor/src/core/data/ogImageMap.json
- editor/public/og-metadata.json
prepare:
desc: "Set up dev environment"
run: when_changed
@@ -49,6 +64,7 @@ tasks:
- task: prepare:env
vars: { MODE: '{{.MODE}}' }
- prepare:icons
- prepare:og
# ============================================================
# Development
@@ -64,6 +80,13 @@ tasks:
OPEN: '{{.OPEN | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
# Dev-only browser-tab label so concurrent worktrees are distinguishable.
# Only the worktree folder basename (e.g. "wt1") is exposed — never the
# full path, hostname, or user. Consumed at dev-serve time by vite.config
# and dropped from production builds.
STIRLING_DEV_LABEL:
sh: >-
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
cmds:
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
@@ -112,12 +135,6 @@ tasks:
- task: dev:_run
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:portal:
desc: "Start developer portal dev server"
deps: [install]
cmds:
- npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}}
# ============================================================
# Build
# ============================================================
@@ -137,8 +154,10 @@ tasks:
build:proprietary:
desc: "Build for proprietary mode"
deps: [prepare]
vars:
PREVIEW: '{{.PREVIEW | default ""}}'
cmds:
- npx vite build editor --mode proprietary
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary'
build:saas:
desc: "Build for SaaS mode"
@@ -162,24 +181,77 @@ tasks:
cmds:
- npx vite build editor --mode prototypes
build:portal:
desc: "Build developer portal"
deps: [install]
cmds:
- npx vite build portal
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}}
storybook:browser:
internal: true
desc: "Install the Chromium build the story scan runs in"
run: once
deps: [install]
cmds:
- npx playwright install chromium
storybook:test:
desc: "Scan every story in real Chromium: it must render and pass axe"
deps: [prepare, storybook:browser]
cmds:
# Runs each story as a browser test. Pass a filter through, e.g.
# task frontend:storybook:test -- Button
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
storybook:a11y:
desc: "a11y regression gate over every story: fail only on NEW axe violations"
deps: [prepare, storybook:browser]
cmds:
- 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 the stories this branch affects (default base origin/main)"
summary: |
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: [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
echo "a11y: no story files affected vs {{.BASE}} — nothing to check"
exit 0
fi
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: [prepare, storybook:browser]
cmds:
- node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record
# ============================================================
# Code quality
# ============================================================
@@ -190,6 +262,23 @@ tasks:
cmds:
- task: lint:eslint
- task: lint:dpdm
- task: lint:colors
lint:colors:
desc: "Enforce theme tokens — no hardcoded colours or raw primitives in components"
aliases: [lint:colours]
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs
- node editor/scripts/lint/theme-lint.mjs css-colors
- node editor/scripts/lint/theme-lint.mjs code-colors
- node editor/scripts/lint/theme-lint.mjs no-primitives
contrast:
desc: "Report low-contrast theme token pairs (warning only, never blocks)"
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs contrast
lint:eslint:
desc: "Run ESLint linting"
@@ -202,8 +291,8 @@ tasks:
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers editor, portal, and the shared design system.
- npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
# shell-agnostic. Covers the whole editor tree, including the portal layer.
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
lint:fix:
desc: "Auto-fix lint issues"
@@ -234,17 +323,24 @@ tasks:
cmds:
- task: typecheck:proprietary
typecheck:_run:
internal: true
cmds:
- 'npx tsc --noEmit --project {{.PROJECT}}'
typecheck:core:
desc: "Typecheck core build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/core/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/core/tsconfig.json }
typecheck:proprietary:
desc: "Typecheck proprietary build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/proprietary/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/proprietary/tsconfig.json }
typecheck:saas:
desc: "Typecheck SaaS build variant"
@@ -252,7 +348,8 @@ tasks:
- task: prepare
vars: { MODE: saas }
cmds:
- npx tsc --noEmit --project editor/src/saas/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/saas/tsconfig.json }
typecheck:desktop:
desc: "Typecheck desktop build variant"
@@ -260,31 +357,36 @@ tasks:
- task: prepare
vars: { MODE: desktop }
cmds:
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/desktop/tsconfig.json }
typecheck:cloud:
desc: "Typecheck cloud shared layer (standalone)"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/cloud/tsconfig.json }
typecheck:scripts:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/scripts/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/scripts/tsconfig.json }
typecheck:prototypes:
desc: "Typecheck prototypes build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/prototypes/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/prototypes/tsconfig.json }
typecheck:portal:
desc: "Typecheck developer portal build variant"
deps: [install]
cmds:
- npx tsc --noEmit --project portal/tsconfig.json
typecheck:shared:
desc: "Typecheck the shared design system"
deps: [install]
cmds:
- npx tsc --noEmit --project shared/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/portal/tsconfig.json }
typecheck:all:
desc: "Typecheck all build variants"
@@ -293,10 +395,10 @@ tasks:
- task: typecheck:proprietary
- task: typecheck:saas
- task: typecheck:desktop
- task: typecheck:cloud
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
- task: typecheck:shared
# ============================================================
# Quality Gate
@@ -310,14 +412,21 @@ tasks:
- task: format:check
- task: test
og:check:
desc: "Fail if committed OG/social-preview metadata is out of date"
cmds:
- node editor/scripts/generate-og-metadata.mjs --check
check:all:
desc: "Full CI quality gate"
cmds:
# Runs first, before prepare regenerates: guards the committed og-metadata.json /
# ogImageMap.json that the Cloudflare Pages (plain `vite build`) deploy relies on.
- task: og:check
- task: typecheck:all
- task: lint
- task: format:check
- task: build
- task: build:portal
- task: test
- task: storybook:build
@@ -327,6 +436,11 @@ tasks:
test:
desc: "Run tests"
cmds:
- task: test:editor
test:editor:
desc: "Run editor tests"
deps: [prepare]
cmds:
- npx vitest run --root editor
@@ -362,8 +476,37 @@ tasks:
# Code Generation
# ============================================================
tool-models:
desc: "Generate tool API types from the Java OpenAPI spec"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts
sources:
- editor/scripts/generate-tool-api-types.mts
- ../SwaggerDoc.json
generates:
- editor/src/core/types/toolApiTypes.ts
tool-models:check:
desc: "Fail if committed tool API types are out of date"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --check
licenses:generate:
desc: "Generate frontend license report"
deps: [install]
cmds:
- node editor/scripts/generate-licenses.js
# ============================================================
# Clean
# ============================================================
clean:
desc: "Clean build artifacts and caches"
cmds:
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
platforms: [windows]
- cmd: rm -rf node_modules/.vite editor/dist dist
platforms: [linux, darwin]
+133
View File
@@ -0,0 +1,133 @@
version: '3'
# Repo-wide lint/format/secret checks - the single source of truth that the git
# pre-commit hook (.pre-commit-config.yaml) and CI (pre_commit.yml) both call.
vars:
# File selections as git pathspecs: git does the include/exclude matching, so
# there is no grep/xargs and it behaves identically on every platform.
PY_FILES: >-
'scripts/*.py'
'.github/scripts/*.py'
'app/core/src/main/resources/static/python/*.py'
':(exclude)*split_photos.py'
SPELL_FILES: >-
'*.html'
'*.css'
'*.js'
'*.py'
'*.md'
':(exclude).vscode/*'
':(exclude).devcontainer/*'
':(exclude)app/core/src/main/resources/*'
':(exclude)app/proprietary/src/main/resources/*'
':(exclude)frontend/editor/public/vendor/*'
':(exclude)*Dockerfile*'
':(exclude)*pdfjs*'
':(exclude)*thirdParty*'
':(exclude)*bootstrap*'
':(exclude)*.min.*'
':(exclude)*diff.js'
WS_FILES: >-
'*.js'
'*.java'
'*.py'
'*.yml'
':(exclude)*pdfjs*'
':(exclude)*thirdParty*'
':(exclude)*bootstrap*'
':(exclude)*.min.*'
':(exclude)*diff.js'
':(exclude).github/workflows/*'
LOCALE_TOML: 'frontend/editor/public/locales/*/translation.toml'
# gitleaks is pinned + checksum-verified by scripts/pre-commit/install_gitleaks.py,
# which owns the version and caches the binary here.
GITLEAKS_BIN: '.task/bin/gitleaks{{if eq OS "windows"}}.exe{{end}}'
tasks:
default:
desc: "Check formatting, spelling, and secrets across the repo"
cmds:
- task: ruff
- task: ruff-format
- task: codespell
- task: gitleaks
- task: whitespace
- task: toml-sort
fix:
desc: "Auto-fix formatting, spelling, and secrets issues across the repo"
cmds:
# Auto-fixers first, then the report-only tools (codespell, gitleaks) so a
# finding there does not stop the fixers from running.
- task: ruff
vars: { FIX: '1' }
- task: ruff-format
vars: { FIX: '1' }
- task: whitespace
vars: { FIX: '1' }
- task: toml-sort
vars: { FIX: '1' }
- task: codespell
- task: gitleaks
install:
desc: "Install the pinned pre-commit Python tools"
run: once
cmds:
- uv sync --project scripts/pre-commit --locked
sources:
- scripts/pre-commit/uv.lock
- scripts/pre-commit/pyproject.toml
status:
- test -d scripts/pre-commit/.venv
clean:
desc: "Remove the cached gitleaks binary and the tool virtualenv"
cmds:
- cmd: rm -rf scripts/pre-commit/.venv .task/bin/gitleaks
platforms: [linux, darwin]
- cmd: cmd /c "rmdir /s /q scripts\pre-commit\.venv & del /q .task\bin\gitleaks.exe"
platforms: [windows]
ignore_error: true
# Individual checks (hidden from `task --list`, but callable, e.g.
# `task pre-commit:toml-sort FIX=1`). Pass FIX=1 to auto-fix where supported.
ruff:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync ruff check --line-length=127 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
ruff-format:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync ruff format {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
codespell:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
toml-sort:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
whitespace:
cmds:
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
gitleaks:
deps: [gitleaks-bin]
# Scan staged changes only, matching the old hook: the git-mode fingerprints
# in .gitleaksignore (file:rule:line) still apply, and with nothing staged
# this is a no-op. Secrets are never auto-fixed, so FIX has no effect.
cmds:
- "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose"
gitleaks-bin:
internal: true
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
cmds:
- uv run --no-project python scripts/pre-commit/install_gitleaks.py
+31 -3
View File
@@ -139,7 +139,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Environment Variables
- All `VITE_*` variables must be declared in the appropriate committed env file:
- `frontend/editor/.env` — core, proprietary, and shared vars
- `frontend/editor/.env` — core and shared vars (base, loaded in every mode)
- `frontend/editor/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
- `frontend/editor/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
- `frontend/editor/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
- These files are committed to Git and must not contain private keys
@@ -152,7 +153,9 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
For a broader explanation of the frontend layering and override architecture, see [frontend/editor/DeveloperGuide.md](frontend/editor/DeveloperGuide.md).
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/editor/src/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`.
```typescript
// ✅ CORRECT - Use @app/* for all imports
@@ -169,7 +172,31 @@ import { useFileContext } from "@proprietary/contexts/FileContext";
- Building layer-specific override that wraps a lower layer's component
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/saas/desktop/cloud) and handles the fallback cascade — see "Frontend `cloud/` Layer" below for the full per-flavor order.
#### Frontend `cloud/` Layer
`@app/*` resolves through a per-flavor cascade — first existing file wins (shadow/override):
- **core** → core
- **proprietary** → proprietary → core
- **saas** → saas → cloud → proprietary → core
- **desktop** → desktop → cloud → proprietary → core
- **cloud** → cloud → proprietary → core
What goes where:
- **core** — OSS base.
- **proprietary** — licensed / offline features.
- **cloud** — the SHARED hosted/SaaS experience used by BOTH saas + desktop: PAYG, wallet, plan, billing, usage meters, cloud config/team/onboarding.
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
**Cloud feature flags on desktop.** The local `AppConfigContext` reads `/api/v1/config/app-config` from the LOCAL bundled backend, so cloud-only flags (`aiEngineEnabled`, `premiumEnabled`, …) are never seen on desktop. To read the cloud's view, use `useSaasAppConfig()` (`desktop/hooks/useSaasAppConfig.ts`, backed by the general `saasAppConfigService` — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns `null` outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. `useAiEngineEnabled()` (core reads `useAppConfig()`, desktop reads `useSaasAppConfig()`) — rather than hardcoding the flag on.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
@@ -428,6 +455,7 @@ The frontend is organized with a clear separation of concerns:
- **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately
- Translation files are located in `frontend/editor/public/locales/`
- After changing any translation file, run `task pre-commit:fix`
## Important Notes
+3 -3
View File
@@ -92,7 +92,7 @@ Visit the [Lombok website](https://projectlombok.org/setup/) for installation in
5. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
5. **Frontend Setup (Required for Stirling 2.0)**
6. **Frontend Setup (Required for Stirling 2.0)**
Navigate to the frontend directory and install dependencies using npm.
### Verify Setup
@@ -275,7 +275,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
1. Set the security environment variable:
```bash
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
export DISABLE_ADDITIONAL_FEATURES=true # or false to enable login and security features for builds
```
2. Build the project:
@@ -305,7 +305,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. However, to improve build times these can often be removed depending on your use case
## 7. Testing
+6 -2
View File
@@ -16,10 +16,14 @@ if that directory exists, is licensed under the license defined in "frontend/edi
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
* All content that resides under the "frontend/editor/src/cloud/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
* All content that resides under the "frontend/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
* All content that resides under the "frontend/editor/src/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE".
* All content that resides under the "frontend/editor/src/portal-saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal-saas/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
+3 -3
View File
@@ -53,14 +53,14 @@ For full installation options (including desktop and Kubernetes), see our [Docum
## Support
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- **Community**: [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [GitHub Issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
## Contributing
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task install` to get started, or see the [Developer Guide](DeveloperGuide.md) for full details.
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task dev` to get started running the editor, run `task` to see the most common commands, or see the [Developer Guide](DeveloperGuide.md) for full details.
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
+55 -8
View File
@@ -25,8 +25,28 @@ includes:
e2e:
taskfile: .taskfiles/e2e.yml
dir: .
pre-commit:
taskfile: .taskfiles/pre-commit.yml
dir: .
tasks:
# ============================================================
# Help (shown when you run `task` with no arguments)
# ============================================================
default:
desc: "List the most common commands"
silent: true
cmds:
- |
echo "Common commands (run 'task --list' to see all):"
echo ""
echo " task dev Start backend & frontend on free ports"
echo " task backend:dev Start backend on default port"
echo " task frontend:dev Start frontend on default port"
echo " task desktop:dev Start desktop app"
echo " task check Quality gate (lint, typecheck, test, etc.)"
# ============================================================
# Setup & Prerequisites
# ============================================================
@@ -58,26 +78,40 @@ tasks:
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
dev:portal:
desc: "Start backend + editor; the portal is an admin route at /portal"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev:saas
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
- task: frontend:dev:saas
SECURITY_ENABLELOGIN: "true"
- task: frontend:dev:proprietary
vars:
PORT: '{{.FRONTEND_PORT}}'
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
cmds:
- task: dev:_all
vars: { FRONTEND: saas, BACKEND: saas }
dev:all:
desc: "Start backend + frontend + engine concurrently on free ports"
cmds:
- task: dev:_all
dev:_all:
internal: true
vars:
FRONTEND: '{{.FRONTEND | default "proprietary"}}'
BACKEND: '{{.BACKEND | default "proprietary"}}'
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
@@ -87,11 +121,12 @@ tasks:
- task: engine:dev
vars:
PORT: '{{.ENGINE_PORT}}'
- task: backend:dev
- task: 'backend:dev:{{.BACKEND}}'
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
- task: frontend:dev
AIENGINE_ENABLED: "true"
- task: 'frontend:dev:{{.FRONTEND}}'
vars:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
@@ -149,6 +184,16 @@ tasks:
- task: frontend:format:check
- task: engine:format:check
# ============================================================
# Code generation
# ============================================================
tool-models:
desc: "Generate all API models from the Java OpenAPI spec"
cmds:
- task: frontend:tool-models
- task: engine:tool-models
# ============================================================
# Quality Gate
# ============================================================
@@ -175,4 +220,6 @@ tasks:
desc: "Clean all build artifacts"
cmds:
- task: backend:clean
- task: frontend:clean
- task: engine:clean
- task: pre-commit:clean
+8
View File
@@ -0,0 +1,8 @@
# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary
# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored).
# Combined-billing account link (Mode A). Feature-flagged: OFF until release.
# Flip to true in app/.env.proprietary.local to test linking locally.
STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false
# SaaS base URL the linked instance calls (register + entitlement).
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app
+1
View File
@@ -1,3 +1,4 @@
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
# stays ignored via the root .gitignore.
!.env.saas
!.env.proprietary
+32
View File
@@ -80,10 +80,18 @@
"moduleName": ".*",
"moduleLicense": "Apache License Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License, version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "The Apache License, Version 2.0"
@@ -108,6 +116,10 @@
"moduleName": ".*",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
},
{
"moduleName": ".*",
"moduleLicense": "Mozilla Public License Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "CDDL+GPL License"
@@ -172,6 +184,14 @@
"moduleName": ".*",
"moduleLicense": "Eclipse Public License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "EPL-2.0"
},
{
"moduleName": ".*",
"moduleLicense": "LGPL-2.1-only"
},
{
"moduleName": ".*",
"moduleLicense": "Ubuntu Font Licence 1.0"
@@ -188,6 +208,18 @@
"moduleName": ".*",
"moduleLicense": "The W3C License"
},
{
"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"
+7 -33
View File
@@ -2,47 +2,21 @@
bootRun {
enabled = false
}
spotless {
java {
target 'src/**/java/**/*.java'
targetExclude 'src/main/java/org/apache/**'
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
// google-java-format 1.28.0 bundles Guava 32.x which crashes Spotless lint on JDK 24/25
suppressLintsFor { setStep('google-java-format') }
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
yaml {
target '**/*.yml', '**/*.yaml'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
format 'gradle', {
target '**/gradle/*.gradle', '**/*.gradle'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
}
dependencies {
api 'com.google.guava:guava:33.6.0-jre'
api "com.google.guava:guava:${guavaVersion}"
api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-aspectj'
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1'
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
api 'org.apache.commons:commons-lang3:3.20.0'
api "org.apache.commons:commons-lang3:${commonsLang3}"
api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
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.5.10' // RAR archive support for CBR files
api 'com.github.junrar:junrar:7.6.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"
@@ -60,7 +34,7 @@ dependencies {
exclude group: 'com.google.code.gson', module: 'gson'
}
api 'com.stirling:jpdfium:1.0.2'
api "com.stirling:jpdfium:${jpdfiumVersion}"
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
@@ -75,12 +49,12 @@ dependencies {
}
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
jpdfiumPlatforms.each { platform ->
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.2"
runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}"
}
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
implementation "com.bucket4j:bucket4j_jdk17-core:${bucket4jVersion}"
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}"
}
@@ -433,6 +433,17 @@ public class EndpointConfiguration {
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
addEndpointToGroup("Automation", "pipeline");
// Adding endpoints to "DocParse" group (parsing, splitting, chunking, extraction,
// templating)
addEndpointToGroup("DocParse", "parse-document");
addEndpointToGroup("DocParse", "extract-fields");
addEndpointToGroup("DocParse", "smart-split");
addEndpointToGroup("DocParse", "chunk-document");
addEndpointToGroup("DocParse", "rag-ingest");
addEndpointToGroup("DocParse", "extract-tables");
addEndpointToGroup("DocParse", "suggest-schema");
addEndpointToGroup("DocParse", "fill-template");
// Adding endpoints to "DeveloperTools" group
addEndpointToGroup("DeveloperTools", "show-javascript");
@@ -132,7 +132,7 @@ public class AppConfig {
return true;
}
Path mountInfo = Path.of("/proc/1/mountinfo");
// this should always exist, if not some unknown usecase
// this should always exist, if not some unknown use case
if (!Files.exists(mountInfo)) {
return true;
}
@@ -77,6 +77,7 @@ public class ApplicationProperties {
private ProcessExecutor processExecutor = new ProcessExecutor();
private PdfEditor pdfEditor = new PdfEditor();
private AiEngine aiEngine = new AiEngine();
private Docparse docparse = new Docparse();
private Mcp mcp = new Mcp();
private InternalApi internalApi = new InternalApi();
private Cluster cluster = new Cluster();
@@ -208,9 +209,10 @@ public class ApplicationProperties {
public static class Policies {
/**
* Absolute directories that policy folder input sources and output sinks may read from or
* write to. Empty (the default) disables folder access entirely, so a policy can never be
* pointed at an arbitrary server path. Stirling's own config directory is always
* off-limits, and folder access is always disabled in SaaS mode regardless of this list.
* write to. Empty (the default) disables folder access except to implicitly defined
* folders, such as server storage folders (if enabled) and the pipeline watched folders.
* Stirling's own config directory is always off-limits, and folder access is always
* disabled in SaaS mode regardless of this list.
*/
private List<String> allowedFolderRoots = new java.util.ArrayList<>();
@@ -241,6 +243,37 @@ public class ApplicationProperties {
* and paused runs are kept regardless of age.
*/
private int runExpiryMinutes = 30;
/**
* Whether a policy S3 source's custom endpoint may resolve to a loopback, link-local, or
* private address. Off by default so a user-supplied endpoint cannot be pointed at internal
* services (e.g. the cloud metadata address); enable for a self-hosted MinIO or other
* in-network object store.
*/
private boolean allowPrivateS3Endpoints = false;
/**
* Whether an API/Purview/ConsignO integration's base URL may resolve to a loopback,
* link-local, or private address. Off by default: unlike S3 connections, any user may
* create one of these, so without this gate a user could point a connection at the cloud
* metadata address and have the server fetch it for them. Enable only when integrations
* genuinely live inside the network (e.g. an on-prem ConsignO or an internal API gateway).
*/
private boolean allowPrivateApiEndpoints = false;
/**
* Whether administrators may define their own API integrations - a free-form base URL,
* path, body and headers - as opposed to only using the built-in vendor presets (Purview,
* ConsignO, S3). On by default, and admin-only regardless: a custom integration can point
* the server at any host, so it is authoring power, not self-serve.
*
* <p>Turning this off stops new custom integrations being created or edited. Ones that
* already exist keep running, because a policy that silently stopped calling out would be a
* worse surprise than one that keeps working; disable the connection itself to stop it.
*/
private boolean allowCustomApiIntegrations = true;
private long webhookMaxBytes = 104857600L;
}
@Data
@@ -295,6 +328,120 @@ public class ApplicationProperties {
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
*/
private int longRunningTimeoutSeconds = 600;
/** Timeout (seconds) for the SSE stream held open by long-running orchestrator runs. */
private int streamTimeoutSeconds = 1800;
/**
* Whether the processor pushes settings-derived AI config to the engine on startup/save.
* Pin false for env-driven deployments (SaaS) to keep the engine env-controlled.
*/
private boolean pushConfigToEngine = true;
/** Model + provider selection, forwarded to the engine per-request. */
private Models models = new Models();
/** Retrieval-augmented-generation (RAG) knobs, forwarded to the engine per-request. */
private Rag rag = new Rag();
/** Request size / cost guardrails. */
private Limits limits = new Limits();
/** Per-capability on/off switches so an admin can disable individual AI tools. */
private Features features = new Features();
@Data
public static class Models {
/** Provider driving the model strings: 'anthropic', 'openai', 'ollama', or 'custom'. */
private String provider = "anthropic";
/** High-quality tier model name (without provider prefix), e.g. 'claude-haiku-4-5'. */
private String smartModel = "claude-haiku-4-5";
/** Cheap/fast tier model name (without provider prefix). */
private String fastModel = "claude-haiku-4-5";
private int smartMaxTokens = 8192;
private int fastMaxTokens = 2048;
/**
* API key for the selected provider (secret; masked). Empty means the engine uses its
* own env credential (e.g. ANTHROPIC_API_KEY).
*/
private String apiKey = "";
/**
* OpenAI-compatible base URL for 'ollama' / 'custom' providers (e.g.
* http://ollama:11434/v1). Ignored for anthropic/openai. SSRF-sensitive - admin only.
*/
private String baseUrl = "";
}
@Data
public static class Rag {
/**
* Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible).
*/
private String embeddingProvider = "voyageai";
/** Embedding model name (without provider prefix), e.g. 'voyage-4'. */
private String embeddingModel = "voyage-4";
/**
* Secret API key for the embedding provider; masked + env-overridable like
* models.apiKey.
*/
private String embeddingApiKey = "";
/**
* OpenAI-compatible base URL for 'ollama' / 'custom' embedding providers (e.g.
* http://ollama:11434/v1). Ignored for voyageai/openai. SSRF-sensitive - admin only.
*/
private String embeddingBaseUrl = "";
/** How many chunks retrieval returns per search. */
private int topK = 20;
/** Per-run cap on knowledge-search tool calls before the agent must answer. */
private int maxSearches = 5;
}
@Data
public static class Limits {
private int maxPages = 200;
private int maxCharacters = 200000;
/** Process-wide cap on concurrent model API calls (engine restart to apply). */
private int modelMaxConcurrency = 32;
}
@Data
public static class Features {
private boolean chat = true;
private boolean documentQuestions = true;
private boolean createPdf = true;
private boolean mathAuditor = true;
private boolean pdfComment = true;
private boolean classify = true;
}
}
/**
* DocParse settings (top-level {@code docparse.*}): document understanding for ingestion
* pipelines. The basic tier (text layer) always works; the advanced tier lives in the engine's
* docparse addon.
*/
@Data
public static class Docparse {
/** Master switch; hides the DocParse endpoints when false. */
private boolean enabled = true;
/** Requested tier: 'auto', 'basic', or 'advanced'. 'auto' resolves per document. */
private String mode = "auto";
/** Mirrors DOCPARSE_AUTO_INSTALL for the engine's boot-time addon install script. */
private boolean autoInstall = false;
}
/**
@@ -367,6 +514,15 @@ public class ApplicationProperties {
*/
private String resourceId = "";
/**
* Additional JWT audiences accepted at the MCP endpoint, on top of {@link #resourceId}.
* Empty (default) keeps strict RFC 8707 binding. Some IdPs cannot mint
* resource-specific audiences - e.g. Supabase's OAuth server always issues {@code
* aud=authenticated} - so operators list the audience their IdP actually emits here
* (env: {@code MCP_AUTH_ACCEPTEDAUDIENCES}, comma-separated).
*/
private List<String> acceptedAudiences = new ArrayList<>();
/**
* JWT claim whose value is matched against a provisioned Stirling username. Defaults to
* {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP
@@ -505,6 +661,14 @@ public class ApplicationProperties {
private String accessibilityStatement;
private String cookiePolicy;
private String impressum;
private LoginAgreement loginAgreement = new LoginAgreement();
@Data
public static class LoginAgreement {
private boolean enabled = false;
private boolean showInAnonymousMode = true;
private String fallbackText = "";
}
}
@Data
@@ -573,7 +737,7 @@ public class ApplicationProperties {
public static class SAML2 {
private String provider;
private Boolean enabled = false;
private Boolean autoCreateUser = false;
private Boolean autoCreateUser = true;
private Boolean blockRegistration = false;
private String registrationId = "stirling";
@@ -650,7 +814,7 @@ public class ApplicationProperties {
private String issuer;
private String clientId;
@ToString.Exclude private String clientSecret;
private Boolean autoCreateUser = false;
private Boolean autoCreateUser = true;
private Boolean blockRegistration = false;
private String useAsUsername;
private Collection<String> scopes = new ArrayList<>();
@@ -721,7 +885,6 @@ public class ApplicationProperties {
@Data
public static class Jwt {
private boolean enableKeystore = true;
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
/**
@@ -825,8 +988,8 @@ public class ApplicationProperties {
@Data
public static class Trust {
private boolean serverAsAnchor = true;
private boolean useSystemTrust = false;
private boolean useMozillaBundle = false;
private boolean useSystemTrust = true;
private boolean useMozillaBundle = true;
private boolean useAATL = false;
private boolean useEUTL = false;
}
@@ -860,8 +1023,8 @@ public class ApplicationProperties {
public static class System {
private String defaultLocale;
private boolean googlevisibility;
private boolean showUpdate;
private boolean showUpdateOnlyAdmin;
private boolean showUpdate = true;
private boolean showUpdateOnlyAdmin = true;
private boolean showSettingsWhenNoLogin = true;
private boolean customHTMLFiles;
private String tessdataDir;
@@ -869,10 +1032,10 @@ public class ApplicationProperties {
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Boolean enableDesktopInstallSlide;
private Boolean enableDesktopInstallSlide = true;
private Datasource datasource;
private boolean disableSanitize;
private int maxDPI;
private int maxDPI = 500;
private boolean enableUrlToPDF;
private Html html = new Html();
private CustomPaths customPaths = new CustomPaths();
@@ -886,8 +1049,9 @@ public class ApplicationProperties {
private String frontendUrl; // Frontend URL for invite email links (e.g.
// 'https://app.example.com'). If not set, falls back to backendUrl.
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
private ServerCertificate serverCertificate = new ServerCertificate();
@Data
public static class MobileScannerSettings {
@@ -897,6 +1061,16 @@ public class ApplicationProperties {
private boolean stretchToFit = false; // Whether to stretch image to fill page
}
@Data
public static class ServerCertificate {
private boolean enabled =
true; // Enable server-side "Sign with Stirling-PDF" certificate
private String organizationName = "Stirling PDF Inc";
private int validity = 365; // Certificate validity in days
private boolean regenerateOnStartup =
false; // Generate a new certificate on each startup
}
public boolean isAnalyticsEnabled() {
return this.enableAnalytics != null && this.enableAnalytics;
}
@@ -981,7 +1155,7 @@ public class ApplicationProperties {
@Data
public static class Sharing {
private boolean enabled = false;
private boolean linkEnabled = false;
private boolean linkEnabled = true;
private boolean emailEnabled = false;
private int linkExpirationDays = 3;
}
@@ -996,6 +1170,10 @@ public class ApplicationProperties {
@Data
public static class Signing {
private boolean enabled = false;
// Signing user-picker scope: 'org' (default) = whole instance, anything else =
// caller's team only (fail-closed). The saas profile pins 'team'.
private String userListScope = "org";
}
}
@@ -1151,7 +1329,7 @@ public class ApplicationProperties {
@Data
public static class Metrics {
private boolean enabled;
private boolean enabled = true;
}
@Data
@@ -1203,7 +1381,7 @@ public class ApplicationProperties {
private boolean enableInvites = false;
private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days)
private String host;
private int port;
private int port = 587;
private String username;
@ToString.Exclude private String password;
private String from;
@@ -1230,10 +1408,10 @@ public class ApplicationProperties {
@ToString.Exclude private String botToken;
private String botUsername;
private String pipelineInboxFolder = "telegram";
private Boolean customFolderSuffix = false;
private Boolean enableAllowUserIDs = false;
private Boolean customFolderSuffix = true;
private Boolean enableAllowUserIDs = true;
private List<Long> allowUserIDs = new ArrayList<>();
private Boolean enableAllowChannelIDs = false;
private Boolean enableAllowChannelIDs = true;
private List<Long> allowChannelIDs = new ArrayList<>();
private long processingTimeoutSeconds = 180;
private long pollingIntervalMillis = 2000;
@@ -4,6 +4,8 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -47,6 +49,9 @@ public class JobResult {
*/
private final List<String> notes = new CopyOnWriteArrayList<>();
/** Key/value metadata that survives the write-through into the shared job store. */
private final Map<String, String> metadata = new ConcurrentHashMap<>();
/**
* Create a new JobResult with the given job ID
*
@@ -161,4 +166,16 @@ public class JobResult {
public List<String> getNotes() {
return Collections.unmodifiableList(notes);
}
/** Attach a metadata value, e.g. a policy id so cluster peers can identify a policy run. */
public void putMetadata(String key, String value) {
if (key != null && value != null) {
this.metadata.put(key, value);
}
}
/** An unmodifiable view of this job's metadata. */
public Map<String, String> getMetadata() {
return Collections.unmodifiableMap(metadata);
}
}
@@ -0,0 +1,56 @@
package stirling.software.common.service;
/**
* Thread-scoped correlation id for one automation run — a single pipeline, policy, or AI-workflow
* execution over its input file(s).
*
* <p>Automations dispatch each tool step as a separate internal loopback POST via {@link
* InternalApiClient}. The orchestrator opens a run scope around its dispatch loop; {@code
* InternalApiClient} reads {@link #current()} and stamps it on every sub-step request as {@link
* #RUN_ID_HEADER}. The SaaS PAYG interceptor uses that header so all sub-steps of ONE run group
* into a single charge, while two <em>separate</em> runs that happen to touch identical bytes stay
* distinct charges (the old content+time-window grouping merged them).
*
* <p>Sub-steps dispatch synchronously on the orchestrator's own thread (loopback {@code
* RestTemplate}), so this ThreadLocal is visible to {@code InternalApiClient}. The id then crosses
* to the receiving request thread via the HTTP header — never via this ThreadLocal.
*
* <p>No-op when the id is absent (a standalone tool call): the interceptor treats a missing run id
* as "its own charge", which is exactly what a one-off call should be.
*/
public final class AutomationRunContext {
/** Header carrying the run id on internal sub-step dispatches. */
public static final String RUN_ID_HEADER = "X-Stirling-Run-Id";
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
private AutomationRunContext() {}
/**
* Opens a run scope on the current thread. Returns an {@link AutoCloseable} that restores the
* previously-active id (nesting-safe) — use in try-with-resources around the dispatch loop.
*/
public static Scope open(String runId) {
String previous = CURRENT.get();
CURRENT.set(runId);
return () -> {
if (previous == null) {
CURRENT.remove();
} else {
CURRENT.set(previous);
}
};
}
/** The run id active on this thread, or {@code null} when not inside a run scope. */
public static String current() {
return CURRENT.get();
}
/** AutoCloseable whose {@link #close()} declares no checked exception. */
public interface Scope extends AutoCloseable {
@Override
void close();
}
}
@@ -0,0 +1,16 @@
package stirling.software.common.service;
/**
* View of the engine's DocParse capability for modules that cannot see the proprietary
* implementation (e.g. ConfigController in core). Implemented by the proprietary
* DocparseCapabilityService; absent when the proprietary module is not loaded.
*/
public interface DocparseCapabilityServiceInterface {
/**
* Whether the engine reports the docparse addon (advanced tier) as installed. Must be cheap and
* non-blocking: returns the cached probe result, {@code false} when the engine is disabled,
* unreachable, or not yet probed.
*/
boolean isAdvancedInstalled();
}
@@ -8,6 +8,7 @@ import java.nio.file.Files;
import java.time.Duration;
import java.util.regex.Pattern;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.core.io.FileSystemResource;
@@ -45,11 +46,37 @@ public class InternalApiClient {
// The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are
// dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is
// intentionally NOT permitted to avoid plan steps re-entering the orchestrator.
//
// `/api/v1/integration/*` holds third-party steps (external API call, Purview labelling,
// ConsignO). They reach outside the JVM, so the namespace is deliberately kept to tools that
// dereference an admin-owned connection rather than a caller-supplied host — see
// ApiConnectionResolver.
private static final Pattern ALLOWED_ENDPOINT_PATH =
Pattern.compile(
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
"^/api/v1/(general|misc|security|convert|filter|integration|docparse)(/[A-Za-z0-9_-]+)+$"
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
/**
* Marker propagated on every internal sub-step dispatch so the saas PAYG interceptor classifies
* the call as {@code BillingCategory.AUTOMATION}. By construction every {@link
* InternalApiClient#post} caller is an automation surface (pipeline executor, AI workflow,
* policy runner) running a child tool inside a parent automation flow — see the saas {@code
* PaygChargeInterceptor.determineCategory} precedence chain, where this header dominates any
* per-tool {@code @RequiresFeature} annotation.
*/
public static final String AUTOMATION_HEADER = "X-Stirling-Automation";
/**
* Header carrying the parent policy's name onto each sub-step dispatch, read from MDC key
* {@link #POLICY_NAME_MDC_KEY} (set by the policy runner on the worker thread). Lets the audit
* layer attribute a tool step to the policy that ran it, instead of showing it as a bare direct
* call.
*/
public static final String POLICY_NAME_HEADER = "X-Stirling-Policy-Name";
/** MDC key the policy runner stamps with the running policy's name; forwarded as a header. */
public static final String POLICY_NAME_MDC_KEY = "auditPolicyName";
private final ServletContext servletContext;
private final UserServiceInterface userService;
private final TempFileManager tempFileManager;
@@ -96,6 +123,32 @@ public class InternalApiClient {
if (apiKey != null && !apiKey.isEmpty()) {
headers.add("X-API-KEY", apiKey);
}
// Tag the sub-step as automation so PAYG bills it under AUTOMATION regardless of which
// tool-level @RequiresFeature annotation the dispatched controller carries (e.g. an AI-OCR
// step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because
// every caller of this dispatcher is an automation surface by design.
headers.add(AUTOMATION_HEADER, "true");
// Propagate the current automation run id (set by the orchestrator around its dispatch
// loop) so the PAYG interceptor groups every sub-step of this one run into a single charge,
// and never merges two separate runs that happen to touch identical bytes. Absent → the
// receiving call is treated as standalone. See AutomationRunContext.
String runId = AutomationRunContext.current();
if (runId != null && !runId.isEmpty()) {
headers.add(AutomationRunContext.RUN_ID_HEADER, runId);
}
// Forward the parent policy name (set in MDC by the policy runner) so the audited sub-step
// ties back to its policy. Single-line, length-capped: it becomes an HTTP header value.
String policyName = MDC.get(POLICY_NAME_MDC_KEY);
if (policyName != null && !policyName.isBlank()) {
String safe = policyName.replaceAll("[\\r\\n]", " ").trim();
if (safe.length() > 200) {
safe = safe.substring(0, 200);
}
if (!safe.isEmpty()) {
headers.add(POLICY_NAME_HEADER, safe);
}
}
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
// without this RestTemplate would use urlencoded instead of the multipart the controller
@@ -0,0 +1,204 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
// Resolves login agreement text from customFiles/disclaimer/<locale>.md (read live);
// enable/visibility come from the legal.loginAgreement settings.
@Service
@Slf4j
public class LoginAgreementService {
// Locale codes only: rejects path separators and dots so the value can never escape the
// disclaimer directory. Matches e.g. en, en-GB, fr-FR, zh-Hant, pt-BR.
private static final Pattern LOCALE_PATTERN =
Pattern.compile("^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$");
// BCP-47 tags are well under this; the cap also prevents the regex's repetition group
// from recursing far enough to overflow the stack on a hostile over-length input.
private static final int MAX_LOCALE_LENGTH = 35;
// Disclaimers are short markdown; cap the read so an oversized file can't be loaded
// wholesale into heap on every public request.
private static final long MAX_FILE_BYTES = 256 * 1024;
private final ApplicationProperties applicationProperties;
public LoginAgreementService(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
public boolean isEnabled() {
return config().isEnabled();
}
public boolean isShowInAnonymousMode() {
return config().isShowInAnonymousMode();
}
/**
* Resolve the markdown to show for the requested language, falling back through the base
* language, the configured default locale (and its base), then the configured fallbackText.
* Returns an empty string when nothing is configured.
*/
public String resolveContent(String requestedLang) {
List<String> candidates = new ArrayList<>();
addLocaleCandidates(candidates, requestedLang);
addLocaleCandidates(candidates, applicationProperties.getSystem().getDefaultLocale());
for (String candidate : candidates) {
String content = readFileIfExists(candidate);
if (content != null && !content.isBlank()) {
return content;
}
}
String fallback = config().getFallbackText();
return fallback == null ? "" : fallback;
}
/**
* Admin read of a single locale's raw file. Returns null for an invalid locale, "" if absent.
*/
public String readRawForLocale(String locale) {
if (!isValidLocale(locale)) {
return null;
}
String content = readFileIfExists(locale);
return content == null ? "" : content;
}
/** Admin write. Blank content deletes the file so it falls back cleanly. */
public void writeForLocale(String locale, String content) throws IOException {
Path file = resolveLocaleFile(locale);
if (file == null) {
throw new IllegalArgumentException("Invalid locale: " + locale);
}
if (content == null || content.isBlank()) {
Files.deleteIfExists(file);
return;
}
Files.createDirectories(file.getParent());
// Write to a sibling temp file then atomically swap, so a concurrent reader (the public
// /login-disclaimer fetch is lockless) never observes a truncated/partial file.
Path tmp = Files.createTempFile(file.getParent(), "disclaimer", ".md.tmp");
try {
Files.writeString(tmp, content, StandardCharsets.UTF_8);
try {
Files.move(
tmp,
file,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(tmp);
}
}
/** Locales that currently have a markdown file, for the admin editor. */
public Set<String> listLocalesWithContent() {
Set<String> result = new TreeSet<>();
Path dir = disclaimerDir();
if (!Files.isDirectory(dir)) {
return result;
}
try (Stream<Path> files = Files.list(dir)) {
files.filter(Files::isRegularFile)
.map(path -> path.getFileName().toString())
.filter(name -> name.endsWith(".md"))
.map(name -> name.substring(0, name.length() - ".md".length()))
.filter(this::isValidLocale)
.forEach(result::add);
} catch (IOException e) {
log.warn("Failed listing login agreement files", e);
}
return result;
}
private ApplicationProperties.Legal.LoginAgreement config() {
return applicationProperties.getLegal().getLoginAgreement();
}
private Path disclaimerDir() {
return Path.of(InstallationPathConfig.getCustomFilesPath(), "disclaimer").normalize();
}
private void addLocaleCandidates(List<String> out, String locale) {
if (!isValidLocale(locale)) {
return;
}
if (!out.contains(locale)) {
out.add(locale);
}
String base = locale.split("[_-]", 2)[0];
if (!base.equals(locale) && !out.contains(base)) {
out.add(base);
}
}
private String readFileIfExists(String locale) {
Path file = resolveLocaleFile(locale);
if (file == null) {
return null;
}
try {
// NOFOLLOW_LINKS: a symlinked entry is treated as non-regular and skipped, so a
// planted symlink can't expose files outside the disclaimer dir via the public read.
if (Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
if (Files.size(file) > MAX_FILE_BYTES) {
log.warn(
"Login agreement file for locale {} exceeds {} bytes; ignoring",
locale,
MAX_FILE_BYTES);
return null;
}
return Files.readString(file, StandardCharsets.UTF_8);
}
} catch (IOException e) {
log.warn("Failed reading login agreement file for locale {}", locale, e);
}
return null;
}
private Path resolveLocaleFile(String locale) {
if (!isValidLocale(locale)) {
return null;
}
Path dir = disclaimerDir();
Path file = dir.resolve(locale + ".md").normalize();
// Defence in depth: the regex already blocks separators, but confirm containment.
if (!file.startsWith(dir)) {
return null;
}
return file;
}
private boolean isValidLocale(String locale) {
// Length check BEFORE the regex: LOCALE_PATTERN's repetition group recurses one stack
// frame per repeat in java.util.regex, so an unbounded input could overflow the stack.
return locale != null
&& locale.length() <= MAX_LOCALE_LENGTH
&& LOCALE_PATTERN.matcher(locale).matches();
}
}
@@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter;
import java.util.Calendar;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@@ -17,6 +18,9 @@ import stirling.software.common.model.PdfMetadata;
@Service
public class PdfMetadataService {
/** ({@code {labels}}). Written by the classify-and-label tool. */
public static final String CLASSIFICATION_KEY = "StirlingPDFClassification";
private final ApplicationProperties applicationProperties;
private final String stirlingPDFLabel;
private final UserServiceInterface userService;
@@ -177,4 +181,14 @@ public class PdfMetadataService {
}
pdf.getDocumentInformation().setAuthor(author);
}
/**
* Write the document classifier's JSON result into the custom Info-dictionary field {@link
* #CLASSIFICATION_KEY}, leaving all other metadata untouched.
*/
public void setClassificationMetadata(PDDocument pdf, String classificationJson) {
PDDocumentInformation info = pdf.getDocumentInformation();
info.setCustomMetadataValue(CLASSIFICATION_KEY, classificationJson);
pdf.setDocumentInformation(info);
}
}
@@ -230,6 +230,18 @@ public class TaskManager {
return false;
}
/** Attach metadata to a job and write it through to the shared store for cluster peers. */
public boolean putMetadata(String jobId, String key, String value) {
JobResult jobResult = jobResults.get(jobId);
if (jobResult != null) {
jobResult.putMetadata(key, value);
writeThrough(jobId, jobResult);
return true;
}
log.warn("Attempted to set metadata on non-existent job ID: {}", jobId);
return false;
}
/**
* Get statistics about all jobs in the system
*
@@ -378,7 +390,7 @@ public class TaskManager {
fileIds.add(rf.getFileId());
}
}
Map<String, String> meta = new HashMap<>();
Map<String, String> meta = new HashMap<>(result.getMetadata());
if (result.getNotes() != null && !result.getNotes().isEmpty()) {
meta.put("notesCount", Integer.toString(result.getNotes().size()));
}
@@ -144,8 +144,10 @@ public class TempFileCleanupService {
int directoriesDeletedCount = 0;
for (Path directory : registry.getTempDirectories()) {
try {
if (Files.exists(directory)) {
if (Files.exists(directory)
&& shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) {
GeneralUtils.deleteDirectory(directory);
registry.unregisterDirectory(directory);
directoriesDeletedCount++;
log.debug("Cleaned up temporary directory: {}", directory);
}
@@ -275,6 +277,21 @@ public class TempFileCleanupService {
return totalDeletedCount.get();
}
private boolean shouldDeleteRegisteredDirectory(Path directory, long maxAgeMillis) {
if (maxAgeMillis <= 0) {
return true;
}
try {
long currentTime = System.currentTimeMillis();
long lastModified = Files.getLastModifiedTime(directory).toMillis();
return (currentTime - lastModified) > maxAgeMillis;
} catch (IOException e) {
log.debug("Could not check directory age, skipping cleanup: {}", directory, e);
return false;
}
}
/** Get the system temp directory path based on configuration or system property. */
private Path getSystemTempPath() {
String systemTempDir =
@@ -1185,23 +1185,181 @@ public class GeneralUtils {
}
public String getLocalNetworkIp() {
String routed = detectLocalIpViaDefaultRoute();
if (routed != null) {
return routed;
}
try {
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
if (interfaces == null) return null;
while (interfaces.hasMoreElements()) {
NetworkInterface iface = interfaces.nextElement();
if (!iface.isUp() || iface.isLoopback() || iface.isVirtual()) continue;
Enumeration<InetAddress> addresses = iface.getInetAddresses();
while (addresses.hasMoreElements()) {
InetAddress addr = addresses.nextElement();
if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) {
return addr.getHostAddress();
}
}
}
return selectBestSiteLocalIp(collectInterfaceInfo());
} catch (Exception e) {
log.warn("Failed to detect local network IP", e);
return null;
}
}
private String detectLocalIpViaDefaultRoute() {
try (DatagramSocket socket = new DatagramSocket()) {
socket.connect(InetAddress.getByName("8.8.8.8"), 53);
InetAddress local = socket.getLocalAddress();
if (local instanceof Inet4Address
&& !local.isAnyLocalAddress()
&& !local.isLoopbackAddress()
&& !local.isLinkLocalAddress()) {
return local.getHostAddress();
}
} catch (Exception e) {
log.debug("Default-route IP detection failed; will scan interfaces", e);
}
return null;
}
private List<NetworkInterfaceInfo> collectInterfaceInfo() throws SocketException {
List<NetworkInterfaceInfo> infos = new ArrayList<>();
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
if (interfaces == null) {
return infos;
}
while (interfaces.hasMoreElements()) {
NetworkInterface iface = interfaces.nextElement();
List<String> siteLocalIpv4s = new ArrayList<>();
Enumeration<InetAddress> addresses = iface.getInetAddresses();
while (addresses.hasMoreElements()) {
InetAddress addr = addresses.nextElement();
if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) {
siteLocalIpv4s.add(addr.getHostAddress());
}
}
if (siteLocalIpv4s.isEmpty()) {
continue;
}
try {
byte[] mac = iface.getHardwareAddress();
infos.add(
new NetworkInterfaceInfo(
iface.getName(),
iface.getDisplayName(),
iface.getIndex(),
iface.isUp(),
iface.isLoopback(),
iface.isPointToPoint(),
iface.isVirtual(),
mac != null && mac.length > 0,
siteLocalIpv4s));
} catch (SocketException e) {
log.debug("Skipping interface {} while scanning for local IP", iface.getName(), e);
}
}
return infos;
}
static String selectBestSiteLocalIp(List<NetworkInterfaceInfo> interfaces) {
return interfaces.stream()
.filter(i -> i.up() && !i.loopback() && !i.pointToPoint() && !i.virtual())
.filter(i -> !isLikelyVirtualInterface(i.name(), i.displayName()))
.flatMap(
i ->
i.siteLocalIpv4s().stream()
.map(
ip ->
new ScoredAddress(
ip,
scoreInterface(i, ip),
i.index())))
.max(
Comparator.comparingInt(ScoredAddress::score)
.thenComparing(
Comparator.comparingInt(ScoredAddress::interfaceIndex)
.reversed()))
.map(ScoredAddress::ip)
.orElse(null);
}
private static int scoreInterface(NetworkInterfaceInfo iface, String ip) {
int score = 0;
if (isLikelyPhysicalInterface(iface.name(), iface.displayName())) {
score += 100;
}
if (iface.hasHardwareAddress()) {
score += 20;
}
if (ip.startsWith("192.168.")) {
score += 30;
} else if (ip.startsWith("10.")) {
score += 20;
} else {
score += 5;
}
return score;
}
static boolean isLikelyVirtualInterface(String name, String displayName) {
String n = name == null ? "" : name.toLowerCase(Locale.ROOT);
String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT);
String[] namePrefixes = {
"tun", "tap", "utun", "veth", "virbr", "vmnet", "docker", "br-", "wg", "ppp", "awdl",
"llw"
};
for (String prefix : namePrefixes) {
if (n.startsWith(prefix)) {
return true;
}
}
String[] displayMarkers = {
"vmware",
"virtualbox",
"virtual box",
"vbox",
"hyper-v",
"hyperv",
"vethernet",
"windows subsystem for linux",
"wsl",
"docker",
"tap-windows",
"tunnel",
"vpn",
"zerotier",
"tailscale",
"bluetooth",
"teredo",
"isatap",
"loopback",
"pseudo",
"virtual"
};
for (String marker : displayMarkers) {
if (d.contains(marker)) {
return true;
}
}
return false;
}
private static boolean isLikelyPhysicalInterface(String name, String displayName) {
String n = name == null ? "" : name.toLowerCase(Locale.ROOT);
String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT);
return n.startsWith("eth")
|| n.startsWith("en")
|| n.startsWith("wl")
|| n.startsWith("em")
|| d.contains("ethernet")
|| d.contains("wi-fi")
|| d.contains("wifi")
|| d.contains("wireless");
}
record NetworkInterfaceInfo(
String name,
String displayName,
int index,
boolean up,
boolean loopback,
boolean pointToPoint,
boolean virtual,
boolean hasHardwareAddress,
List<String> siteLocalIpv4s) {}
private record ScoredAddress(String ip, int score, int interfaceIndex) {}
}
@@ -5,6 +5,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
@@ -28,6 +29,8 @@ import lombok.extern.slf4j.Slf4j;
@Component
public class PdfTextLocator {
private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^A-Za-z0-9]");
/** One found line of text with its user-space bounding box. */
public record MatchedBox(float x, float y, float width, float height) {}
@@ -82,7 +85,7 @@ public class PdfTextLocator {
/** Strip everything non-alphanumeric and lowercase for tolerant matching. */
private static String normalize(String s) {
return s.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
return NON_ALPHANUMERIC_PATTERN.matcher(s).replaceAll("").toLowerCase(Locale.ROOT);
}
private static final class CapturedLine {
@@ -1,7 +1,11 @@
package stirling.software.common.util;
import java.util.regex.Pattern;
public class RequestUriUtils {
private static final Pattern SHARE_LINK_PATTERN = Pattern.compile("^/share/[^/]+/?$");
public static boolean isStaticResource(String requestURI) {
return isStaticResource("", requestURI);
}
@@ -57,6 +61,16 @@ public class RequestUriUtils {
return true;
}
// Admin portal SPA shell (mounted at /processor — must match the frontend
// PORTAL_BASENAME). Served publicly like the editor root so a direct nav /
// refresh to /processor loads the app (the JWT lives in localStorage, not a
// cookie, so the server can't authenticate the navigation itself). The
// portal gates access via its own auth gate + RequirePortalAccess, and its
// data APIs stay protected, so serving the shell pre-auth is safe.
if (normalizedUri.equals("/processor") || normalizedUri.startsWith("/processor/")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
@@ -188,11 +202,12 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| trimmedUri.startsWith("/api/v1/webhooks/")
|| trimmedUri.startsWith("/v1/api-docs")
// Workflow participant endpoints - access controlled by share tokens, not login
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
// Share-link SPA bootstrap; data APIs remain protected
|| trimmedUri.matches("^/share/[^/]+/?$");
|| SHARE_LINK_PATTERN.matcher(trimmedUri).matches();
}
private static String stripContextPath(String contextPath, String requestURI) {
@@ -244,10 +244,7 @@ public class SvgSanitizer {
return false;
}
return normalized.startsWith("http://")
|| normalized.startsWith("https://")
|| normalized.startsWith("//")
|| normalized.startsWith("file:");
return true;
}
private boolean isUrlAllowed(String url) {
@@ -155,6 +155,7 @@ public class TempFileManager {
if (directory != null && Files.isDirectory(directory)) {
try {
GeneralUtils.deleteDirectory(directory);
registry.unregisterDirectory(directory);
log.debug("Deleted temp directory: {}", directory.toString());
} catch (IOException e) {
log.warn("Failed to delete temp directory: {}", directory.toString(), e);
@@ -85,6 +85,18 @@ public class TempFileRegistry {
return directory;
}
/**
* Unregister a temporary directory from the registry.
*
* @param directory The directory to unregister
*/
public void unregisterDirectory(Path directory) {
if (directory != null) {
tempDirectories.remove(directory);
log.debug("Unregistered temp directory: {}", directory.toString());
}
}
/**
* Register a third-party temporary file that requires special handling.
*
@@ -0,0 +1,481 @@
package stirling.software.SPDF.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.common.model.ApplicationProperties;
/**
* Unit tests for {@link EndpointConfiguration}. The class wires up its endpoint/group registry in
* {@code init()} during construction and then applies environment overrides. We build it with a
* real {@link ApplicationProperties} (whose System/Endpoints sub-objects are non-null by default)
* so the constructor runs cleanly without any mocking.
*/
class EndpointConfigurationGapTest {
private ApplicationProperties applicationProperties;
/**
* Construct an EndpointConfiguration with the given pro flag and current applicationProperties.
*/
private EndpointConfiguration build(boolean runningProOrHigher) {
return new EndpointConfiguration(applicationProperties, runningProOrHigher);
}
/** Default config: not pro, no removals, url-to-pdf disabled (default System flag is false). */
private EndpointConfiguration buildDefault() {
return build(false);
}
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
}
@Nested
@DisplayName("endpointKeyForUri (static)")
class EndpointKeyForUriTests {
@Test
@DisplayName("returns null for null uri")
void nullUri() {
assertNull(EndpointConfiguration.endpointKeyForUri(null));
}
@Test
@DisplayName("returns null when uri does not contain /api/v1")
void notApiPath() {
assertNull(EndpointConfiguration.endpointKeyForUri("/foo/bar/baz"));
assertNull(EndpointConfiguration.endpointKeyForUri("https://example.com/home"));
}
@Test
@DisplayName("returns null when uri has too few path segments")
void tooFewSegments() {
// "/api/v1/general" splits to ["", "api", "v1", "general"] -> length 4, not > 4
assertNull(EndpointConfiguration.endpointKeyForUri("/api/v1/general"));
}
@Test
@DisplayName("extracts plain endpoint key from a standard /api/v1/<group>/<endpoint> uri")
void plainEndpoint() {
assertEquals(
"remove-pages",
EndpointConfiguration.endpointKeyForUri("/api/v1/general/remove-pages"));
}
@Test
@DisplayName("builds a <from>-to-<to> key for convert endpoints")
void convertEndpoint() {
assertEquals(
"pdf-to-img",
EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/img"));
}
@Test
@DisplayName("convert path without a target segment falls back to the segment after group")
void convertWithoutTarget() {
// "/api/v1/convert/pdf" -> length 5, the convert branch needs length > 5
assertEquals("pdf", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf"));
}
}
@Nested
@DisplayName("enable / disable endpoint")
class EnableDisableEndpointTests {
@Test
@DisplayName("a freshly registered endpoint is enabled by default")
void enabledByDefault() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isEndpointEnabled("merge-pdfs"));
}
@Test
@DisplayName("disableEndpoint marks the endpoint disabled")
void disableEndpoint() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
assertFalse(config.isEndpointEnabled("merge-pdfs"));
}
@Test
@DisplayName("enableEndpoint re-enables a previously disabled endpoint")
void reEnableEndpoint() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
assertFalse(config.isEndpointEnabled("merge-pdfs"));
config.enableEndpoint("merge-pdfs");
assertTrue(config.isEndpointEnabled("merge-pdfs"));
}
@Test
@DisplayName("leading slash is normalized away on disable")
void leadingSlashNormalizedOnDisable() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("/merge-pdfs");
// both forms resolve to the same key
assertFalse(config.isEndpointEnabled("merge-pdfs"));
assertFalse(config.isEndpointEnabled("/merge-pdfs"));
}
@Test
@DisplayName("isEndpointEnabled tolerates a leading slash on the query")
void leadingSlashOnQuery() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isEndpointEnabled("/merge-pdfs"));
}
@Test
@DisplayName("disabling clears with enable, removing the disable reason")
void enableClearsReason() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("split-pages", DisableReason.DEPENDENCY);
assertEquals(
DisableReason.DEPENDENCY,
config.getEndpointAvailability("split-pages").getReason());
config.enableEndpoint("split-pages");
EndpointAvailability availability = config.getEndpointAvailability("split-pages");
assertTrue(availability.isEnabled());
assertNull(availability.getReason());
}
}
@Nested
@DisplayName("isEndpointEnabledForUri")
class IsEndpointEnabledForUriTests {
@Test
@DisplayName("translates a /api/v1 uri to a key and reports its status")
void translatesUri() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isEndpointEnabledForUri("/api/v1/general/merge-pdfs"));
config.disableEndpoint("merge-pdfs");
assertFalse(config.isEndpointEnabledForUri("/api/v1/general/merge-pdfs"));
}
@Test
@DisplayName("falls back to treating a non-api uri as a raw key")
void fallsBackToRawKey() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
// non-api path: key resolution returns null, so the uri itself is used as the key
assertFalse(config.isEndpointEnabledForUri("merge-pdfs"));
}
}
@Nested
@DisplayName("group enable / disable")
class GroupTests {
@Test
@DisplayName("a functional group with all endpoints enabled reports enabled")
void functionalGroupEnabled() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isGroupEnabled("PageOps"));
}
@Test
@DisplayName("disabling a functional group cascades to all its endpoints")
void disableFunctionalGroupCascades() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps");
assertFalse(config.isGroupEnabled("PageOps"));
assertFalse(config.isEndpointEnabled("remove-pages"));
assertFalse(config.isEndpointEnabled("split-pages"));
}
@Test
@DisplayName("re-enabling a functional group re-enables its endpoints")
void enableFunctionalGroupRestores() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps");
assertFalse(config.isEndpointEnabled("remove-pages"));
config.enableGroup("PageOps");
assertTrue(config.isEndpointEnabled("remove-pages"));
assertTrue(config.isGroupEnabled("PageOps"));
}
@Test
@DisplayName("a functional group with one disabled endpoint is not enabled")
void functionalGroupWithDisabledEndpoint() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("remove-pages");
assertFalse(config.isGroupEnabled("PageOps"));
}
@Test
@DisplayName("disabledGroups reflects disabled groups and getDisabledGroups returns a copy")
void getDisabledGroupsReturnsCopy() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps");
Set<String> disabled = config.getDisabledGroups();
assertTrue(disabled.contains("PageOps"));
// mutating the returned set must not affect internal state
disabled.clear();
assertTrue(config.getDisabledGroups().contains("PageOps"));
}
@Test
@DisplayName("an unknown group with no endpoints is not enabled")
void unknownGroupNotEnabled() {
EndpointConfiguration config = buildDefault();
assertFalse(config.isGroupEnabled("NoSuchGroupXyz"));
}
}
@Nested
@DisplayName("tool group semantics")
class ToolGroupTests {
@Test
@DisplayName("a tool group is enabled until explicitly disabled")
void toolGroupEnabledUntilDisabled() {
EndpointConfiguration config = buildDefault();
assertTrue(config.isGroupEnabled("qpdf"));
config.disableGroup("qpdf");
assertFalse(config.isGroupEnabled("qpdf"));
}
@Test
@DisplayName("disabling a tool group does NOT cascade to its endpoints directly")
void toolGroupNoCascade() {
EndpointConfiguration config = buildDefault();
// repair has alternatives (qpdf, Ghostscript); disabling only qpdf keeps it enabled
config.disableGroup("qpdf");
assertTrue(config.isEndpointEnabled("repair"));
}
@Test
@DisplayName("endpoint with alternatives is disabled only when all tool groups are gone")
void allAlternativesDisabled() {
EndpointConfiguration config = buildDefault();
config.disableGroup("qpdf");
config.disableGroup("Ghostscript");
// repair's only alternatives are qpdf and Ghostscript
assertFalse(config.isEndpointEnabled("repair"));
}
@Test
@DisplayName("endpoint with a still-enabled alternative stays enabled")
void oneAlternativeRemains() {
EndpointConfiguration config = buildDefault();
// compress-pdf alternatives: qpdf, Ghostscript, Java
config.disableGroup("qpdf");
config.disableGroup("Ghostscript");
assertTrue(config.isEndpointEnabled("compress-pdf"));
config.disableGroup("Java");
assertFalse(config.isEndpointEnabled("compress-pdf"));
}
@Test
@DisplayName("single-dependency endpoint (no alternatives) disabled when its tool group is")
void singleDependencyDisabled() {
EndpointConfiguration config = buildDefault();
// pdf-to-epub depends on Calibre, no alternatives registered
assertTrue(config.isEndpointEnabled("pdf-to-epub"));
config.disableGroup("Calibre");
assertFalse(config.isEndpointEnabled("pdf-to-epub"));
}
}
@Nested
@DisplayName("addEndpointToGroup / addEndpointAlternative")
class RegistrationTests {
@Test
@DisplayName("addEndpointToGroup makes the endpoint part of the group")
void addEndpointToGroup() {
EndpointConfiguration config = buildDefault();
config.addEndpointToGroup("CustomGroup", "custom-endpoint");
Set<String> endpoints = config.getEndpointsForGroup("CustomGroup");
assertTrue(endpoints.contains("custom-endpoint"));
}
@Test
@DisplayName("disabling a custom functional group disables its added endpoint")
void customFunctionalGroupCascades() {
EndpointConfiguration config = buildDefault();
config.addEndpointToGroup("CustomGroup", "custom-endpoint");
assertTrue(config.isEndpointEnabled("custom-endpoint"));
config.disableGroup("CustomGroup");
assertFalse(config.isEndpointEnabled("custom-endpoint"));
}
@Test
@DisplayName("getEndpointsForGroup returns an empty set for unknown groups")
void unknownGroupEmptySet() {
EndpointConfiguration config = buildDefault();
Set<String> endpoints = config.getEndpointsForGroup("NoSuchGroupXyz");
assertNotNull(endpoints);
assertTrue(endpoints.isEmpty());
}
}
@Nested
@DisplayName("getEndpointAvailability / determineDisableReason")
class AvailabilityTests {
@Test
@DisplayName("an enabled endpoint has a null disable reason")
void enabledHasNullReason() {
EndpointConfiguration config = buildDefault();
EndpointAvailability availability = config.getEndpointAvailability("merge-pdfs");
assertTrue(availability.isEnabled());
assertNull(availability.getReason());
}
@Test
@DisplayName("explicit disable preserves the supplied reason")
void explicitDisableReason() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs", DisableReason.DEPENDENCY);
EndpointAvailability availability = config.getEndpointAvailability("merge-pdfs");
assertFalse(availability.isEnabled());
assertEquals(DisableReason.DEPENDENCY, availability.getReason());
}
@Test
@DisplayName("default disableEndpoint reason is CONFIG")
void defaultDisableReasonIsConfig() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
assertEquals(
DisableReason.CONFIG, config.getEndpointAvailability("merge-pdfs").getReason());
}
@Test
@DisplayName("endpoint disabled via functional group reports the group's reason")
void functionalGroupReason() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps", DisableReason.DEPENDENCY);
EndpointAvailability availability = config.getEndpointAvailability("crop");
assertFalse(availability.isEnabled());
// crop is disabled both via group cascade and group membership; reason is DEPENDENCY
assertEquals(DisableReason.DEPENDENCY, availability.getReason());
}
}
@Nested
@DisplayName("getAllEndpoints")
class GetAllEndpointsTests {
@Test
@DisplayName("aggregates endpoints across all groups")
void aggregatesAcrossGroups() {
EndpointConfiguration config = buildDefault();
Set<String> all = config.getAllEndpoints();
assertTrue(all.contains("merge-pdfs"));
assertTrue(all.contains("compress-pdf"));
assertTrue(all.contains("ocr-pdf"));
assertFalse(all.isEmpty());
}
@Test
@DisplayName("custom endpoints registered after init appear in getAllEndpoints")
void includesCustomEndpoints() {
EndpointConfiguration config = buildDefault();
config.addEndpointToGroup("CustomGroup", "brand-new-endpoint");
assertTrue(config.getAllEndpoints().contains("brand-new-endpoint"));
}
}
@Nested
@DisplayName("environment / constructor driven configuration")
class EnvironmentConfigTests {
@Test
@DisplayName("url-to-pdf is disabled when enableUrlToPDF is false (default)")
void urlToPdfDisabledByDefault() {
EndpointConfiguration config = buildDefault();
assertFalse(config.isEndpointEnabled("url-to-pdf"));
}
@Test
@DisplayName("url-to-pdf stays enabled when enableUrlToPDF is true")
void urlToPdfEnabledWhenFlagSet() {
applicationProperties.getSystem().setEnableUrlToPDF(true);
EndpointConfiguration config = build(false);
assertTrue(config.isEndpointEnabled("url-to-pdf"));
}
@Test
@DisplayName("endpoints.toRemove disables the listed endpoints at construction")
void endpointsToRemove() {
applicationProperties
.getEndpoints()
.setToRemove(List.of(" merge-pdfs ", "split-pages"));
EndpointConfiguration config = build(false);
// values are trimmed before disabling
assertFalse(config.isEndpointEnabled("merge-pdfs"));
assertFalse(config.isEndpointEnabled("split-pages"));
}
@Test
@DisplayName("endpoints.groupsToRemove disables the listed groups at construction")
void groupsToRemove() {
applicationProperties.getEndpoints().setGroupsToRemove(List.of(" PageOps "));
EndpointConfiguration config = build(false);
assertTrue(config.getDisabledGroups().contains("PageOps"));
assertFalse(config.isEndpointEnabled("remove-pages"));
}
@Test
@DisplayName("non-pro build disables the enterprise group")
void nonProDisablesEnterprise() {
EndpointConfiguration config = build(false);
assertTrue(config.getDisabledGroups().contains("enterprise"));
}
@Test
@DisplayName("pro build does not disable the enterprise group")
void proDoesNotDisableEnterprise() {
EndpointConfiguration config = build(true);
assertFalse(config.getDisabledGroups().contains("enterprise"));
}
}
@Nested
@DisplayName("getEndpointStatuses (Lombok getter) and logging summary")
class MiscTests {
@Test
@DisplayName("getEndpointStatuses reflects explicit disable state")
void endpointStatusesReflectDisable() {
EndpointConfiguration config = buildDefault();
config.disableEndpoint("merge-pdfs");
assertEquals(Boolean.FALSE, config.getEndpointStatuses().get("merge-pdfs"));
}
@Test
@DisplayName("logDisabledEndpointsSummary runs without throwing")
void logSummaryDoesNotThrow() {
EndpointConfiguration config = buildDefault();
config.disableGroup("PageOps");
config.disableGroup("qpdf");
// purely a smoke test of the logging branch coverage
config.logDisabledEndpointsSummary();
}
@Test
@DisplayName("logDisabledEndpointsSummary runs when nothing is disabled")
void logSummaryNothingDisabled() {
applicationProperties.getSystem().setEnableUrlToPDF(true);
EndpointConfiguration config = build(true);
config.logDisabledEndpointsSummary();
}
}
}
@@ -0,0 +1,191 @@
package stirling.software.SPDF.pdf.parser;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.SPDF.pdf.parser.PageImageLocator.ImageBox;
/**
* Unit tests for {@link PageImageLocator}. PDFs are built in memory with PDFBox so each test is
* deterministic and needs no fixtures or native libraries. The locator transforms the image unit
* square through the CTM, so an image drawn at {@code (x, y)} with size {@code (w, h)} must yield
* the box {@code (x, y, x+w, y+h)}.
*/
class PageImageLocatorTest {
/** A tiny opaque raster; pixel content is irrelevant, only its placement matters. */
private static PDImageXObject tinyImage(PDDocument doc) throws Exception {
BufferedImage img = new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB);
return LosslessFactory.createFromImage(doc, img);
}
/** Builds a one-page PDF that draws one image at the given placement. */
private static byte[] pdfWithImageAt(float x, float y, float w, float h) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDImageXObject image = tinyImage(doc);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(image, x, y, w, h);
}
return save(doc);
}
}
private static byte[] save(PDDocument doc) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
@Nested
@DisplayName("drawImage bounding boxes")
class DrawImageBoxes {
@Test
@DisplayName("a single image yields one box with the page index and CTM-derived bounds")
void singleImageBox() throws Exception {
byte[] pdf = pdfWithImageAt(100f, 200f, 50f, 80f);
try (PDDocument doc = Loader.loadPDF(pdf)) {
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 0);
locator.processPage(doc.getPage(0));
List<ImageBox> boxes = locator.getImageBoxes();
assertThat(boxes).hasSize(1);
ImageBox box = boxes.get(0);
assertThat(box.pageIndex()).isZero();
assertThat(box.x1()).isCloseTo(100f, within(0.5f));
assertThat(box.y1()).isCloseTo(200f, within(0.5f));
assertThat(box.x2()).isCloseTo(150f, within(0.5f));
assertThat(box.y2()).isCloseTo(280f, within(0.5f));
}
}
@Test
@DisplayName("the supplied page index is stored on every box")
void pageIndexStored() throws Exception {
byte[] pdf = pdfWithImageAt(10f, 10f, 20f, 20f);
try (PDDocument doc = Loader.loadPDF(pdf)) {
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 7);
locator.processPage(doc.getPage(0));
assertThat(locator.getImageBoxes().get(0).pageIndex()).isEqualTo(7);
}
}
@Test
@DisplayName("two images on one page yield two boxes")
void twoImages() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDImageXObject image = tinyImage(doc);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.drawImage(image, 50f, 50f, 30f, 30f);
cs.drawImage(image, 200f, 400f, 60f, 40f);
}
byte[] pdf = save(doc);
try (PDDocument reopened = Loader.loadPDF(pdf)) {
PageImageLocator locator = new PageImageLocator(reopened.getPage(0), 0);
locator.processPage(reopened.getPage(0));
assertThat(locator.getImageBoxes()).hasSize(2);
}
}
}
@Test
@DisplayName("a page with no images yields no boxes")
void noImages() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
byte[] pdf = save(doc);
try (PDDocument reopened = Loader.loadPDF(pdf)) {
PageImageLocator locator = new PageImageLocator(reopened.getPage(0), 0);
locator.processPage(reopened.getPage(0));
assertThat(locator.getImageBoxes()).isEmpty();
}
}
}
@Test
@DisplayName("getImageBoxes is empty before any page is processed")
void emptyBeforeProcessing() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
PageImageLocator locator = new PageImageLocator(doc.getPage(0), 0);
assertThat(locator.getImageBoxes()).isEmpty();
}
}
}
@Nested
@DisplayName("path operation no-ops")
class PathNoOps {
private PageImageLocator newLocator() {
PDPage page = new PDPage(PDRectangle.A4);
return new PageImageLocator(page, 0);
}
@Test
@DisplayName("moveTo updates the current point")
void moveToUpdatesPoint() {
PageImageLocator locator = newLocator();
locator.moveTo(12f, 34f);
Point2D current = locator.getCurrentPoint();
assertThat(current.getX()).isEqualTo(12d);
assertThat(current.getY()).isEqualTo(34d);
}
@Test
@DisplayName("lineTo updates the current point")
void lineToUpdatesPoint() {
PageImageLocator locator = newLocator();
locator.lineTo(5f, 6f);
assertThat(locator.getCurrentPoint().getX()).isEqualTo(5d);
assertThat(locator.getCurrentPoint().getY()).isEqualTo(6d);
}
@Test
@DisplayName("curveTo updates the current point to the final control point")
void curveToUpdatesPoint() {
PageImageLocator locator = newLocator();
locator.curveTo(1f, 1f, 2f, 2f, 9f, 8f);
assertThat(locator.getCurrentPoint().getX()).isEqualTo(9d);
assertThat(locator.getCurrentPoint().getY()).isEqualTo(8d);
}
@Test
@DisplayName("rectangle, clip, path and shading operations are no-ops that do not throw")
void otherOpsDoNotThrow() {
PageImageLocator locator = newLocator();
Point2D p = new Point2D.Float(0f, 0f);
// None of these record anything or alter state; they must simply not throw.
locator.appendRectangle(p, p, p, p);
locator.clip(0);
locator.closePath();
locator.endPath();
locator.strokePath();
locator.fillPath(0);
locator.fillAndStrokePath(0);
locator.shadingFill(COSName.getPDFName("Sh0"));
assertThat(locator.getImageBoxes()).isEmpty();
}
}
}
@@ -0,0 +1,345 @@
package stirling.software.SPDF.pdf.parser;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static stirling.software.SPDF.pdf.parser.PdfModels.RawPage;
import static stirling.software.SPDF.pdf.parser.PdfModels.TableCell;
import static stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import static stirling.software.SPDF.pdf.parser.PdfModels.TableRow;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link TabulaTableParser}. Tables are built in-memory with PDFBox so the tests are
* deterministic and need no fixtures, network, or external processes.
*/
class TabulaTableParserGapTest {
private final TabulaTableParser parser = new TabulaTableParser();
// ── error / empty branches ───────────────────────────────────────────────
@Nested
@DisplayName("Empty and error branches")
class EmptyAndErrorBranches {
@Test
@DisplayName("page number 0 is out of Tabula's 1-based range -> empty list, no throw")
void pageNumberZeroReturnsEmpty() throws Exception {
byte[] pdf = pdfWithText(new String[] {"hello"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> result = parser.parse(doc, 0);
assertNotNull(result);
assertTrue(result.isEmpty());
}
}
@Test
@DisplayName("page number beyond the document -> empty list, exception swallowed")
void pageNumberOutOfRangeReturnsEmpty() throws Exception {
byte[] pdf = pdfWithText(new String[] {"hello"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> result = parser.parse(doc, 99);
assertNotNull(result);
assertTrue(result.isEmpty());
}
}
@Test
@DisplayName("negative page number -> empty list")
void negativePageNumberReturnsEmpty() throws Exception {
byte[] pdf = pdfWithText(new String[] {"hello"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertTrue(parser.parse(doc, -5).isEmpty());
}
}
@Test
@DisplayName("lattice mode on a page with no ruled lines -> no tables")
void latticeWithNoRulingsReturnsEmpty() throws Exception {
byte[] pdf = pdfWithText(new String[] {"just some prose", "no table here"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> result = parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
assertNotNull(result);
assertTrue(
result.isEmpty(), "borderless text must not be detected in lattice mode");
}
}
@Test
@DisplayName("blank page in lattice mode -> empty list")
void blankPageLatticeReturnsEmpty() throws Exception {
byte[] pdf = blankPdf();
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertTrue(parser.parse(doc, new RawPage(1, 0f, 0f, List.of())).isEmpty());
}
}
}
// ── stream mode (BasicExtractionAlgorithm) ───────────────────────────────
@Nested
@DisplayName("Stream mode")
class StreamMode {
@Test
@DisplayName("page with text yields at least one well-formed fragment")
void streamOnTextProducesFragment() throws Exception {
byte[] pdf =
pdfWithText(new String[] {"Name Age City", "Alice 30 Paris", "Bob 25 Rome"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
assertNotNull(fragments);
assertFalse(fragments.isEmpty(), "stream mode always builds a table from text");
assertFragmentWellFormed(fragments.get(0), 1, 0);
}
}
@Test
@DisplayName("fragment ids encode page and index")
void streamFragmentIdFormat() throws Exception {
byte[] pdf = pdfWithText(new String[] {"col1 col2", "a b"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
assertFalse(fragments.isEmpty());
assertEquals("tbl-p1-0", fragments.get(0).tableId());
assertEquals(1, fragments.get(0).pageNumber());
}
}
@Test
@DisplayName("rawRows and the parsed rows stay in lockstep")
void streamRowsMatchRawRows() throws Exception {
byte[] pdf = pdfWithText(new String[] {"x y", "1 2", "3 4"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
assertFalse(fragments.isEmpty());
TableFragment f = fragments.get(0);
assertEquals(f.rawRows().size(), f.rows().size());
}
}
}
// ── lattice mode with a real bordered grid ───────────────────────────────
@Nested
@DisplayName("Lattice mode")
class LatticeMode {
@Test
@DisplayName("bordered grid is detected and produces well-formed fragments")
void latticeDetectsBorderedTable() throws Exception {
byte[] pdf = pdfWithGrid();
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
assertNotNull(fragments);
assertFalse(
fragments.isEmpty(), "a clean ruled grid must be detected in lattice mode");
TableFragment f = fragments.get(0);
assertFragmentWellFormed(f, 1, 0);
assertTrue(f.columnCount() >= 1, "a detected grid must have at least one column");
assertFalse(f.rawRows().isEmpty(), "a detected grid must have rows");
}
}
@Test
@DisplayName("convenience overload with page number routes to lattice mode")
void parseByPageNumberDetectsGrid() throws Exception {
byte[] pdf = pdfWithGrid();
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments = parser.parse(doc, 1);
assertNotNull(fragments);
assertFalse(fragments.isEmpty());
assertEquals(1, fragments.get(0).pageNumber());
}
}
@Test
@DisplayName("cell text is normalised (trimmed, newlines collapsed)")
void latticeCellTextIsNormalised() throws Exception {
byte[] pdf = pdfWithGrid();
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TableFragment> fragments =
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
assertFalse(fragments.isEmpty());
for (List<String> row : fragments.get(0).rawRows()) {
for (String cell : row) {
assertNotNull(cell);
assertFalse(cell.contains("\n"), "newlines must be collapsed");
assertFalse(cell.contains("\r"), "carriage returns must be collapsed");
assertEquals(cell.trim(), cell, "cell text must be trimmed");
}
}
}
}
}
// ── contract invariants ──────────────────────────────────────────────────
@Nested
@DisplayName("Contract invariants")
class ContractInvariants {
@Test
@DisplayName("parse never returns null")
void parseNeverReturnsNull() throws Exception {
byte[] pdf = pdfWithText(new String[] {"abc"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
assertNotNull(parser.parse(doc, new RawPage(1, 0f, 0f, List.of())));
assertNotNull(parser.parse(doc, 1));
assertNotNull(parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of())));
}
}
@Test
@DisplayName("the document is not closed by the parser")
void documentRemainsOpenAfterParse() throws Exception {
byte[] pdf = pdfWithText(new String[] {"keep me open"});
try (PDDocument doc = Loader.loadPDF(pdf)) {
parser.parse(doc, new RawPage(1, 0f, 0f, List.of()));
parser.parseStream(doc, new RawPage(1, 0f, 0f, List.of()));
// ObjectExtractor.close() would close the underlying COSDocument; the parser must
// not.
assertFalse(
doc.getDocument().isClosed(),
"parser must not close the caller's document");
assertEquals(1, doc.getNumberOfPages());
}
}
}
// ── helpers ──────────────────────────────────────────────────────────────
/** Asserts every field of a fragment satisfies the documented contract. */
private static void assertFragmentWellFormed(
TableFragment f, int expectedPage, int expectedIndex) {
assertNotNull(f);
assertEquals(expectedPage, f.pageNumber());
assertEquals("tbl-p" + expectedPage + "-" + expectedIndex, f.tableId());
assertNotNull(f.bounds());
assertNotNull(f.headers());
assertTrue(f.headers().isEmpty(), "headers are deferred to v2 and must be empty");
assertNotNull(f.rows());
assertNotNull(f.rawRows());
assertNotNull(f.warnings());
assertSame(null, f.continuedFromPage(), "continuedFromPage is deferred to v2");
assertTrue(f.columnCount() >= 0);
assertTrue(f.confidence() >= 0f && f.confidence() <= 1f, "confidence must be within [0,1]");
assertEquals(f.rawRows().size(), f.rows().size());
for (TableRow row : f.rows()) {
assertNotNull(row.cells());
for (TableCell cell : row.cells()) {
assertNotNull(cell.text());
assertNotNull(cell.bounds());
assertEquals(1, cell.colSpan(), "colSpan is always 1 in v1");
assertEquals(1, cell.rowSpan(), "rowSpan is always 1 in v1");
}
}
}
private static byte[] pdfWithText(String[] lines) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.setNonStrokingColor(Color.BLACK);
float y = 720f;
for (String line : lines) {
cs.beginText();
cs.newLineAtOffset(72f, y);
cs.showText(line);
cs.endText();
y -= 20f;
}
}
return save(doc);
}
}
private static byte[] blankPdf() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
return save(doc);
}
}
/**
* Builds a small 3-row x 3-column ruled grid with text in each cell. The ruled lines make the
* table detectable by lattice mode.
*/
private static byte[] pdfWithGrid() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
float left = 100f;
float right = 400f;
float top = 700f;
float bottom = 550f;
int cols = 3;
int rows = 3;
float colStep = (right - left) / cols;
float rowStep = (top - bottom) / rows;
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setStrokingColor(Color.BLACK);
cs.setLineWidth(1f);
// vertical lines
for (int c = 0; c <= cols; c++) {
float x = left + c * colStep;
cs.moveTo(x, bottom);
cs.lineTo(x, top);
}
// horizontal lines
for (int r = 0; r <= rows; r++) {
float yLine = bottom + r * rowStep;
cs.moveTo(left, yLine);
cs.lineTo(right, yLine);
}
cs.stroke();
// cell text
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10);
cs.setNonStrokingColor(Color.BLACK);
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
cs.beginText();
cs.newLineAtOffset(left + c * colStep + 5f, top - (r + 1) * rowStep + 6f);
cs.showText("R" + r + "C" + c);
cs.endText();
}
}
}
return save(doc);
}
}
private static byte[] save(PDDocument doc) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
@@ -0,0 +1,270 @@
package stirling.software.common.configuration;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.function.Predicate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
class AppConfigTest {
private ApplicationProperties applicationProperties;
private MockEnvironment env;
private AppConfig appConfig;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
env = new MockEnvironment();
appConfig = new AppConfig(env, applicationProperties);
ReflectionTestUtils.setField(appConfig, "contextPath", "/");
ReflectionTestUtils.setField(appConfig, "serverPort", "8080");
ReflectionTestUtils.setField(appConfig, "v2Enabled", true);
}
@Nested
@DisplayName("Value-backed getters and simple beans")
class SimpleBeans {
@Test
@DisplayName("getter fields reflect injected @Value values")
void valueGetters() {
assertThat(appConfig.getContextPath()).isEqualTo("/");
assertThat(appConfig.getServerPort()).isEqualTo("8080");
}
@Test
@DisplayName("v2Enabled bean mirrors the field")
void v2EnabledBean() {
assertThat(appConfig.v2Enabled()).isTrue();
}
@Test
@DisplayName("constant beans return fixed values")
void constants() {
assertThat(appConfig.appName()).isEqualTo("Stirling PDF");
assertThat(appConfig.homeText()).isEqualTo("null");
assertThat(appConfig.contextPath("/ctx")).isEqualTo("/ctx");
}
@Test
@DisplayName("appVersion resolves from version.properties on classpath")
void appVersion() {
assertThat(appConfig.appVersion()).isNotBlank();
}
@Test
@DisplayName("StirlingPDFLabel embeds version")
void stirlingLabel() {
assertThat(appConfig.stirlingPDFLabel()).startsWith("Stirling-PDF v");
}
}
@Nested
@DisplayName("Beans backed by ApplicationProperties")
class PropertyBackedBeans {
@Test
@DisplayName("loginEnabled reflects security flag")
void loginEnabled() {
applicationProperties.getSecurity().setEnableLogin(true);
assertThat(appConfig.loginEnabled()).isTrue();
}
@Test
@DisplayName("backendUrl falls back to localhost when unset")
void backendUrlFallback() {
assertThat(appConfig.getBackendUrl()).isEqualTo("http://localhost");
}
@Test
@DisplayName("backendUrl returns configured value when present")
void backendUrlConfigured() {
applicationProperties.getSystem().setBackendUrl("https://api.example.com");
assertThat(appConfig.getBackendUrl()).isEqualTo("https://api.example.com");
}
@Test
@DisplayName("languages bean returns configured languages list")
void languages() {
applicationProperties.getUi().setLanguages(List.of("en", "de"));
assertThat(appConfig.languages()).containsExactly("en", "de");
}
@Test
@DisplayName("navBarText falls back to Stirling PDF when unset")
void navBarTextFallback() {
assertThat(appConfig.navBarText()).isEqualTo("Stirling PDF");
}
@Test
@DisplayName("navBarText returns configured value")
void navBarTextConfigured() {
applicationProperties.getUi().setAppNameNavbar("My PDF");
assertThat(appConfig.navBarText()).isEqualTo("My PDF");
}
@Test
@DisplayName("enableAlphaFunctionality reflects system flag")
void alphaFunctionality() {
applicationProperties.getSystem().setEnableAlphaFunctionality(true);
assertThat(appConfig.enableAlphaFunctionality()).isTrue();
}
@Test
@DisplayName("legal text beans return configured values")
void legalBeans() {
var legal = applicationProperties.getLegal();
legal.setTermsAndConditions("terms");
legal.setPrivacyPolicy("privacy");
legal.setCookiePolicy("cookie");
legal.setImpressum("impressum");
legal.setAccessibilityStatement("a11y");
assertThat(appConfig.termsAndConditions()).isEqualTo("terms");
assertThat(appConfig.privacyPolicy()).isEqualTo("privacy");
assertThat(appConfig.cookiePolicy()).isEqualTo("cookie");
assertThat(appConfig.impressum()).isEqualTo("impressum");
assertThat(appConfig.accessibilityStatement()).isEqualTo("a11y");
}
@Test
@DisplayName("analyticsPrompt true when enableAnalytics null")
void analyticsPrompt() {
applicationProperties.getSystem().setEnableAnalytics(null);
assertThat(appConfig.analyticsPrompt()).isTrue();
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
assertThat(appConfig.analyticsPrompt()).isFalse();
}
@Test
@DisplayName("analyticsEnabled true when premium enabled regardless of system flag")
void analyticsEnabledViaPremium() {
applicationProperties.getPremium().setEnabled(true);
assertThat(appConfig.analyticsEnabled()).isTrue();
}
@Test
@DisplayName("analyticsEnabled reflects system flag when premium disabled")
void analyticsEnabledViaSystem() {
applicationProperties.getPremium().setEnabled(false);
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
assertThat(appConfig.analyticsEnabled()).isTrue();
applicationProperties.getSystem().setEnableAnalytics(Boolean.FALSE);
assertThat(appConfig.analyticsEnabled()).isFalse();
}
@Test
@DisplayName("scarf and posthog beans reflect derived flags")
void scarfAndPosthog() {
applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE);
applicationProperties.getSystem().setEnableScarf(Boolean.TRUE);
applicationProperties.getSystem().setEnablePosthog(Boolean.TRUE);
assertThat(appConfig.scarfEnabled()).isTrue();
assertThat(appConfig.posthogEnabled()).isTrue();
}
@Test
@DisplayName("uuid bean returns generated UUID")
void uuidBean() {
applicationProperties.getAutomaticallyGenerated().setUUID("abc-123");
assertThat(appConfig.uuid()).isEqualTo("abc-123");
}
@Test
@DisplayName("typed config beans return live nested instances")
void typedConfigBeans() {
assertThat(appConfig.security()).isSameAs(applicationProperties.getSecurity());
assertThat(appConfig.oAuth2())
.isSameAs(applicationProperties.getSecurity().getOauth2());
assertThat(appConfig.premium()).isSameAs(applicationProperties.getPremium());
assertThat(appConfig.system()).isSameAs(applicationProperties.getSystem());
assertThat(appConfig.datasource())
.isSameAs(applicationProperties.getSystem().getDatasource());
}
}
@Nested
@DisplayName("Profile-default and environment beans")
class ProfileAndEnvBeans {
@Test
@DisplayName("default-profile license beans return community defaults")
void licenseDefaults() {
assertThat(appConfig.runningProOrHigher()).isFalse();
assertThat(appConfig.runningEnterprise()).isFalse();
assertThat(appConfig.licenseType()).isEqualTo("NORMAL");
}
@Test
@DisplayName("activeSecurity reflects classpath presence of SecurityConfiguration")
void activeSecurity() {
// Just exercise the branch; result depends on classpath, assert it does not throw.
boolean present = appConfig.missingActiveSecurity();
assertThat(present).isIn(true, false);
}
@Test
@DisplayName("rateLimit parses system property")
void rateLimitProperty() {
String prev = System.getProperty("rateLimit");
try {
System.setProperty("rateLimit", "true");
assertThat(appConfig.rateLimit()).isTrue();
} finally {
if (prev == null) {
System.clearProperty("rateLimit");
} else {
System.setProperty("rateLimit", prev);
}
}
}
@Test
@DisplayName("runningInDocker false outside container")
void runningInDocker() {
// CI/test host is not a container with /.dockerenv.
assertThat(appConfig.runningInDocker()).isFalse();
}
@Test
@DisplayName("configDirMounted defaults to true when not in docker")
void configDirMounted() {
assertThat(appConfig.isRunningInDockerWithConfig()).isTrue();
}
@Test
@DisplayName("directoryFilter accepts files and rejects processing dirs")
void directoryFilter(@org.junit.jupiter.api.io.TempDir Path tempDir) throws Exception {
Predicate<Path> filter = appConfig.processOnlyFiles();
Path file = Files.createFile(tempDir.resolve("a.txt"));
Path normalDir = Files.createDirectory(tempDir.resolve("normal"));
Path processingDir = Files.createDirectory(tempDir.resolve("processing"));
assertThat(filter.test(file)).isTrue();
assertThat(filter.test(normalDir)).isTrue();
assertThat(filter.test(processingDir)).isFalse();
}
@Test
@DisplayName("machineType returns Server-jar in plain test environment")
void machineTypeServerJar() {
assertThat(appConfig.determineMachineType()).isEqualTo("Server-jar");
}
@Test
@DisplayName("machineType returns a Client-* variant when BROWSER_OPEN set")
void machineTypeClient() {
env.setProperty("BROWSER_OPEN", "true");
assertThat(appConfig.determineMachineType()).startsWith("Client-");
}
}
}
@@ -0,0 +1,178 @@
package stirling.software.common.configuration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mockStatic;
import java.io.FileNotFoundException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
import org.snakeyaml.engine.v2.api.LoadSettings;
import stirling.software.common.util.YamlHelper;
class ConfigInitializerMoreTest {
private static final LoadSettings LOAD_SETTINGS =
LoadSettings.builder()
.setUseMarks(true)
.setMaxAliasesForCollections(Integer.MAX_VALUE)
.setAllowRecursiveKeys(true)
.setParseComments(true)
.build();
// Template after the enterpriseEdition -> premium rename.
private static final String PREMIUM_TEMPLATE =
"""
premium:
enabled: false
key: 0000
proFeatures:
ssoAutoLogin: false
customMetadata:
autoUpdateMetadata: false
author: username
creator: Stirling-PDF
producer: Stirling-PDF
""";
@Nested
@DisplayName("migrateEnterpriseEditionToPremium")
class EnterpriseMigration {
@Test
@DisplayName("carries legacy enterpriseEdition values forward into premium block")
void migratesLegacyEnterpriseValues() throws Exception {
String legacy =
"""
enterpriseEdition:
enabled: true
key: ABC-123
SSOAutoLogin: true
CustomMetadata:
autoUpdateMetadata: true
author: alice
creator: bob
producer: carol
""";
YamlHelper template = new YamlHelper(LOAD_SETTINGS, PREMIUM_TEMPLATE);
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, legacy);
invokeMigrate(existing, template);
assertThat(template.getValueByExactKeyPath("premium", "enabled")).isEqualTo("true");
assertThat(template.getValueByExactKeyPath("premium", "key")).isEqualTo("ABC-123");
assertThat(template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"))
.isEqualTo("true");
assertThat(
template.getValueByExactKeyPath(
"premium",
"proFeatures",
"customMetadata",
"autoUpdateMetadata"))
.isEqualTo("true");
assertThat(
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "author"))
.isEqualTo("alice");
assertThat(
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "creator"))
.isEqualTo("bob");
assertThat(
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "producer"))
.isEqualTo("carol");
}
@Test
@DisplayName("no legacy enterpriseEdition block leaves template defaults intact")
void noLegacyKeysIsNoOp() throws Exception {
String noEnterprise =
"""
security:
enableLogin: false
""";
YamlHelper template = new YamlHelper(LOAD_SETTINGS, PREMIUM_TEMPLATE);
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, noEnterprise);
invokeMigrate(existing, template);
assertThat(template.getValueByExactKeyPath("premium", "enabled")).isEqualTo("false");
assertThat(
template.getValueByExactKeyPath(
"premium", "proFeatures", "customMetadata", "author"))
.isEqualTo("username");
}
private void invokeMigrate(YamlHelper yaml, YamlHelper template) throws Exception {
var method =
ConfigInitializer.class.getDeclaredMethod(
"migrateEnterpriseEditionToPremium",
YamlHelper.class,
YamlHelper.class);
method.setAccessible(true);
method.invoke(new ConfigInitializer(), yaml, template);
}
}
@Nested
@DisplayName("ensureConfigExists - create branch (template absent on common classpath)")
class EnsureConfigCreateBranch {
@Test
@DisplayName("no settings file -> attempts create, fails fast when template missing")
void createWithoutTemplateThrows(@TempDir Path tempDir) throws Exception {
Path settings = tempDir.resolve("configs").resolve("settings.yml");
Path custom = tempDir.resolve("configs").resolve("custom_settings.yml");
try (MockedStatic<InstallationPathConfig> mocked =
mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath)
.thenReturn(settings.toString());
mocked.when(InstallationPathConfig::getCustomSettingsPath)
.thenReturn(custom.toString());
// settings.yml.template is packaged in the core module, not common, so the
// create branch must surface a FileNotFoundException here.
assertThatThrownBy(() -> new ConfigInitializer().ensureConfigExists())
.isInstanceOf(FileNotFoundException.class);
}
}
@Test
@DisplayName("short existing settings file is backed up before recreate attempt")
void shortFileIsBackedUp(@TempDir Path tempDir) throws Exception {
Path configDir = Files.createDirectories(tempDir.resolve("configs"));
Path settings = configDir.resolve("settings.yml");
Path custom = configDir.resolve("custom_settings.yml");
// Fewer than MIN_SETTINGS_FILE_LINES (31) lines triggers the recreate path.
Files.writeString(settings, "a: 1\nb: 2\n");
try (MockedStatic<InstallationPathConfig> mocked =
mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath)
.thenReturn(settings.toString());
mocked.when(InstallationPathConfig::getCustomSettingsPath)
.thenReturn(custom.toString());
assertThatThrownBy(() -> new ConfigInitializer().ensureConfigExists())
.isInstanceOf(FileNotFoundException.class);
}
// Original was moved to a timestamped .bak before the failed recreate.
try (Stream<Path> files = Files.list(configDir)) {
assertThat(files.anyMatch(p -> p.getFileName().toString().contains(".bak")))
.isTrue();
}
assertThat(Files.exists(settings)).isFalse();
}
}
}
@@ -0,0 +1,520 @@
package stirling.software.common.configuration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.CustomPaths.Operations;
import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline;
import stirling.software.common.model.ApplicationProperties.ProcessExecutor.UnoServerEndpoint;
/**
* Unit tests for {@link RuntimePathConfig}. All of the resolution logic lives in the constructor,
* so each test builds a real {@link ApplicationProperties} (a plain @Data POJO with sensible
* defaults), constructs the config, and asserts on the exposed getters.
*/
class RuntimePathConfigTest {
/** The base path the production code derives from {@link InstallationPathConfig#getPath()}. */
private static final String BASE_PATH = InstallationPathConfig.getPath();
private static ApplicationProperties newProperties() {
return new ApplicationProperties();
}
private static RuntimePathConfig build(ApplicationProperties properties) {
return new RuntimePathConfig(properties);
}
@Nested
@DisplayName("Pipeline directory resolution")
class PipelinePaths {
@Test
@DisplayName("Defaults to <basePath>/pipeline and derived sub-folders")
void defaultPipelinePaths() {
RuntimePathConfig config = build(newProperties());
String expectedPipeline = Path.of(BASE_PATH, "pipeline").toString();
assertEquals(expectedPipeline, config.getPipelinePath());
// Watched folders are resolved to an absolute, normalized path by the production code.
assertEquals(
Path.of(expectedPipeline, "watchedFolders")
.toAbsolutePath()
.normalize()
.toString(),
config.getPipelineWatchedFoldersPath());
assertEquals(
Path.of(expectedPipeline, "finishedFolders").toString(),
config.getPipelineFinishedFoldersPath());
assertEquals(
Path.of(expectedPipeline, "defaultWebUIConfigs").toString(),
config.getPipelineDefaultWebUiConfigs());
}
@Test
@DisplayName("Custom pipelineDir overrides the default pipeline path")
void customPipelineDir() {
ApplicationProperties properties = newProperties();
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
pipeline.setPipelineDir("/custom/pipeline");
RuntimePathConfig config = build(properties);
assertEquals("/custom/pipeline", config.getPipelinePath());
// Sub-folders are derived from the (already-resolved) custom pipeline path.
assertEquals(
Path.of("/custom/pipeline", "finishedFolders").toString(),
config.getPipelineFinishedFoldersPath());
assertEquals(
Path.of("/custom/pipeline", "defaultWebUIConfigs").toString(),
config.getPipelineDefaultWebUiConfigs());
}
@Test
@DisplayName("Blank pipelineDir falls back to the default")
void blankPipelineDirFallsBackToDefault() {
ApplicationProperties properties = newProperties();
properties.getSystem().getCustomPaths().getPipeline().setPipelineDir(" ");
RuntimePathConfig config = build(properties);
assertEquals(Path.of(BASE_PATH, "pipeline").toString(), config.getPipelinePath());
}
@Test
@DisplayName("Custom finished and webUI configs dirs override defaults")
void customFinishedAndWebUiDirs() {
ApplicationProperties properties = newProperties();
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
pipeline.setFinishedFoldersDir("/custom/finished");
pipeline.setWebUIConfigsDir("/custom/webui");
RuntimePathConfig config = build(properties);
assertEquals("/custom/finished", config.getPipelineFinishedFoldersPath());
assertEquals("/custom/webui", config.getPipelineDefaultWebUiConfigs());
}
}
@Nested
@DisplayName("Watched folder resolution")
class WatchedFolders {
@Test
@DisplayName("Default watched folder is <pipeline>/watchedFolders and list has one entry")
void defaultWatchedFolder() {
RuntimePathConfig config = build(newProperties());
// Watched folders are resolved to an absolute, normalized path by the production code.
String expected =
Path.of(Path.of(BASE_PATH, "pipeline").toString(), "watchedFolders")
.toAbsolutePath()
.normalize()
.toString();
assertEquals(expected, config.getPipelineWatchedFoldersPath());
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
assertEquals(expected, config.getPipelineWatchedFoldersPaths().get(0));
}
@Test
@DisplayName("Legacy single watchedFoldersDir is used when no list is provided")
void legacyWatchedFolder() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDir("relativeWatched");
RuntimePathConfig config = build(properties);
// Legacy paths are normalized to absolute.
String expected = Path.of("relativeWatched").toAbsolutePath().normalize().toString();
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
assertEquals(expected, config.getPipelineWatchedFoldersPath());
}
@Test
@DisplayName("New list config takes precedence over the legacy single dir")
void listTakesPrecedenceOverLegacy() {
ApplicationProperties properties = newProperties();
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
pipeline.setWatchedFoldersDir("legacyDir");
pipeline.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("listDirA", "listDirB")));
RuntimePathConfig config = build(properties);
List<String> paths = config.getPipelineWatchedFoldersPaths();
assertEquals(2, paths.size());
assertEquals(Path.of("listDirA").toAbsolutePath().normalize().toString(), paths.get(0));
assertEquals(Path.of("listDirB").toAbsolutePath().normalize().toString(), paths.get(1));
// The legacy value must NOT appear when the list is present.
assertFalse(
paths.contains(Path.of("legacyDir").toAbsolutePath().normalize().toString()));
}
@Test
@DisplayName("Duplicate paths in the list are de-duplicated after normalization")
void duplicatePathsAreDeduplicated() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(
new ArrayList<>(Arrays.asList("dupDir", "dupDir", "otherDir")));
RuntimePathConfig config = build(properties);
List<String> paths = config.getPipelineWatchedFoldersPaths();
assertEquals(2, paths.size());
assertEquals(Path.of("dupDir").toAbsolutePath().normalize().toString(), paths.get(0));
assertEquals(Path.of("otherDir").toAbsolutePath().normalize().toString(), paths.get(1));
}
@Test
@DisplayName("Blank and whitespace-only list entries are sanitized out")
void blankListEntriesAreFiltered() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(
new ArrayList<>(Arrays.asList(" ", "", "validDir", " ")));
RuntimePathConfig config = build(properties);
List<String> paths = config.getPipelineWatchedFoldersPaths();
assertEquals(1, paths.size());
assertEquals(Path.of("validDir").toAbsolutePath().normalize().toString(), paths.get(0));
}
@Test
@DisplayName("List entries are trimmed before resolution")
void listEntriesAreTrimmed() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList(" spacedDir ")));
RuntimePathConfig config = build(properties);
assertEquals(
Path.of("spacedDir").toAbsolutePath().normalize().toString(),
config.getPipelineWatchedFoldersPath());
}
@Test
@DisplayName("An all-blank list falls back to the legacy dir, then default")
void allBlankListFallsBackToDefault() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("", " ")));
RuntimePathConfig config = build(properties);
// sanitizePathList strips everything -> empty -> falls through to default watched
// folder.
// The default is also resolved to an absolute, normalized path by the production code.
String expectedDefault =
Path.of(Path.of(BASE_PATH, "pipeline").toString(), "watchedFolders")
.toAbsolutePath()
.normalize()
.toString();
assertEquals(1, config.getPipelineWatchedFoldersPaths().size());
assertEquals(expectedDefault, config.getPipelineWatchedFoldersPath());
}
@Test
@DisplayName("First watched folder path is always exposed via the singular getter")
void singularGetterReturnsFirstEntry() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getPipeline()
.setWatchedFoldersDirs(new ArrayList<>(Arrays.asList("firstDir", "secondDir")));
RuntimePathConfig config = build(properties);
assertEquals(
config.getPipelineWatchedFoldersPaths().get(0),
config.getPipelineWatchedFoldersPath());
assertEquals(
Path.of("firstDir").toAbsolutePath().normalize().toString(),
config.getPipelineWatchedFoldersPath());
}
}
@Nested
@DisplayName("Operation tool path resolution")
class OperationPaths {
@Test
@DisplayName("Defaults to bare command names when not running in Docker")
void defaultOperationPaths() {
// The test host has no /.dockerenv, so the non-docker defaults apply.
RuntimePathConfig config = build(newProperties());
assertEquals("weasyprint", config.getWeasyPrintPath());
assertEquals("unoconvert", config.getUnoConvertPath());
assertEquals("ebook-convert", config.getCalibrePath());
assertEquals("ocrmypdf", config.getOcrMyPdfPath());
assertEquals("soffice", config.getSOfficePath());
}
@Test
@DisplayName("Custom operation paths override the defaults")
void customOperationPaths() {
ApplicationProperties properties = newProperties();
Operations operations = properties.getSystem().getCustomPaths().getOperations();
operations.setWeasyprint("/opt/custom/weasyprint");
operations.setUnoconvert("/opt/custom/unoconvert");
operations.setCalibre("/opt/custom/ebook-convert");
operations.setOcrmypdf("/opt/custom/ocrmypdf");
operations.setSoffice("/opt/custom/soffice");
RuntimePathConfig config = build(properties);
assertEquals("/opt/custom/weasyprint", config.getWeasyPrintPath());
assertEquals("/opt/custom/unoconvert", config.getUnoConvertPath());
assertEquals("/opt/custom/ebook-convert", config.getCalibrePath());
assertEquals("/opt/custom/ocrmypdf", config.getOcrMyPdfPath());
assertEquals("/opt/custom/soffice", config.getSOfficePath());
}
@Test
@DisplayName("Blank custom operation path falls back to the default")
void blankOperationPathFallsBack() {
ApplicationProperties properties = newProperties();
properties.getSystem().getCustomPaths().getOperations().setWeasyprint(" ");
RuntimePathConfig config = build(properties);
assertEquals("weasyprint", config.getWeasyPrintPath());
}
@Test
@DisplayName("A single custom path leaves the other operation paths at defaults")
void partialOperationOverride() {
ApplicationProperties properties = newProperties();
properties
.getSystem()
.getCustomPaths()
.getOperations()
.setSoffice("/usr/local/soffice");
RuntimePathConfig config = build(properties);
assertEquals("/usr/local/soffice", config.getSOfficePath());
assertEquals("weasyprint", config.getWeasyPrintPath());
assertEquals("unoconvert", config.getUnoConvertPath());
}
}
@Nested
@DisplayName("Tesseract data path resolution")
class TessdataPath {
@Test
@DisplayName("Explicit tessdataDir config wins over env var and default")
void configuredTessdataDirWins() {
ApplicationProperties properties = newProperties();
properties.getSystem().setTessdataDir("/my/tessdata");
RuntimePathConfig config = build(properties);
// Config setting has the highest priority regardless of TESSDATA_PREFIX env state.
assertEquals("/my/tessdata", config.getTessDataPath());
}
@Test
@DisplayName("tessDataPath is never null even with no config")
void tessDataPathNeverNull() {
RuntimePathConfig config = build(newProperties());
// With no config setting, the value comes from TESSDATA_PREFIX or the hard default,
// either of which is non-null.
assertNotNull(config.getTessDataPath());
assertFalse(config.getTessDataPath().isEmpty());
}
}
@Nested
@DisplayName("UNO server endpoint resolution")
class UnoServerEndpoints {
@Test
@DisplayName("Auto mode builds one endpoint when session limit is unset (defaults to 1)")
void autoSingleEndpointByDefault() {
// Default ApplicationProperties: autoUnoServer = true, libreOfficeSessionLimit = 0 ->
// 1.
RuntimePathConfig config = build(newProperties());
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("127.0.0.1", endpoints.get(0).getHost());
assertEquals(2003, endpoints.get(0).getPort());
}
@Test
@DisplayName("Auto mode builds N endpoints on consecutive even ports")
void autoMultipleEndpoints() {
ApplicationProperties properties = newProperties();
properties.getProcessExecutor().getSessionLimit().setLibreOfficeSessionLimit(3);
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(3, endpoints.size());
assertEquals(2003, endpoints.get(0).getPort());
assertEquals(2005, endpoints.get(1).getPort());
assertEquals(2007, endpoints.get(2).getPort());
for (UnoServerEndpoint endpoint : endpoints) {
assertEquals("127.0.0.1", endpoint.getHost());
}
}
@Test
@DisplayName("Manual mode returns the configured (valid) endpoints")
void manualEndpointsAreUsed() {
ApplicationProperties properties = newProperties();
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
processExecutor.setAutoUnoServer(false);
UnoServerEndpoint endpoint = new UnoServerEndpoint();
endpoint.setHost("10.0.0.5");
endpoint.setPort(4000);
processExecutor.setUnoServerEndpoints(new ArrayList<>(Arrays.asList(endpoint)));
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("10.0.0.5", endpoints.get(0).getHost());
assertEquals(4000, endpoints.get(0).getPort());
}
@Test
@DisplayName("Manual mode filters out endpoints with blank host or non-positive port")
void manualEndpointsAreSanitized() {
ApplicationProperties properties = newProperties();
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
processExecutor.setAutoUnoServer(false);
UnoServerEndpoint valid = new UnoServerEndpoint();
valid.setHost("192.168.1.10");
valid.setPort(5000);
UnoServerEndpoint blankHost = new UnoServerEndpoint();
blankHost.setHost(" ");
blankHost.setPort(5001);
UnoServerEndpoint badPort = new UnoServerEndpoint();
badPort.setHost("192.168.1.11");
badPort.setPort(0);
processExecutor.setUnoServerEndpoints(
new ArrayList<>(Arrays.asList(valid, blankHost, badPort)));
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("192.168.1.10", endpoints.get(0).getHost());
assertEquals(5000, endpoints.get(0).getPort());
}
@Test
@DisplayName("Manual mode with no usable endpoints falls back to a single default endpoint")
void manualModeNoEndpointsFallsBackToDefault() {
ApplicationProperties properties = newProperties();
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
processExecutor.setAutoUnoServer(false);
processExecutor.setUnoServerEndpoints(new ArrayList<>());
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("127.0.0.1", endpoints.get(0).getHost());
assertEquals(2003, endpoints.get(0).getPort());
}
@Test
@DisplayName("Null processExecutor defaults to a single UNO endpoint")
void nullProcessExecutorDefaultsToSingleEndpoint() {
ApplicationProperties properties = newProperties();
properties.setProcessExecutor(null);
RuntimePathConfig config = build(properties);
List<UnoServerEndpoint> endpoints = config.getUnoServerEndpoints();
assertEquals(1, endpoints.size());
assertEquals("127.0.0.1", endpoints.get(0).getHost());
assertEquals(2003, endpoints.get(0).getPort());
}
}
@Nested
@DisplayName("General contract")
class GeneralContract {
@Test
@DisplayName("getProperties returns the same instance passed to the constructor")
void propertiesAccessorReturnsSameInstance() {
ApplicationProperties properties = newProperties();
RuntimePathConfig config = build(properties);
assertSame(properties, config.getProperties());
}
@Test
@DisplayName("basePath matches InstallationPathConfig.getPath()")
void basePathMatchesInstallationPath() {
RuntimePathConfig config = build(newProperties());
assertEquals(BASE_PATH, config.getBasePath());
}
@Test
@DisplayName("All resolved path getters are non-null")
void allPathsNonNull() {
RuntimePathConfig config = build(newProperties());
assertNotNull(config.getPipelinePath());
assertNotNull(config.getPipelineWatchedFoldersPath());
assertNotNull(config.getPipelineWatchedFoldersPaths());
assertNotNull(config.getPipelineFinishedFoldersPath());
assertNotNull(config.getPipelineDefaultWebUiConfigs());
assertNotNull(config.getWeasyPrintPath());
assertNotNull(config.getUnoConvertPath());
assertNotNull(config.getCalibrePath());
assertNotNull(config.getOcrMyPdfPath());
assertNotNull(config.getSOfficePath());
assertNotNull(config.getTessDataPath());
assertNotNull(config.getUnoServerEndpoints());
assertTrue(config.getUnoServerEndpoints().size() >= 1);
}
}
}
@@ -31,6 +31,22 @@ class ApplicationPropertiesLogicTest {
assertTrue(sys.isAnalyticsEnabled());
}
@Test
void storageSigning_userListScope_defaultsToOrg_andIsSettable() {
// Self-host backward-compat: scope must default to "org" (saas profile pins "team").
ApplicationProperties.Storage.Signing signing = new ApplicationProperties.Storage.Signing();
assertFalse(signing.isEnabled());
assertEquals("org", signing.getUserListScope());
signing.setUserListScope("team");
assertEquals("team", signing.getUserListScope());
// Reachable from the full tree as storage.signing.userListScope.
assertEquals(
"org", new ApplicationProperties().getStorage().getSigning().getUserListScope());
}
@Test
void tempFileManagement_defaults_and_overrides() {
Function<String, String> normalize = s -> Path.of(s).normalize().toString();
@@ -0,0 +1,244 @@
package stirling.software.common.pdf;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.jpdfium.text.PageText;
import stirling.software.jpdfium.text.Table;
import stirling.software.jpdfium.text.TextChar;
import stirling.software.jpdfium.text.TextLine;
import stirling.software.jpdfium.text.TextWord;
/**
* Gap-filling tests for {@link PdfMarkdownConverter} not covered by {@link
* PdfMarkdownConverterTest}: the visible-for-testing column-range detector across a range of
* geometries, the package-private extraction helpers, and the full conversion of the wrapped-cell
* fixture (only run under a disabled accuracy test in the sibling suite).
*/
class PdfMarkdownConverterMoreTest {
@TempDir Path tmp;
// ---- helpers ------------------------------------------------------------
/** A word occupying [x, x+width] on baseline y; chars are synthetic so text length is real. */
private static TextWord word(String text, float x, float width) {
List<TextChar> chars = new ArrayList<>();
for (int i = 0; i < text.length(); i++) {
chars.add(
new TextChar(
i,
text.charAt(i),
x,
0f,
width / Math.max(1, text.length()),
10f,
"Helvetica",
10f));
}
return new TextWord(chars, x, 0f, width, 10f);
}
/** A single-line row built from the given words, spanning their full x-range. */
private static TextLine row(float y, TextWord... words) {
float minX = Float.MAX_VALUE;
float maxX = -Float.MAX_VALUE;
for (TextWord w : words) {
minX = Math.min(minX, w.x());
maxX = Math.max(maxX, w.x() + w.width());
}
return new TextLine(List.of(words), minX, y, maxX - minX, 10f);
}
/** Copies a classpath fixture into the temp dir and returns its path. */
private Path fixture(String name) throws IOException {
Path dest = tmp.resolve(name);
try (InputStream in = getClass().getResourceAsStream("/pdf-ingestion-fixtures/" + name)) {
assertThat(in).as("fixture on classpath: " + name).isNotNull();
Files.copy(in, dest);
}
return dest;
}
// ---- findColumnRangesFromLines -----------------------------------------
@Nested
@DisplayName("findColumnRangesFromLines")
class ColumnRanges {
@Test
@DisplayName("two well-separated bands are detected as two columns")
void twoColumns() {
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 4; r++) {
float y = 400f - r * 12f;
rows.add(row(y, word("left", 50f, 40f), word("right", 190f, 40f)));
}
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
assertThat(cols).hasSize(2);
// First band starts near 50, second near 190.
assertThat(cols.get(0)[0]).isLessThan(cols.get(1)[0]);
}
@Test
@DisplayName("two bands within a narrow gutter merge into one column")
void narrowGutterMerges() {
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 4; r++) {
float y = 400f - r * 12f;
// Gap of ~10pt is far below the merge threshold for 40pt-wide words.
rows.add(row(y, word("aa", 50f, 40f), word("bb", 100f, 40f)));
}
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
assertThat(cols).hasSize(1);
}
@Test
@DisplayName("a single occupied band yields one column (trailing-band flush)")
void singleColumn() {
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 3; r++) {
rows.add(row(400f - r * 12f, word("word", 50f, 60f)));
}
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
assertThat(cols).hasSize(1);
assertThat(cols.get(0)[0]).isCloseTo(50f, org.assertj.core.api.Assertions.within(2f));
}
@Test
@DisplayName("rows with no words produce no columns")
void noWordsNoColumns() {
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 3; r++) {
rows.add(new TextLine(List.of(), 0f, 400f - r * 12f, 0f, 10f));
}
assertThat(PdfMarkdownConverter.findColumnRangesFromLines(rows)).isEmpty();
}
@Test
@DisplayName("an empty row list produces no columns")
void emptyInput() {
assertThat(PdfMarkdownConverter.findColumnRangesFromLines(List.of())).isEmpty();
}
@Test
@DisplayName("a sparsely-covered band below the support threshold is dropped")
void sparseBandDropped() {
// Five rows fill the left band; only one fills a far-right band, which is below the
// 35%-of-rows support floor and so is not reported as a column.
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 5; r++) {
rows.add(row(400f - r * 12f, word("left", 50f, 40f)));
}
rows.add(row(320f, word("left", 50f, 40f), word("rareoutlier", 400f, 60f)));
List<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
assertThat(cols).hasSize(1);
}
}
// ---- package-private extraction helpers ---------------------------------
@Nested
@DisplayName("extraction helpers")
class ExtractionHelpers {
@Test
@DisplayName("extractAllPageText returns one PageText per page")
void extractAllPageText() throws IOException {
Path pdf = fixture("bordered-table-test_widget.pdf");
try (PdfDocument doc = PdfDocument.open(pdf)) {
List<PageText> pages = new PdfMarkdownConverter().extractAllPageText(doc);
assertThat(pages).isNotNull();
assertThat(pages).hasSize(doc.pageCount());
}
}
@Test
@DisplayName("extractTables returns a non-null list for the first page")
void extractTables() throws IOException {
Path pdf = fixture("bordered-table-test_widget.pdf");
try (PdfDocument doc = PdfDocument.open(pdf)) {
List<Table> tables = new PdfMarkdownConverter().extractTables(doc, 0);
assertThat(tables).isNotNull();
}
}
@Test
@DisplayName("renderTables maps each extracted table to a markdown string")
void renderTables() throws IOException {
Path pdf = fixture("bordered-table-test_widget.pdf");
PdfMarkdownConverter converter = new PdfMarkdownConverter();
try (PdfDocument doc = PdfDocument.open(pdf)) {
List<Table> tables = converter.extractTables(doc, 0);
List<String> rendered = converter.renderTables(tables);
assertThat(rendered).isNotNull();
assertThat(rendered).hasSameSizeAs(tables);
}
}
@Test
@DisplayName("renderTables on an empty table list returns an empty list")
void renderTablesEmpty() {
assertThat(new PdfMarkdownConverter().renderTables(List.of())).isEmpty();
}
}
// ---- full conversion of additional fixtures -----------------------------
@Nested
@DisplayName("convert full pipeline")
class ConvertPipeline {
@Test
@DisplayName("wrapped-cell expense report converts without throwing and yields content")
void wrappedCellFixture() throws IOException {
Path pdf = fixture("wrapped-cell-test_expense-report.pdf");
String md;
try (PdfDocument doc = PdfDocument.open(pdf)) {
md = new PdfMarkdownConverter().convert(doc);
}
assertThat(md).isNotNull();
assertThat(md).isNotBlank();
}
@Test
@DisplayName("converting a fixture twice is deterministic")
void deterministic() throws IOException {
Path pdf = fixture("multi-column-test_lorem.pdf");
String first;
String second;
try (PdfDocument doc = PdfDocument.open(pdf)) {
first = new PdfMarkdownConverter().convert(doc);
}
try (PdfDocument doc = PdfDocument.open(pdf)) {
second = new PdfMarkdownConverter().convert(doc);
}
assertThat(first).isEqualTo(second);
}
@Test
@DisplayName("the many-tables stress fixture converts without throwing")
void manyTablesFixture() throws IOException {
Path pdf = fixture("many-tables-test_stress.pdf");
assertDoesNotThrow(
() -> {
try (PdfDocument doc = PdfDocument.open(pdf)) {
return new PdfMarkdownConverter().convert(doc);
}
});
}
}
}

Some files were not shown because too many files have changed in this diff Show More