Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3869072cf7 | ||
|
|
3817779b9e | ||
|
|
c4e66f2c2d | ||
|
|
c27fd4db69 | ||
|
|
0ae7052dcb | ||
|
|
69fc4d5bc1 | ||
|
|
2e023a6e78 | ||
|
|
7e523d48d7 | ||
|
|
b8cb020e59 | ||
|
|
a92722ff13 | ||
|
|
fed7ad300f |
@@ -1,97 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,67 +0,0 @@
|
||||
// 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 });
|
||||
});
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// 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 : ""}`);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"""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 )")
|
||||
@@ -1,106 +0,0 @@
|
||||
<!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 {"&":"&","<":"<",">":">"}[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 · base <code>'+esc(D.base)+'</code> → head <code>'+esc(D.head)+'</code>'+
|
||||
(D.cropSelector ? ' · 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>
|
||||
@@ -1,25 +0,0 @@
|
||||
// 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();
|
||||
@@ -1,120 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,116 +0,0 @@
|
||||
"""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"))
|
||||
@@ -1,298 +0,0 @@
|
||||
<!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">‹</button>
|
||||
<img id="stage-img" alt="" />
|
||||
<div class="missing hide" id="missing"></div>
|
||||
<button class="nav-btn next" id="next" aria-label="Next">›</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>
|
||||
@@ -27,6 +27,7 @@ node_modules/
|
||||
**/node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/editor/dist/
|
||||
frontend/dist-portal/
|
||||
frontend/editor/playwright-report/
|
||||
.npm/
|
||||
.yarn/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.14.1
|
||||
pkgver=2.14.0
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.14.1
|
||||
pkgver=2.14.0
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -87,21 +87,6 @@ engine: &engine
|
||||
- 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
|
||||
- *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"
|
||||
|
||||
@@ -116,9 +116,6 @@ jobs:
|
||||
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
|
||||
@@ -249,9 +246,7 @@ jobs:
|
||||
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
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push V2 image (Docker fork fallback)
|
||||
@@ -264,9 +259,7 @@ 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_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
@@ -297,8 +290,6 @@ jobs:
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
POLICIES_ENABLED: "true"
|
||||
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
|
||||
@@ -342,70 +333,9 @@ 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: |
|
||||
@@ -429,40 +359,12 @@ jobs:
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
|
||||
|
||||
// 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 httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
|
||||
|
||||
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` +
|
||||
portalNote +
|
||||
storybookNote +
|
||||
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
|
||||
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
|
||||
`🔄 **Auto-deployed** for approved V2 contributors.`;
|
||||
|
||||
@@ -558,11 +460,7 @@ 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
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: AI Engine CI
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
@@ -34,9 +34,104 @@ jobs:
|
||||
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.6.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.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,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Quality-check engine
|
||||
id: engine-check
|
||||
run: task engine:check
|
||||
|
||||
@@ -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 four
|
||||
# stacks under testing/compose. Slow and secret-gated, so it runs in three
|
||||
# situations:
|
||||
#
|
||||
# - PRs that touch proprietary / premium / SSO compose / enterprise tests
|
||||
@@ -12,6 +12,8 @@ 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:
|
||||
@@ -50,10 +52,6 @@ jobs:
|
||||
|
||||
playwright-e2e-enterprise:
|
||||
needs: pick
|
||||
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE
|
||||
# (nor DEPOT_TOKEN), 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: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -167,8 +165,6 @@ 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()
|
||||
@@ -242,8 +238,6 @@ 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()
|
||||
@@ -274,8 +268,6 @@ 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()
|
||||
@@ -288,23 +280,10 @@ 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/playwright-report/
|
||||
path: frontend/editor/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -43,7 +43,6 @@ jobs:
|
||||
docker-base: ${{ steps.changes.outputs.docker-base }}
|
||||
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)
|
||||
@@ -172,20 +171,6 @@ 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:
|
||||
@@ -217,9 +202,6 @@ 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-
|
||||
@@ -243,7 +225,6 @@ jobs:
|
||||
- test-build-docker-images
|
||||
- tauri-build
|
||||
- ai-engine
|
||||
- generated-models
|
||||
- pre-commit
|
||||
- dependency-review
|
||||
runs-on: ubuntu-latest
|
||||
@@ -269,7 +250,6 @@ 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: |
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
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
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
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
|
||||
|
||||
- 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.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,
|
||||
});
|
||||
}
|
||||
@@ -13,17 +13,6 @@ 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
|
||||
@@ -207,9 +196,9 @@ jobs:
|
||||
# --------------------------------------------------------------
|
||||
- name: Download vitest coverage artifact
|
||||
# frontend-validation uploads as `frontend-coverage`. Tolerate
|
||||
# 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'
|
||||
# absence so a backend-only PR still produces the matrix with
|
||||
# just backend rows populated.
|
||||
if: always()
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
|
||||
with:
|
||||
name: frontend-coverage
|
||||
@@ -217,12 +206,12 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Download Playwright frontend coverage artifact
|
||||
# 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'
|
||||
# e2e-live uploads as `playwright-frontend-coverage-<run_id>`.
|
||||
# Same tolerance as vitest - matrix script handles missing inputs.
|
||||
if: always()
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
|
||||
with:
|
||||
name: playwright-frontend-coverage
|
||||
name: playwright-frontend-coverage-${{ github.run_id }}
|
||||
path: matrix-inputs/playwright/
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
@@ -62,17 +62,7 @@ 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
|
||||
@@ -179,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
|
||||
name: playwright-frontend-coverage-${{ github.run_id }}
|
||||
path: |
|
||||
.test-state/playwright/coverage-pw-summary/
|
||||
.test-state/playwright/coverage-pw/
|
||||
|
||||
@@ -44,22 +44,11 @@ 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/playwright-report/
|
||||
path: frontend/editor/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -98,13 +98,6 @@ jobs:
|
||||
|
||||
- name: Install Task
|
||||
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:
|
||||
@@ -360,7 +353,6 @@ jobs:
|
||||
|
||||
- name: Install Task
|
||||
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
|
||||
|
||||
@@ -53,8 +53,8 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-nightly-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
name: playwright-nightly-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
# Builds all desktop platforms on a schedule so the Rust dependency cache is
|
||||
|
||||
+2
-2
@@ -15,10 +15,10 @@ 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
|
||||
frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
|
||||
|
||||
# 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
|
||||
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
|
||||
|
||||
# False positive: generic-api-key matches the Java type name "X509Certificate"
|
||||
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
|
||||
|
||||
+98
-22
@@ -55,6 +55,15 @@ tasks:
|
||||
- editor/src/core/data/ogImageMap.json
|
||||
- editor/public/og-metadata.json
|
||||
|
||||
prepare:classifier-categories:
|
||||
internal: true
|
||||
run: when_changed
|
||||
desc: "Regenerate the engine classifier categories JSON from the TS source of truth"
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-classification-taxonomy.mts
|
||||
sources:
|
||||
- editor/src/proprietary/data/classificationTaxonomy.ts
|
||||
|
||||
prepare:
|
||||
desc: "Set up dev environment"
|
||||
run: when_changed
|
||||
@@ -65,6 +74,7 @@ tasks:
|
||||
vars: { MODE: '{{.MODE}}' }
|
||||
- prepare:icons
|
||||
- prepare:og
|
||||
- prepare:classifier-categories
|
||||
|
||||
# ============================================================
|
||||
# Development
|
||||
@@ -128,6 +138,37 @@ tasks:
|
||||
- task: dev:_run
|
||||
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||
|
||||
dev:portal:
|
||||
desc: "Start developer portal dev server"
|
||||
ignore_error: true
|
||||
deps: [install]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "5173"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_URL: '{{.EDITOR_URL | default ""}}'
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
MOCKS: '{{.MOCKS | default ""}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
|
||||
|
||||
dev:portal:proxy:serve:
|
||||
internal: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
|
||||
cmds:
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
# ============================================================
|
||||
@@ -174,6 +215,29 @@ tasks:
|
||||
cmds:
|
||||
- npx vite build editor --mode prototypes
|
||||
|
||||
build:portal:
|
||||
desc: "Build developer portal"
|
||||
deps: [install]
|
||||
vars:
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
cmds:
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
|
||||
deps: [prepare]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- task: build:proprietary
|
||||
vars: { PREVIEW: '1' }
|
||||
- task: build:portal
|
||||
vars: { SUBPATH: portal }
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
|
||||
storybook:
|
||||
desc: "Start Storybook dev server"
|
||||
@@ -209,8 +273,8 @@ tasks:
|
||||
deps: [install]
|
||||
cmds:
|
||||
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
|
||||
# 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
|
||||
# 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
|
||||
|
||||
lint:fix:
|
||||
desc: "Auto-fix lint issues"
|
||||
@@ -291,6 +355,8 @@ tasks:
|
||||
desc: "Typecheck scripts"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: scripts/tsconfig.json }
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/scripts/tsconfig.json }
|
||||
|
||||
@@ -306,7 +372,14 @@ tasks:
|
||||
deps: [install]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: editor/src/portal/tsconfig.json }
|
||||
vars: { PROJECT: portal/tsconfig.json }
|
||||
|
||||
typecheck:shared:
|
||||
desc: "Typecheck the shared design system"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- task: typecheck:_run
|
||||
vars: { PROJECT: shared/tsconfig.json }
|
||||
|
||||
typecheck:all:
|
||||
desc: "Typecheck all build variants"
|
||||
@@ -319,6 +392,7 @@ tasks:
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
- task: typecheck:portal
|
||||
- task: typecheck:shared
|
||||
|
||||
# ============================================================
|
||||
# Quality Gate
|
||||
@@ -337,16 +411,28 @@ tasks:
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs --check
|
||||
|
||||
classifier-categories:
|
||||
desc: "Regenerate the engine classifier categories JSON from the TS source"
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-classification-taxonomy.mts
|
||||
|
||||
classifier-categories:check:
|
||||
desc: "Fail if the committed classifier categories JSON is out of date"
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-classification-taxonomy.mts --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: classifier-categories:check
|
||||
- task: typecheck:all
|
||||
- task: lint
|
||||
- task: format:check
|
||||
- task: build
|
||||
- task: build:portal
|
||||
- task: test
|
||||
- task: storybook:build
|
||||
|
||||
@@ -358,6 +444,7 @@ tasks:
|
||||
desc: "Run tests"
|
||||
cmds:
|
||||
- task: test:editor
|
||||
- task: test:portal
|
||||
|
||||
test:editor:
|
||||
desc: "Run editor tests"
|
||||
@@ -365,6 +452,12 @@ tasks:
|
||||
cmds:
|
||||
- npx vitest run --root editor
|
||||
|
||||
test:portal:
|
||||
desc: "Run portal tests"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx vitest run --root portal
|
||||
|
||||
test:watch:
|
||||
desc: "Run tests in watch mode"
|
||||
deps: [prepare]
|
||||
@@ -396,23 +489,6 @@ 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]
|
||||
@@ -426,7 +502,7 @@ tasks:
|
||||
clean:
|
||||
desc: "Clean build artifacts and caches"
|
||||
cmds:
|
||||
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
|
||||
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal
|
||||
platforms: [windows]
|
||||
- cmd: rm -rf node_modules/.vite editor/dist dist
|
||||
- cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
|
||||
platforms: [linux, darwin]
|
||||
|
||||
@@ -139,8 +139,7 @@ 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 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` — core, proprietary, and shared vars
|
||||
- `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
|
||||
@@ -453,7 +452,6 @@ 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
@@ -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.
|
||||
6. **Frontend Setup (Required for Stirling 2.0)**
|
||||
5. **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 to enable login and security features for builds
|
||||
export DISABLE_ADDITIONAL_FEATURES=true # or false for 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 use case
|
||||
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
|
||||
|
||||
## 7. Testing
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ 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/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/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/portal/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ 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
|
||||
|
||||
|
||||
+106
-13
@@ -79,12 +79,86 @@ tasks:
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:
|
||||
desc: "Start backend + editor; the portal is an admin route at /portal"
|
||||
desc: "Start backend + developer portal concurrently on free ports"
|
||||
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}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:all:
|
||||
desc: "Start backend + developer portal + editor concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
# Point the portal's "Editor" app switcher at the editor we spawn here.
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
dev:portal:all:saas:
|
||||
desc: "Start SaaS backend + developer portal + editor concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
deps:
|
||||
- task: backend:dev:saas
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
dev:portal:proxy:
|
||||
desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
@@ -95,7 +169,18 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
SUBPATH: portal
|
||||
MOCKS: 'false'
|
||||
- task: frontend:dev:portal:proxy:serve
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
|
||||
PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
|
||||
|
||||
dev:saas:
|
||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||
@@ -143,6 +228,24 @@ tasks:
|
||||
- task: backend:build
|
||||
- task: frontend:build
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:preview:portal:proxy
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
# ============================================================
|
||||
# Test
|
||||
# ============================================================
|
||||
@@ -185,16 +288,6 @@ 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
|
||||
# ============================================================
|
||||
|
||||
@@ -80,18 +80,10 @@
|
||||
"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"
|
||||
@@ -116,10 +108,6 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "CDDL+GPL License"
|
||||
@@ -184,14 +172,6 @@
|
||||
"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"
|
||||
|
||||
@@ -132,7 +132,7 @@ public class AppConfig {
|
||||
return true;
|
||||
}
|
||||
Path mountInfo = Path.of("/proc/1/mountinfo");
|
||||
// this should always exist, if not some unknown use case
|
||||
// this should always exist, if not some unknown usecase
|
||||
if (!Files.exists(mountInfo)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ public class RuntimePathConfig {
|
||||
defaultWatchedFolders,
|
||||
watchedFoldersDirs,
|
||||
pipeline != null ? pipeline.getWatchedFoldersDir() : null);
|
||||
this.pipelineWatchedFoldersPath = this.pipelineWatchedFoldersPaths.getFirst();
|
||||
this.pipelineWatchedFoldersPath = this.pipelineWatchedFoldersPaths.get(0);
|
||||
this.pipelineFinishedFoldersPath =
|
||||
resolvePath(
|
||||
defaultFinishedFolders,
|
||||
|
||||
@@ -60,40 +60,54 @@ public class Provider {
|
||||
}
|
||||
|
||||
private UsernameAttribute validateUsernameAttribute(UsernameAttribute usernameAttribute) {
|
||||
return switch (name) {
|
||||
case "google" -> validateGoogleUsernameAttribute(usernameAttribute);
|
||||
case "github" -> validateGitHubUsernameAttribute(usernameAttribute);
|
||||
case "keycloak" -> validateKeycloakUsernameAttribute(usernameAttribute);
|
||||
default -> usernameAttribute;
|
||||
};
|
||||
switch (name) {
|
||||
case "google" -> {
|
||||
return validateGoogleUsernameAttribute(usernameAttribute);
|
||||
}
|
||||
case "github" -> {
|
||||
return validateGitHubUsernameAttribute(usernameAttribute);
|
||||
}
|
||||
case "keycloak" -> {
|
||||
return validateKeycloakUsernameAttribute(usernameAttribute);
|
||||
}
|
||||
default -> {
|
||||
return usernameAttribute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private UsernameAttribute validateKeycloakUsernameAttribute(
|
||||
UsernameAttribute usernameAttribute) {
|
||||
return switch (usernameAttribute) {
|
||||
case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME, PREFERRED_USERNAME -> usernameAttribute;
|
||||
switch (usernameAttribute) {
|
||||
case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME, PREFERRED_USERNAME -> {
|
||||
return usernameAttribute;
|
||||
}
|
||||
default ->
|
||||
throw new UnsupportedClaimException(
|
||||
String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private UsernameAttribute validateGoogleUsernameAttribute(UsernameAttribute usernameAttribute) {
|
||||
return switch (usernameAttribute) {
|
||||
case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME -> usernameAttribute;
|
||||
switch (usernameAttribute) {
|
||||
case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME -> {
|
||||
return usernameAttribute;
|
||||
}
|
||||
default ->
|
||||
throw new UnsupportedClaimException(
|
||||
String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private UsernameAttribute validateGitHubUsernameAttribute(UsernameAttribute usernameAttribute) {
|
||||
return switch (usernameAttribute) {
|
||||
case LOGIN, EMAIL, NAME -> usernameAttribute;
|
||||
switch (usernameAttribute) {
|
||||
case LOGIN, EMAIL, NAME -> {
|
||||
return usernameAttribute;
|
||||
}
|
||||
default ->
|
||||
throw new UnsupportedClaimException(
|
||||
String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -361,8 +361,8 @@ public class PdfMarkdownConverter {
|
||||
if (xs.isEmpty()) {
|
||||
return List.of(lines);
|
||||
}
|
||||
float minX = xs.getFirst();
|
||||
float maxX = xs.getLast();
|
||||
float minX = xs.get(0);
|
||||
float maxX = xs.get(xs.size() - 1);
|
||||
float splitAt = (minX + maxX) / 2f;
|
||||
float biggestGap = 0;
|
||||
for (int i = 1; i < xs.size(); i++) {
|
||||
@@ -492,7 +492,7 @@ public class PdfMarkdownConverter {
|
||||
|
||||
List<List<Line>> anchorGroups = new ArrayList<>();
|
||||
List<Line> current = new ArrayList<>();
|
||||
current.add(cands.getFirst());
|
||||
current.add(cands.get(0));
|
||||
for (int i = 1; i < cands.size(); i++) {
|
||||
float gap = cands.get(i - 1).y - cands.get(i).y;
|
||||
if (gap > splitThreshold) {
|
||||
@@ -513,8 +513,8 @@ public class PdfMarkdownConverter {
|
||||
if (anchors.size() < 2) {
|
||||
continue;
|
||||
}
|
||||
float top = anchors.getFirst().y;
|
||||
float bottom = anchors.getLast().y;
|
||||
float top = anchors.get(0).y;
|
||||
float bottom = anchors.get(anchors.size() - 1).y;
|
||||
|
||||
// Each anchor seeds a row; absorb wrapped continuation lines (non-anchors within the
|
||||
// run's vertical span, with a little slack below the last row) into the anchor above.
|
||||
@@ -674,8 +674,8 @@ public class PdfMarkdownConverter {
|
||||
float minGutter = Math.max(10f, charWidth * 2.5f);
|
||||
List<float[]> merged = new ArrayList<>();
|
||||
for (float[] band : columns) {
|
||||
if (!merged.isEmpty() && band[0] - merged.getLast()[1] < minGutter) {
|
||||
merged.getLast()[1] = band[1];
|
||||
if (!merged.isEmpty() && band[0] - merged.get(merged.size() - 1)[1] < minGutter) {
|
||||
merged.get(merged.size() - 1)[1] = band[1];
|
||||
} else {
|
||||
merged.add(new float[] {band[0], band[1]});
|
||||
}
|
||||
@@ -734,7 +734,7 @@ public class PdfMarkdownConverter {
|
||||
}
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(buildGfmRow(rows.getFirst(), widths, cols)).append('\n');
|
||||
sb.append(buildGfmRow(rows.get(0), widths, cols)).append('\n');
|
||||
sb.append('|');
|
||||
for (int c = 0; c < cols; c++) {
|
||||
sb.append('-').append("-".repeat(widths[c])).append('-').append('|');
|
||||
@@ -910,8 +910,8 @@ public class PdfMarkdownConverter {
|
||||
}
|
||||
// Only merge a sentence continuation between two text paragraphs, never into/out of a
|
||||
// table.
|
||||
if (!(output.getLast() instanceof String last)
|
||||
|| !(pageItems.getFirst() instanceof String first)) {
|
||||
if (!(output.get(output.size() - 1) instanceof String last)
|
||||
|| !(pageItems.get(0) instanceof String first)) {
|
||||
return;
|
||||
}
|
||||
if (!first.isEmpty()
|
||||
@@ -932,13 +932,13 @@ public class PdfMarkdownConverter {
|
||||
for (Object e : elements) {
|
||||
if (e instanceof TableBlock tb
|
||||
&& !out.isEmpty()
|
||||
&& out.getLast() instanceof TableBlock prev
|
||||
&& out.get(out.size() - 1) instanceof TableBlock prev
|
||||
&& columnsMatch(flatten(prev.rows()), flatten(tb.rows()))) {
|
||||
List<List<Line>> merged = new ArrayList<>(prev.rows());
|
||||
List<List<Line>> tail = tb.rows();
|
||||
if (!tail.isEmpty()
|
||||
&& !prev.rows().isEmpty()
|
||||
&& rowText(tail.getFirst()).equals(rowText(prev.rows().getFirst()))) {
|
||||
&& rowText(tail.get(0)).equals(rowText(prev.rows().get(0)))) {
|
||||
tail = tail.subList(1, tail.size());
|
||||
}
|
||||
merged.addAll(tail);
|
||||
@@ -971,7 +971,7 @@ public class PdfMarkdownConverter {
|
||||
continue;
|
||||
}
|
||||
if (e instanceof TableBlock tb && !tb.rows().isEmpty()) {
|
||||
return rowText(tb.rows().getFirst());
|
||||
return rowText(tb.rows().get(0));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ public class InternalApiClient {
|
||||
boolean hasFilePart =
|
||||
body.values().stream()
|
||||
.flatMap(java.util.List::stream)
|
||||
.anyMatch(Resource.class::isInstance);
|
||||
.anyMatch(v -> v instanceof Resource);
|
||||
if (isAiTool && !hasFilePart) {
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ import stirling.software.common.model.PdfMetadata;
|
||||
@Service
|
||||
public class PdfMetadataService {
|
||||
|
||||
/** ({@code {labels}}). Written by the classify-and-label tool. */
|
||||
/**
|
||||
* ({@code {category, docType, typeConfidence, tags}}). Written by the classify-and-tag tool.
|
||||
*/
|
||||
public static final String CLASSIFICATION_KEY = "StirlingPDFClassification";
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@@ -140,25 +140,20 @@ public class ChecksumUtils {
|
||||
|
||||
for (String algorithm : algorithms) {
|
||||
String key = algorithm; // keep original key for output
|
||||
Object digestOrChecksum =
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32" -> new CRC32();
|
||||
case "ADLER32" -> new Adler32();
|
||||
default -> {
|
||||
try {
|
||||
// For MessageDigest, pass the original name (case-insensitive
|
||||
// per JCA)
|
||||
yield MessageDigest.getInstance(algorithm);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(
|
||||
"Unsupported algorithm: " + algorithm, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (digestOrChecksum instanceof Checksum checksum) {
|
||||
checksums.put(key, checksum);
|
||||
} else {
|
||||
digests.put(key, (MessageDigest) digestOrChecksum);
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32":
|
||||
checksums.put(key, new CRC32());
|
||||
break;
|
||||
case "ADLER32":
|
||||
checksums.put(key, new Adler32());
|
||||
break;
|
||||
default:
|
||||
try {
|
||||
// For MessageDigest, pass the original name (case-insensitive per JCA)
|
||||
digests.put(key, MessageDigest.getInstance(algorithm));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("Unsupported algorithm: " + algorithm, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -751,7 +751,7 @@ public class ExceptionUtils {
|
||||
String targetDescription;
|
||||
if (errorInfo.affectedPages() != null && !errorInfo.affectedPages().isEmpty()) {
|
||||
if (errorInfo.affectedPages().size() == 1) {
|
||||
targetDescription = "page " + errorInfo.affectedPages().getFirst();
|
||||
targetDescription = "page " + errorInfo.affectedPages().get(0);
|
||||
} else {
|
||||
targetDescription =
|
||||
"pages "
|
||||
@@ -848,7 +848,7 @@ public class ExceptionUtils {
|
||||
}
|
||||
|
||||
// Use the first page number, or null if none found
|
||||
Integer pageNumber = affectedPages.isEmpty() ? null : affectedPages.getFirst();
|
||||
Integer pageNumber = affectedPages.isEmpty() ? null : affectedPages.get(0);
|
||||
|
||||
return new GhostscriptErrorInfo(
|
||||
ErrorCode.GHOSTSCRIPT_PAGE_DRAWING,
|
||||
|
||||
@@ -114,7 +114,7 @@ public enum FormFieldTypeSupport {
|
||||
return;
|
||||
}
|
||||
|
||||
PDAnnotationWidget widget = checkBox.getWidgets().getFirst();
|
||||
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
|
||||
|
||||
PDAppearanceCharacteristicsDictionary appearanceChars =
|
||||
widget.getAppearanceCharacteristics();
|
||||
|
||||
@@ -88,16 +88,28 @@ public class FormUtils {
|
||||
* text)
|
||||
*/
|
||||
public String detectFieldType(PDField field) {
|
||||
return switch (field) {
|
||||
case PDSignatureField ignored -> FIELD_TYPE_SIGNATURE;
|
||||
case PDPushButton ignored -> FIELD_TYPE_BUTTON;
|
||||
case PDTextField ignored -> FIELD_TYPE_TEXT;
|
||||
case PDCheckBox ignored -> FIELD_TYPE_CHECKBOX;
|
||||
case PDComboBox ignored -> FIELD_TYPE_COMBOBOX;
|
||||
case PDListBox ignored -> FIELD_TYPE_LISTBOX;
|
||||
case PDRadioButton ignored -> FIELD_TYPE_RADIO;
|
||||
case null, default -> FIELD_TYPE_TEXT;
|
||||
};
|
||||
if (field instanceof PDSignatureField) {
|
||||
return FIELD_TYPE_SIGNATURE;
|
||||
}
|
||||
if (field instanceof PDPushButton) {
|
||||
return FIELD_TYPE_BUTTON;
|
||||
}
|
||||
if (field instanceof PDTextField) {
|
||||
return FIELD_TYPE_TEXT;
|
||||
}
|
||||
if (field instanceof PDCheckBox) {
|
||||
return FIELD_TYPE_CHECKBOX;
|
||||
}
|
||||
if (field instanceof PDComboBox) {
|
||||
return FIELD_TYPE_COMBOBOX;
|
||||
}
|
||||
if (field instanceof PDListBox) {
|
||||
return FIELD_TYPE_LISTBOX;
|
||||
}
|
||||
if (field instanceof PDRadioButton) {
|
||||
return FIELD_TYPE_RADIO;
|
||||
}
|
||||
return FIELD_TYPE_TEXT;
|
||||
}
|
||||
|
||||
public List<FormFieldInfo> extractFormFields(PDDocument document) {
|
||||
@@ -571,17 +583,22 @@ public class FormUtils {
|
||||
continue;
|
||||
}
|
||||
String type = info.type();
|
||||
Object value =
|
||||
switch (type) {
|
||||
case FIELD_TYPE_CHECKBOX ->
|
||||
isChecked(info.value()) ? Boolean.TRUE : Boolean.FALSE;
|
||||
case FIELD_TYPE_LISTBOX ->
|
||||
info.multiSelect() ? new ArrayList<>() : safeDefault(info.value());
|
||||
case FIELD_TYPE_BUTTON, FIELD_TYPE_SIGNATURE -> null;
|
||||
default -> safeDefault(info.value());
|
||||
};
|
||||
if (value == null) {
|
||||
continue; // skip non-fillable
|
||||
Object value;
|
||||
switch (type) {
|
||||
case FIELD_TYPE_CHECKBOX:
|
||||
value = isChecked(info.value()) ? Boolean.TRUE : Boolean.FALSE;
|
||||
break;
|
||||
case FIELD_TYPE_LISTBOX:
|
||||
if (info.multiSelect()) {
|
||||
value = new ArrayList<>();
|
||||
} else {
|
||||
value = safeDefault(info.value());
|
||||
}
|
||||
break;
|
||||
case FIELD_TYPE_BUTTON, FIELD_TYPE_SIGNATURE:
|
||||
continue; // skip non-fillable
|
||||
default:
|
||||
value = safeDefault(info.value());
|
||||
}
|
||||
record.put(info.name(), value);
|
||||
}
|
||||
@@ -932,44 +949,44 @@ public class FormUtils {
|
||||
if (selection == null || selection.trim().isEmpty()) return null;
|
||||
List<String> filtered =
|
||||
filterChoiceSelections(List.of(selection), allowedOptions, fieldName);
|
||||
return filtered.isEmpty() ? null : filtered.getFirst();
|
||||
return filtered.isEmpty() ? null : filtered.get(0);
|
||||
}
|
||||
|
||||
private void applyValueToField(PDField field, String value, boolean strict) throws IOException {
|
||||
try {
|
||||
switch (field) {
|
||||
case PDTextField textField -> setTextValue(textField, value);
|
||||
case PDCheckBox checkBox -> {
|
||||
LinkedHashSet<String> candidateStates = collectCheckBoxStates(checkBox);
|
||||
boolean shouldCheck = shouldCheckBoxBeChecked(value, candidateStates);
|
||||
try {
|
||||
if (shouldCheck) {
|
||||
checkBox.check();
|
||||
} else {
|
||||
checkBox.unCheck();
|
||||
}
|
||||
} catch (IOException checkProblem) {
|
||||
log.warn(
|
||||
"Failed to set checkbox state for '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
checkProblem.getMessage(),
|
||||
checkProblem);
|
||||
if (strict) {
|
||||
throw checkProblem;
|
||||
}
|
||||
if (field instanceof PDTextField textField) {
|
||||
setTextValue(textField, value);
|
||||
} else if (field instanceof PDCheckBox checkBox) {
|
||||
LinkedHashSet<String> candidateStates = collectCheckBoxStates(checkBox);
|
||||
boolean shouldCheck = shouldCheckBoxBeChecked(value, candidateStates);
|
||||
try {
|
||||
if (shouldCheck) {
|
||||
checkBox.check();
|
||||
} else {
|
||||
checkBox.unCheck();
|
||||
}
|
||||
} catch (IOException checkProblem) {
|
||||
log.warn(
|
||||
"Failed to set checkbox state for '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
checkProblem.getMessage(),
|
||||
checkProblem);
|
||||
if (strict) {
|
||||
throw checkProblem;
|
||||
}
|
||||
}
|
||||
case PDRadioButton radioButton -> {
|
||||
if (value != null && !value.isBlank()) {
|
||||
radioButton.setValue(value);
|
||||
}
|
||||
} else if (field instanceof PDRadioButton radioButton) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
radioButton.setValue(value);
|
||||
}
|
||||
case PDChoice choiceField -> applyChoiceValue(choiceField, value);
|
||||
case PDPushButton ignored -> log.debug("Ignore Push button");
|
||||
case PDSignatureField ignored ->
|
||||
log.debug("Skipping signature field '{}'", field.getFullyQualifiedName());
|
||||
case null -> log.warn("Attempted to set value on null field");
|
||||
default -> field.setValue(value != null ? value : "");
|
||||
} else if (field instanceof PDChoice choiceField) {
|
||||
applyChoiceValue(choiceField, value);
|
||||
} else if (field instanceof PDPushButton) {
|
||||
log.debug("Ignore Push button");
|
||||
} else if (field instanceof PDSignatureField) {
|
||||
log.debug("Skipping signature field '{}'", field.getFullyQualifiedName());
|
||||
} else {
|
||||
field.setValue(value != null ? value : "");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
@@ -1289,42 +1306,37 @@ public class FormUtils {
|
||||
|
||||
List<String> resolveOptions(PDTerminalField field) {
|
||||
try {
|
||||
return switch (field) {
|
||||
case PDChoice choice -> {
|
||||
LinkedHashSet<String> allowed = new LinkedHashSet<>();
|
||||
List<String> exportValues = choice.getOptionsExportValues();
|
||||
List<String> displayValues = choice.getOptionsDisplayValues();
|
||||
if (field instanceof PDChoice choice) {
|
||||
LinkedHashSet<String> allowed = new LinkedHashSet<>();
|
||||
List<String> exportValues = choice.getOptionsExportValues();
|
||||
List<String> displayValues = choice.getOptionsDisplayValues();
|
||||
|
||||
if (exportValues != null) {
|
||||
exportValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
}
|
||||
if (displayValues != null) {
|
||||
displayValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
}
|
||||
yield new ArrayList<>(allowed);
|
||||
if (exportValues != null) {
|
||||
exportValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
}
|
||||
case PDRadioButton radio -> {
|
||||
List<String> exports = radio.getExportValues();
|
||||
yield exports != null && !exports.isEmpty()
|
||||
? new ArrayList<>(exports)
|
||||
: Collections.emptyList();
|
||||
if (displayValues != null) {
|
||||
displayValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
}
|
||||
case PDCheckBox checkBox -> {
|
||||
List<String> exports = checkBox.getExportValues();
|
||||
yield exports != null && !exports.isEmpty()
|
||||
? new ArrayList<>(exports)
|
||||
: Collections.emptyList();
|
||||
return new ArrayList<>(allowed);
|
||||
} else if (field instanceof PDRadioButton radio) {
|
||||
List<String> exports = radio.getExportValues();
|
||||
if (exports != null && !exports.isEmpty()) {
|
||||
return new ArrayList<>(exports);
|
||||
}
|
||||
case null, default -> Collections.emptyList();
|
||||
};
|
||||
} else if (field instanceof PDCheckBox checkBox) {
|
||||
List<String> exports = checkBox.getExportValues();
|
||||
if (exports != null && !exports.isEmpty()) {
|
||||
return new ArrayList<>(exports);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to resolve options for field '{}': {}",
|
||||
@@ -1453,7 +1465,7 @@ public class FormUtils {
|
||||
|
||||
// Only check options for choice-type fields (combobox, listbox, radio)
|
||||
if (CHOICE_FIELD_TYPES.contains(type) && options != null && !options.isEmpty()) {
|
||||
String optionCandidate = cleanLabel(options.getFirst());
|
||||
String optionCandidate = cleanLabel(options.get(0));
|
||||
if (optionCandidate != null && !looksGeneric(optionCandidate)) {
|
||||
return optionCandidate;
|
||||
}
|
||||
@@ -1545,7 +1557,7 @@ public class FormUtils {
|
||||
continue;
|
||||
}
|
||||
|
||||
PDAnnotationWidget widget = widgets.getFirst();
|
||||
PDAnnotationWidget widget = widgets.get(0);
|
||||
PDRectangle originalRectangle = cloneRectangle(widget.getRectangle());
|
||||
PDPage page = resolveWidgetPage(document, widget, null);
|
||||
if (page == null || originalRectangle == null) {
|
||||
@@ -2434,19 +2446,19 @@ public class FormUtils {
|
||||
|
||||
private static int firstWidgetPageIndex(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().getFirst().getPageIndex()
|
||||
? f.getWidgets().get(0).getPageIndex()
|
||||
: -1;
|
||||
}
|
||||
|
||||
private static float firstWidgetY(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().getFirst().getY()
|
||||
? f.getWidgets().get(0).getY()
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static float firstWidgetX(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().getFirst().getX()
|
||||
? f.getWidgets().get(0).getX()
|
||||
: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,27 +26,29 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class ImageProcessingUtils {
|
||||
|
||||
static BufferedImage convertColorType(BufferedImage sourceImage, String colorType) {
|
||||
return switch (colorType) {
|
||||
case "greyscale" -> {
|
||||
BufferedImage convertedImage =
|
||||
BufferedImage convertedImage;
|
||||
switch (colorType) {
|
||||
case "greyscale":
|
||||
convertedImage =
|
||||
new BufferedImage(
|
||||
sourceImage.getWidth(),
|
||||
sourceImage.getHeight(),
|
||||
BufferedImage.TYPE_BYTE_GRAY);
|
||||
convertedImage.getGraphics().drawImage(sourceImage, 0, 0, null);
|
||||
yield convertedImage;
|
||||
}
|
||||
case "blackwhite" -> {
|
||||
BufferedImage convertedImage =
|
||||
break;
|
||||
case "blackwhite":
|
||||
convertedImage =
|
||||
new BufferedImage(
|
||||
sourceImage.getWidth(),
|
||||
sourceImage.getHeight(),
|
||||
BufferedImage.TYPE_BYTE_BINARY);
|
||||
convertedImage.getGraphics().drawImage(sourceImage, 0, 0, null);
|
||||
yield convertedImage;
|
||||
}
|
||||
default -> sourceImage;
|
||||
};
|
||||
break;
|
||||
default: // full color
|
||||
convertedImage = sourceImage;
|
||||
break;
|
||||
}
|
||||
return convertedImage;
|
||||
}
|
||||
|
||||
public static byte[] getImageData(BufferedImage image) {
|
||||
|
||||
@@ -330,7 +330,7 @@ public class PDFToFile {
|
||||
|
||||
if (outputFiles.size() == 1) {
|
||||
// Return single output file
|
||||
File outputFile = outputFiles.getFirst();
|
||||
File outputFile = outputFiles.get(0);
|
||||
if ("txt:Text".equals(outputFormat)) {
|
||||
outputFormat = "txt";
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ public class ProcessExecutor {
|
||||
boolean isQpdf =
|
||||
commandToRun != null
|
||||
&& !commandToRun.isEmpty()
|
||||
&& commandToRun.getFirst().contains("qpdf");
|
||||
&& commandToRun.get(0).contains("qpdf");
|
||||
|
||||
if (!outputLines.isEmpty()) {
|
||||
String outputMessage = String.join("\n", outputLines);
|
||||
@@ -370,7 +370,7 @@ public class ProcessExecutor {
|
||||
}
|
||||
|
||||
// Check if this is a UNO conversion by looking for unoconvert executable
|
||||
String executable = command.getFirst();
|
||||
String executable = command.get(0);
|
||||
if (executable != null) {
|
||||
// Extract basename from path for matching
|
||||
String basename = executable;
|
||||
@@ -504,7 +504,7 @@ public class ProcessExecutor {
|
||||
}
|
||||
|
||||
// Validate executable (first argument)
|
||||
String executable = command.getFirst();
|
||||
String executable = command.get(0);
|
||||
if (executable == null || executable.isBlank()) {
|
||||
throw new IllegalArgumentException("Command executable must not be empty");
|
||||
}
|
||||
|
||||
@@ -57,16 +57,6 @@ 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")
|
||||
|
||||
@@ -114,7 +114,7 @@ public class YamlHelper {
|
||||
|
||||
for (NodeTuple tuple : mappingNode.getValue()) {
|
||||
ScalarNode keyNode = (tuple.getKeyNode() instanceof ScalarNode sk) ? sk : null;
|
||||
if (keyNode == null || !keyNode.getValue().equals(keys.getFirst())) {
|
||||
if (keyNode == null || !keyNode.getValue().equals(keys.get(0))) {
|
||||
updatedTuples.add(tuple);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -721,7 +721,7 @@ class PDFToFileTest {
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
List<String> args = invocation.getArgument(0);
|
||||
String outputPath = args.getLast();
|
||||
String outputPath = args.get(args.size() - 1);
|
||||
Files.write(Path.of(outputPath), "Fake DOCX content".getBytes());
|
||||
return mockExecutorResult;
|
||||
});
|
||||
|
||||
@@ -73,14 +73,6 @@ class RequestUriUtilsTest {
|
||||
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_portalShell() {
|
||||
// The admin portal SPA shell (/processor) is served pre-auth so it's directly navigable.
|
||||
assertTrue(RequestUriUtils.isStaticResource("/processor"));
|
||||
assertTrue(RequestUriUtils.isStaticResource("/processor/users"));
|
||||
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/processor"));
|
||||
}
|
||||
|
||||
// --- isFrontendRoute tests ---
|
||||
|
||||
@Test
|
||||
|
||||
+1
-11
@@ -175,14 +175,6 @@ springBoot {
|
||||
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
|
||||
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
|
||||
def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true'
|
||||
// The admin portal ships as a lazy route inside the editor bundle (see
|
||||
// proprietary/routes/adminRouteExtensions). -PbuildWithPortal=true includes that
|
||||
// chunk via VITE_INCLUDE_PORTAL on the editor build; the deploy GHA sets it when
|
||||
// the portal or AI layers change. Building the portal implies building the editor.
|
||||
def buildWithPortal = project.hasProperty('buildWithPortal') && project.property('buildWithPortal') == 'true'
|
||||
if (buildWithPortal) {
|
||||
buildWithFrontend = true
|
||||
}
|
||||
// Workspace root holds package.json and node_modules (shared across editor /
|
||||
// future portal). Editor-specific paths (src, public, dist, tauri) live one
|
||||
// level deeper under frontend/editor/.
|
||||
@@ -305,11 +297,9 @@ tasks.register('npmBuild', Exec) {
|
||||
// Override VITE_API_BASE_URL to use relative paths for production builds
|
||||
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
|
||||
environment 'VITE_API_BASE_URL', '/'
|
||||
// Include the admin portal's lazy route/chunk in the editor build when requested.
|
||||
environment 'VITE_INCLUDE_PORTAL', (buildWithPortal ? 'true' : 'false')
|
||||
|
||||
doFirst {
|
||||
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})"
|
||||
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.servlet.MultipartConfigFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -21,11 +22,7 @@ import stirling.software.SPDF.controller.web.UploadLimitService;
|
||||
@Slf4j
|
||||
public class MultipartConfiguration {
|
||||
|
||||
private final UploadLimitService uploadLimitService;
|
||||
|
||||
public MultipartConfiguration(UploadLimitService uploadLimitService) {
|
||||
this.uploadLimitService = uploadLimitService;
|
||||
}
|
||||
@Autowired private UploadLimitService uploadLimitService;
|
||||
|
||||
/**
|
||||
* Creates MultipartConfigElement that respects fileUploadLimit from settings.yml or environment
|
||||
|
||||
+7
-8
@@ -294,25 +294,24 @@ public class BookletImpositionController {
|
||||
|
||||
// Apply rotation if needed (rotate about origin), then translate to keep in cell
|
||||
switch (rot) {
|
||||
case 90 -> {
|
||||
case 90:
|
||||
cs.transform(Matrix.getRotateInstance(Math.PI / 2, 0, 0));
|
||||
// After 90° CCW, the content spans x in [-r.getHeight(), 0] and y in [0,
|
||||
// r.getWidth()]
|
||||
cs.transform(Matrix.getTranslateInstance(0, -r.getWidth()));
|
||||
}
|
||||
case 180 -> {
|
||||
break;
|
||||
case 180:
|
||||
cs.transform(Matrix.getRotateInstance(Math.PI, 0, 0));
|
||||
cs.transform(Matrix.getTranslateInstance(-r.getWidth(), -r.getHeight()));
|
||||
}
|
||||
case 270 -> {
|
||||
break;
|
||||
case 270:
|
||||
cs.transform(Matrix.getRotateInstance(3 * Math.PI / 2, 0, 0));
|
||||
// After 270° CCW, the content spans x in [0, r.getHeight()] and y in
|
||||
// [-r.getWidth(), 0]
|
||||
cs.transform(Matrix.getTranslateInstance(-r.getHeight(), 0));
|
||||
}
|
||||
default -> {
|
||||
break;
|
||||
default:
|
||||
// 0°: no-op
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse LayerUtility passed from caller
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
@@ -57,6 +58,8 @@ import stirling.software.jpdfium.doc.PdfBookmarkEditor.BookmarkTree;
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class MergeController {
|
||||
|
||||
private static final Pattern QUOTE_WRAP_PATTERN = Pattern.compile("^\"|\"$");
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@@ -158,6 +161,30 @@ public class MergeController {
|
||||
};
|
||||
}
|
||||
|
||||
private String[] parseClientFileIds(String clientFileIds) {
|
||||
if (clientFileIds == null || clientFileIds.trim().isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
try {
|
||||
String trimmed = clientFileIds.trim();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
String inside = trimmed.substring(1, trimmed.length() - 1).trim();
|
||||
if (inside.isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
String[] parts = inside.split(",");
|
||||
String[] result = new String[parts.length];
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
result[i] = QUOTE_WRAP_PATTERN.matcher(parts[i].trim()).replaceAll("");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse client file IDs: {}", clientFileIds, e);
|
||||
}
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
|
||||
PDDocumentOutline outline = new PDDocumentOutline();
|
||||
mergedDocument.getDocumentCatalog().setDocumentOutline(outline);
|
||||
|
||||
+20
-11
@@ -125,14 +125,17 @@ public class UIDataController {
|
||||
pipelineConfigs.add(content);
|
||||
}
|
||||
|
||||
for (int i = 0; i < pipelineConfigs.size(); i++) {
|
||||
String config = pipelineConfigs.get(i);
|
||||
for (String config : pipelineConfigs) {
|
||||
Map<String, Object> jsonContent =
|
||||
objectMapper.readValue(
|
||||
config, new TypeReference<Map<String, Object>>() {});
|
||||
String name = (String) jsonContent.get("name");
|
||||
if (name == null || name.length() < 1) {
|
||||
String filename = jsonFiles.get(i).getFileName().toString();
|
||||
String filename =
|
||||
jsonFiles
|
||||
.get(pipelineConfigs.indexOf(config))
|
||||
.getFileName()
|
||||
.toString();
|
||||
name = filename.substring(0, filename.lastIndexOf('.'));
|
||||
}
|
||||
Map<String, String> configWithName = new HashMap<>();
|
||||
@@ -298,14 +301,20 @@ public class UIDataController {
|
||||
}
|
||||
|
||||
private static String getFormatFromExtension(String extension) {
|
||||
return switch (extension) {
|
||||
case "ttf" -> "truetype";
|
||||
case "woff" -> "woff";
|
||||
case "woff2" -> "woff2";
|
||||
case "eot" -> "embedded-opentype";
|
||||
case "svg" -> "svg";
|
||||
default -> "";
|
||||
};
|
||||
switch (extension) {
|
||||
case "ttf":
|
||||
return "truetype";
|
||||
case "woff":
|
||||
return "woff";
|
||||
case "woff2":
|
||||
return "woff2";
|
||||
case "eot":
|
||||
return "embedded-opentype";
|
||||
case "svg":
|
||||
return "svg";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -187,7 +187,7 @@ public class ConvertImgPDFController {
|
||||
}
|
||||
|
||||
if (webpFiles.size() == 1) {
|
||||
Path webpFilePath = webpFiles.getFirst();
|
||||
Path webpFilePath = webpFiles.get(0);
|
||||
byte[] webpBytes = Files.readAllBytes(webpFilePath);
|
||||
Files.deleteIfExists(tempFile);
|
||||
tempFile = null;
|
||||
|
||||
+3
-3
@@ -147,7 +147,7 @@ public class ConvertSvgToPDF {
|
||||
String outputFilename =
|
||||
filenames.isEmpty()
|
||||
? "combined_svgs.pdf"
|
||||
: GeneralUtils.generateFilename(filenames.getFirst(), "_combined.pdf");
|
||||
: GeneralUtils.generateFilename(filenames.get(0), "_combined.pdf");
|
||||
|
||||
log.info("Successfully combined {} SVGs into single PDF", sanitizedSvgs.size());
|
||||
|
||||
@@ -203,7 +203,7 @@ public class ConvertSvgToPDF {
|
||||
|
||||
try {
|
||||
if (convertedPdfs.size() == 1) {
|
||||
ConvertedPdf pdf = convertedPdfs.getFirst();
|
||||
ConvertedPdf pdf = convertedPdfs.get(0);
|
||||
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try {
|
||||
Files.write(tempOut.getPath(), pdf.content);
|
||||
@@ -218,7 +218,7 @@ public class ConvertSvgToPDF {
|
||||
filenames.isEmpty()
|
||||
? "converted_svgs.zip"
|
||||
: GeneralUtils.generateFilename(
|
||||
filenames.getFirst(), "_converted_svgs.zip");
|
||||
filenames.get(0), "_converted_svgs.zip");
|
||||
TempFile zipFile = createZipFromPdfs(convertedPdfs);
|
||||
return WebResponseUtils.zipFileToWebResponse(zipFile, zipFilename);
|
||||
} catch (IOException e) {
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ public class ExtractCSVController {
|
||||
if (csvEntries.isEmpty()) {
|
||||
return ResponseEntity.noContent().build();
|
||||
} else if (csvEntries.size() == 1) {
|
||||
return createCsvResponse(csvEntries.getFirst(), baseName);
|
||||
return createCsvResponse(csvEntries.get(0), baseName);
|
||||
} else {
|
||||
return createZipResponse(csvEntries, baseName);
|
||||
}
|
||||
|
||||
+1
-3
@@ -113,9 +113,7 @@ public class AutoRenameController {
|
||||
mergedLineInfos.sort(
|
||||
Comparator.comparing((LineInfo li) -> li.fontSize).reversed());
|
||||
String title =
|
||||
mergedLineInfos.isEmpty()
|
||||
? null
|
||||
: mergedLineInfos.getFirst().text;
|
||||
mergedLineInfos.isEmpty() ? null : mergedLineInfos.get(0).text;
|
||||
|
||||
return title != null
|
||||
? title
|
||||
|
||||
+1
-1
@@ -333,7 +333,7 @@ public class AutoSplitPdfController {
|
||||
}
|
||||
|
||||
if (!splitDocuments.isEmpty() && !isValidQrCode) {
|
||||
splitDocuments.getLast().addPage(document.getPage(page));
|
||||
splitDocuments.get(splitDocuments.size() - 1).addPage(document.getPage(page));
|
||||
} else if (page == 0) {
|
||||
PDDocument firstDocument = new PDDocument();
|
||||
firstDocument.addPage(document.getPage(page));
|
||||
|
||||
+2
-2
@@ -267,7 +267,7 @@ public class CompressController {
|
||||
if (references.isEmpty()) continue;
|
||||
|
||||
// Get the first instance of this image
|
||||
PDImageXObject originalImage = getOriginalImage(doc, references.getFirst());
|
||||
PDImageXObject originalImage = getOriginalImage(doc, references.get(0));
|
||||
|
||||
// Track original size
|
||||
int originalSize = (int) originalImage.getCOSObject().getLength();
|
||||
@@ -1167,7 +1167,7 @@ public class CompressController {
|
||||
List<ImageReference> references = entry.getValue();
|
||||
if (references.isEmpty()) continue;
|
||||
|
||||
PDImageXObject originalImage = getOriginalImage(doc, references.getFirst());
|
||||
PDImageXObject originalImage = getOriginalImage(doc, references.get(0));
|
||||
|
||||
int originalSize = (int) originalImage.getCOSObject().getLength();
|
||||
stats.totalOriginalBytes += originalSize;
|
||||
|
||||
+1
-1
@@ -211,7 +211,7 @@ public class ExtractImageScansController {
|
||||
} else {
|
||||
|
||||
// Return the processed image as a response
|
||||
byte[] imageBytes = processedImageBytes.getFirst();
|
||||
byte[] imageBytes = processedImageBytes.get(0);
|
||||
finalOutput = tempFileManager.createManagedTempFile(".png");
|
||||
try (OutputStream out = Files.newOutputStream(finalOutput.getPath())) {
|
||||
out.write(imageBytes);
|
||||
|
||||
+1
-1
@@ -404,7 +404,7 @@ public class CertSignController {
|
||||
PDAcroForm acroForm = new PDAcroForm(doc);
|
||||
doc.getDocumentCatalog().setAcroForm(acroForm);
|
||||
PDSignatureField signatureField = new PDSignatureField(acroForm);
|
||||
PDAnnotationWidget widget = signatureField.getWidgets().getFirst();
|
||||
PDAnnotationWidget widget = signatureField.getWidgets().get(0);
|
||||
List<PDField> acroFormFields = acroForm.getFields();
|
||||
acroForm.setSignaturesExist(true);
|
||||
acroForm.setAppendOnly(true);
|
||||
|
||||
+1
-1
@@ -634,7 +634,7 @@ class RedactExecuteService {
|
||||
PageColumnLayout layout =
|
||||
PageColumnLayout.fromLineBoxes(extractor.getLineBoxes(), pageWidth);
|
||||
if (layout.columnCount() > 1) {
|
||||
float[] g = layout.gutters().getFirst();
|
||||
float[] g = layout.gutters().get(0);
|
||||
log.info(
|
||||
"[redact/execute] page {} layout: 2 cols, gutter x=[{}, {}]",
|
||||
pageIdx + 1,
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ public class RemoveCertSignController {
|
||||
// Remove signature fields safely
|
||||
List<PDField> fieldsToRemove =
|
||||
acroForm.getFields().stream()
|
||||
.filter(PDSignatureField.class::isInstance)
|
||||
.filter(field -> field instanceof PDSignatureField)
|
||||
.toList();
|
||||
|
||||
if (!fieldsToRemove.isEmpty()) {
|
||||
|
||||
+2
-5
@@ -2,6 +2,7 @@ package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -12,11 +13,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
@Slf4j
|
||||
public class UploadLimitService {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public UploadLimitService(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
@Autowired private ApplicationProperties applicationProperties;
|
||||
|
||||
public long getUploadLimit() {
|
||||
String raw =
|
||||
|
||||
+12
-34
@@ -1,7 +1,5 @@
|
||||
package stirling.software.SPDF.model.api.general;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
@@ -19,8 +17,20 @@ public class PosterPdfRequest extends PDFFile {
|
||||
allowableValues = {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"})
|
||||
private String pageSize = "A4";
|
||||
|
||||
@Schema(
|
||||
description = "Horizontal decimation factor (how many columns to split into)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "2",
|
||||
minimum = "1",
|
||||
maximum = "10")
|
||||
private int xFactor = 2;
|
||||
|
||||
@Schema(
|
||||
description = "Vertical decimation factor (how many rows to split into)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "2",
|
||||
minimum = "1",
|
||||
maximum = "10")
|
||||
private int yFactor = 2;
|
||||
|
||||
@Schema(
|
||||
@@ -28,36 +38,4 @@ public class PosterPdfRequest extends PDFFile {
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "false")
|
||||
private boolean rightToLeft = false;
|
||||
|
||||
@JsonProperty("xFactor")
|
||||
@Schema(
|
||||
description = "Horizontal decimation factor (how many columns to split into)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "2",
|
||||
minimum = "1",
|
||||
maximum = "10")
|
||||
public int getXFactor() {
|
||||
return xFactor;
|
||||
}
|
||||
|
||||
@JsonProperty("xFactor")
|
||||
public void setXFactor(int xFactor) {
|
||||
this.xFactor = xFactor;
|
||||
}
|
||||
|
||||
@JsonProperty("yFactor")
|
||||
@Schema(
|
||||
description = "Vertical decimation factor (how many rows to split into)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "2",
|
||||
minimum = "1",
|
||||
maximum = "10")
|
||||
public int getYFactor() {
|
||||
return yFactor;
|
||||
}
|
||||
|
||||
@JsonProperty("yFactor")
|
||||
public void setYFactor(int yFactor) {
|
||||
this.yFactor = yFactor;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -29,8 +29,7 @@ public class AddPasswordRequest extends PDFFile {
|
||||
description = "The length of the encryption key",
|
||||
type = "integer",
|
||||
allowableValues = {"40", "128", "256"},
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "256")
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int keyLength = 256;
|
||||
|
||||
@Schema(description = "Whether document assembly is prevented", defaultValue = "false")
|
||||
|
||||
@@ -2688,7 +2688,7 @@ public class PdfJsonConversionService {
|
||||
|
||||
// Find which page the field is on
|
||||
PDAnnotationWidget widget =
|
||||
field.getWidgets().isEmpty() ? null : field.getWidgets().getFirst();
|
||||
field.getWidgets().isEmpty() ? null : field.getWidgets().get(0);
|
||||
if (widget != null) {
|
||||
PDPage fieldPage = widget.getPage();
|
||||
if (fieldPage != null) {
|
||||
@@ -3164,7 +3164,7 @@ public class PdfJsonConversionService {
|
||||
&& imageObjectNames != null
|
||||
&& !imageObjectNames.isEmpty()
|
||||
&& !targetTokens.isEmpty()) {
|
||||
Object previous = targetTokens.getLast();
|
||||
Object previous = targetTokens.get(targetTokens.size() - 1);
|
||||
if (previous instanceof COSName cosName
|
||||
&& imageObjectNames.contains(cosName.getName())) {
|
||||
targetTokens.remove(targetTokens.size() - 1);
|
||||
@@ -5246,7 +5246,7 @@ public class PdfJsonConversionService {
|
||||
throws IOException {
|
||||
if (OperatorName.DRAW_OBJECT.equals(operator.getName())
|
||||
&& !operands.isEmpty()
|
||||
&& operands.getFirst() instanceof COSName name) {
|
||||
&& operands.get(0) instanceof COSName name) {
|
||||
currentXObjectName = name;
|
||||
}
|
||||
super.processOperator(operator, operands);
|
||||
|
||||
+1
-1
@@ -420,7 +420,7 @@ public class PdfJsonImageService {
|
||||
throws IOException {
|
||||
if (OperatorName.DRAW_OBJECT.equals(operator.getName())
|
||||
&& !operands.isEmpty()
|
||||
&& operands.getFirst() instanceof COSName name) {
|
||||
&& operands.get(0) instanceof COSName name) {
|
||||
currentXObjectName = name;
|
||||
}
|
||||
super.processOperator(operator, operands);
|
||||
|
||||
@@ -137,7 +137,7 @@ public class JobController {
|
||||
if (result.hasFiles() && !result.hasMultipleFiles()) {
|
||||
try {
|
||||
List<ResultFile> files = result.getAllResultFiles();
|
||||
ResultFile singleFile = files.getFirst();
|
||||
ResultFile singleFile = files.get(0);
|
||||
|
||||
byte[] fileContent = fileStorage.retrieveBytes(singleFile.getFileId());
|
||||
return ResponseEntity.ok()
|
||||
|
||||
+6
-1
@@ -4,6 +4,8 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
@@ -22,7 +24,10 @@ class MultipartConfigurationTest {
|
||||
void setUp() throws Exception {
|
||||
// Manually constructed config with a mocked service, so Spring env overrides do not apply.
|
||||
uploadLimitService = mock(UploadLimitService.class);
|
||||
configuration = new MultipartConfiguration(uploadLimitService);
|
||||
configuration = new MultipartConfiguration();
|
||||
Field field = MultipartConfiguration.class.getDeclaredField("uploadLimitService");
|
||||
field.setAccessible(true);
|
||||
field.set(configuration, uploadLimitService);
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
+58
@@ -84,6 +84,12 @@ class MergeControllerGapTest {
|
||||
return (MultipartFile[]) m.invoke(null, files, fileOrder);
|
||||
}
|
||||
|
||||
private String[] parseClientFileIds(String value) throws Exception {
|
||||
Method m = MergeController.class.getDeclaredMethod("parseClientFileIds", String.class);
|
||||
m.setAccessible(true);
|
||||
return (String[]) m.invoke(mergeController, value);
|
||||
}
|
||||
|
||||
private long getPdfDateTimeSafe(MultipartFile file) throws Exception {
|
||||
Method m =
|
||||
MergeController.class.getDeclaredMethod("getPdfDateTimeSafe", MultipartFile.class);
|
||||
@@ -325,6 +331,58 @@ class MergeControllerGapTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- parseClientFileIds -------------------------------------------------
|
||||
|
||||
@Nested
|
||||
@DisplayName("parseClientFileIds")
|
||||
class ParseClientFileIds {
|
||||
|
||||
@Test
|
||||
@DisplayName("null input returns empty array")
|
||||
void nullReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds(null).length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank input returns empty array")
|
||||
void blankReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds(" ").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty JSON array returns empty array")
|
||||
void emptyArrayReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds("[]").length);
|
||||
assertEquals(0, parseClientFileIds("[ ]").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-array text returns empty array")
|
||||
void nonArrayReturnsEmpty() throws Exception {
|
||||
assertEquals(0, parseClientFileIds("not-an-array").length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses quoted, comma-separated ids and strips surrounding quotes")
|
||||
void parsesQuotedIds() throws Exception {
|
||||
String[] result = parseClientFileIds("[\"id1\", \"id2\",\"id3\"]");
|
||||
assertArrayEquals(new String[] {"id1", "id2", "id3"}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses unquoted ids as-is after trimming")
|
||||
void parsesUnquotedIds() throws Exception {
|
||||
String[] result = parseClientFileIds("[a, b , c]");
|
||||
assertArrayEquals(new String[] {"a", "b", "c"}, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single element array yields a one-element result")
|
||||
void singleElement() throws Exception {
|
||||
assertArrayEquals(new String[] {"only"}, parseClientFileIds("[\"only\"]"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- reorderFilesByProvidedOrder ----------------------------------------
|
||||
|
||||
@Nested
|
||||
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
class UIDataControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Test
|
||||
void getPipelineData_usesEachSourceFilenameWhenJsonContentIsIdentical() throws Exception {
|
||||
Path configDir = tempDir.resolve("defaultWebUIConfigs");
|
||||
Files.createDirectories(configDir);
|
||||
|
||||
String configJson = "{\"steps\":[]}";
|
||||
Files.writeString(configDir.resolve("first-config.json"), configJson);
|
||||
Files.writeString(configDir.resolve("second-config.json"), configJson);
|
||||
|
||||
ApplicationProperties applicationProperties = mock(ApplicationProperties.class);
|
||||
SharedSignatureService signatureService = mock(SharedSignatureService.class);
|
||||
RuntimePathConfig runtimePathConfig = mock(RuntimePathConfig.class);
|
||||
|
||||
when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(configDir.toString());
|
||||
|
||||
UIDataController controller =
|
||||
new UIDataController(
|
||||
applicationProperties,
|
||||
signatureService,
|
||||
null,
|
||||
new DefaultResourceLoader(),
|
||||
runtimePathConfig,
|
||||
new ObjectMapper());
|
||||
|
||||
ResponseEntity<UIDataController.PipelineData> response = controller.getPipelineData();
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
UIDataController.PipelineData body = response.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
|
||||
List<Map<String, String>> configsWithNames = body.getPipelineConfigsWithNames();
|
||||
assertThat(configsWithNames).hasSize(2);
|
||||
assertThat(configsWithNames)
|
||||
.extracting(entry -> entry.get("name"))
|
||||
.containsExactlyInAnyOrder("first-config", "second-config");
|
||||
assertThat(configsWithNames)
|
||||
.extracting(entry -> entry.get("json"))
|
||||
.containsOnly(configJson);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -204,7 +204,7 @@ class ConvertOfficeControllerTest {
|
||||
inv -> {
|
||||
// unoconvert writes directly to the output path (last arg)
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path out = Path.of(command.getLast());
|
||||
Path out = Path.of(command.get(command.size() - 1));
|
||||
Files.writeString(out, "%PDF-1.4 produced");
|
||||
return result;
|
||||
});
|
||||
@@ -239,7 +239,7 @@ class ConvertOfficeControllerTest {
|
||||
inv -> {
|
||||
// soffice writes <basename>.pdf into the --outdir (workDir)
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path inputPath = Path.of(command.getLast());
|
||||
Path inputPath = Path.of(command.get(command.size() - 1));
|
||||
Path out = inputPath.getParent().resolve("report.pdf");
|
||||
Files.writeString(out, "%PDF soffice");
|
||||
return result;
|
||||
@@ -311,7 +311,7 @@ class ConvertOfficeControllerTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path inputPath = Path.of(command.getLast());
|
||||
Path inputPath = Path.of(command.get(command.size() - 1));
|
||||
Path out = inputPath.getParent().resolve("report.pdf");
|
||||
Files.write(out, new byte[0]);
|
||||
return result;
|
||||
@@ -344,7 +344,7 @@ class ConvertOfficeControllerTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path inputPath = Path.of(command.getLast());
|
||||
Path inputPath = Path.of(command.get(command.size() - 1));
|
||||
Path out = inputPath.getParent().resolve("page.pdf");
|
||||
Files.writeString(out, "%PDF html");
|
||||
return result;
|
||||
@@ -398,7 +398,7 @@ class ConvertOfficeControllerTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> command = inv.getArgument(0);
|
||||
Path inputPath = Path.of(command.getLast());
|
||||
Path inputPath = Path.of(command.get(command.size() - 1));
|
||||
Path out = inputPath.getParent().resolve("report.pdf");
|
||||
Files.writeString(out, "%PDF produced");
|
||||
return result;
|
||||
|
||||
+1
-1
@@ -197,7 +197,7 @@ class ConvertPDFToPDFAMoreTest {
|
||||
// qpdf normalize/clean writes its (last-arg) output file
|
||||
if (command.contains("--normalize-content=y")) {
|
||||
// qpdf produced file is the last argument
|
||||
Path out = Path.of(command.getLast());
|
||||
Path out = Path.of(command.get(command.size() - 1));
|
||||
Files.write(out, simplePdfBytes());
|
||||
}
|
||||
return okResult;
|
||||
|
||||
+2
-2
@@ -262,7 +262,7 @@ class ConvertPdfToVideoControllerTest {
|
||||
assertTrue(command.contains("+faststart"));
|
||||
assertFalse(command.contains("libvpx-vp9"));
|
||||
// Output path is always the last argument.
|
||||
assertEquals(backing.getAbsolutePath(), command.getLast());
|
||||
assertEquals(backing.getAbsolutePath(), command.get(command.size() - 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -278,7 +278,7 @@ class ConvertPdfToVideoControllerTest {
|
||||
assertTrue(command.contains("30"));
|
||||
assertFalse(command.contains("libx264"));
|
||||
assertFalse(command.contains("+faststart"));
|
||||
assertEquals(backing.getAbsolutePath(), command.getLast());
|
||||
assertEquals(backing.getAbsolutePath(), command.get(command.size() - 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@ class CompressControllerMoreTest {
|
||||
|
||||
// The qpdf output path is the last argument of the command.
|
||||
private static Path qpdfOutputPath(List<String> command) {
|
||||
return Path.of(command.getLast());
|
||||
return Path.of(command.get(command.size() - 1));
|
||||
}
|
||||
|
||||
/** Stub gs to write a valid PDF to its output file and report success. */
|
||||
|
||||
+2
-2
@@ -162,7 +162,7 @@ class RemoveImagesControllerTest {
|
||||
/** Counts every PDImageXObject reachable through page + nested form resources. */
|
||||
private int countImagesInSavedOutput() throws IOException {
|
||||
assertFalse(savedTempFiles.isEmpty(), "expected the controller to create a temp file");
|
||||
File out = savedTempFiles.getLast();
|
||||
File out = savedTempFiles.get(savedTempFiles.size() - 1);
|
||||
try (PDDocument doc = Loader.loadPDF(out)) {
|
||||
int count = 0;
|
||||
for (PDPage page : doc.getPages()) {
|
||||
@@ -245,7 +245,7 @@ class RemoveImagesControllerTest {
|
||||
|
||||
assertEquals(0, countImagesInSavedOutput());
|
||||
// page count must be preserved
|
||||
File out = savedTempFiles.getLast();
|
||||
File out = savedTempFiles.get(savedTempFiles.size() - 1);
|
||||
try (PDDocument result = Loader.loadPDF(out)) {
|
||||
assertEquals(3, result.getNumberOfPages());
|
||||
}
|
||||
|
||||
+10
-6
@@ -98,8 +98,12 @@ class RepairControllerMoreTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes a valid PDF to the given output path, mimicking a successful tool run. */
|
||||
private static void writeValidPdfTo(Path out) throws Exception {
|
||||
/**
|
||||
* Writes a valid PDF to the path at the given command index, mimicking a successful tool run.
|
||||
*/
|
||||
private static void writeValidPdfTo(List<String> command, int outputPathIndex)
|
||||
throws Exception {
|
||||
Path out = Path.of(command.get(outputPathIndex));
|
||||
byte[] pdf = buildPdfBytes(1);
|
||||
Files.write(out, pdf);
|
||||
}
|
||||
@@ -129,7 +133,7 @@ class RepairControllerMoreTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> cmd = inv.getArgument(0);
|
||||
writeValidPdfTo(Path.of(cmd.get(2)));
|
||||
writeValidPdfTo(cmd, 2);
|
||||
return okResult;
|
||||
});
|
||||
|
||||
@@ -172,7 +176,7 @@ class RepairControllerMoreTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> cmd = inv.getArgument(0);
|
||||
writeValidPdfTo(Path.of(cmd.getLast()));
|
||||
writeValidPdfTo(cmd, cmd.size() - 1);
|
||||
return okResult;
|
||||
});
|
||||
|
||||
@@ -212,7 +216,7 @@ class RepairControllerMoreTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> cmd = inv.getArgument(0);
|
||||
writeValidPdfTo(Path.of(cmd.getLast()));
|
||||
writeValidPdfTo(cmd, cmd.size() - 1);
|
||||
return okResult;
|
||||
});
|
||||
|
||||
@@ -252,7 +256,7 @@ class RepairControllerMoreTest {
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
List<String> cmd = inv.getArgument(0);
|
||||
writeValidPdfTo(Path.of(cmd.getLast()));
|
||||
writeValidPdfTo(cmd, cmd.size() - 1);
|
||||
return okResult;
|
||||
});
|
||||
|
||||
|
||||
+9
-1
@@ -49,7 +49,15 @@ class UploadLimitServiceTest {
|
||||
systemProps = mock(ApplicationProperties.System.class);
|
||||
when(applicationProperties.getSystem()).thenReturn(systemProps);
|
||||
|
||||
uploadLimitService = new UploadLimitService(applicationProperties);
|
||||
uploadLimitService = new UploadLimitService();
|
||||
// inject mock
|
||||
try {
|
||||
var field = UploadLimitService.class.getDeclaredField("applicationProperties");
|
||||
field.setAccessible(true);
|
||||
field.set(uploadLimitService, applicationProperties);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "getReadableUploadLimit case #{index}: rawValue={0}, expected={1}")
|
||||
|
||||
-9
@@ -4,9 +4,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import stirling.software.proprietary.access.service.DefaultPrincipalResolver;
|
||||
import stirling.software.proprietary.access.service.DefaultTeamLeadLookup;
|
||||
import stirling.software.proprietary.access.service.PrincipalResolver;
|
||||
import stirling.software.proprietary.access.service.TeamLeadLookup;
|
||||
|
||||
/** Access-layer bean wiring. */
|
||||
@@ -19,11 +17,4 @@ public class AccessConfig {
|
||||
TeamLeadLookup defaultTeamLeadLookup() {
|
||||
return new DefaultTeamLeadLookup();
|
||||
}
|
||||
|
||||
/** USER/TEAM projection unless another bean is defined (e.g. the saas resolver). */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(PrincipalResolver.class)
|
||||
PrincipalResolver defaultPrincipalResolver() {
|
||||
return new DefaultPrincipalResolver();
|
||||
}
|
||||
}
|
||||
|
||||
+4
-33
@@ -25,9 +25,7 @@ import stirling.software.proprietary.access.model.PrincipalType;
|
||||
import stirling.software.proprietary.access.model.ResourceGrant;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.access.service.ResourceAccessService;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
/** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */
|
||||
@RestController
|
||||
@@ -38,8 +36,6 @@ import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
public class ResourceGrantController {
|
||||
|
||||
private final ResourceAccessService accessService;
|
||||
private final UserRepository userRepository;
|
||||
private final TeamRepository teamRepository;
|
||||
|
||||
@GetMapping("/grants")
|
||||
public ResponseEntity<?> list(
|
||||
@@ -49,14 +45,6 @@ public class ResourceGrantController {
|
||||
return ResponseEntity.ok(grants.stream().map(this::toDto).toList());
|
||||
}
|
||||
|
||||
@GetMapping("/grants/by-principal")
|
||||
public ResponseEntity<?> listByPrincipal(
|
||||
@RequestParam PrincipalType principalType, @RequestParam Long principalId) {
|
||||
List<ResourceGrant> grants =
|
||||
accessService.listGrantsForPrincipal(principalType, principalId);
|
||||
return ResponseEntity.ok(grants.stream().map(this::toDto).toList());
|
||||
}
|
||||
|
||||
@PostMapping("/grants")
|
||||
public ResponseEntity<?> create(
|
||||
@RequestBody GrantRequest request, @AuthenticationPrincipal User admin) {
|
||||
@@ -69,26 +57,17 @@ public class ResourceGrantController {
|
||||
"error",
|
||||
"resourceType, principalType and principalId are required"));
|
||||
}
|
||||
// PORTAL is a singleton (empty resourceId); every other type must name a resource.
|
||||
boolean portal = request.resourceType() == ResourceType.PORTAL;
|
||||
if (!portal && (request.resourceId() == null || request.resourceId().isBlank())) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "resourceId is required for " + request.resourceType()));
|
||||
}
|
||||
Long principalId = request.principalId();
|
||||
String principalError = validatePrincipalExists(request.principalType(), principalId);
|
||||
if (principalError != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", principalError));
|
||||
}
|
||||
AccessPermission permission =
|
||||
request.permission() == null ? AccessPermission.USE : request.permission();
|
||||
String resourceId = portal ? "" : request.resourceId();
|
||||
// PORTAL is a singleton resource; its grants always target the whole type.
|
||||
String resourceId =
|
||||
request.resourceType() == ResourceType.PORTAL ? "" : request.resourceId();
|
||||
ResourceGrant grant =
|
||||
accessService.grant(
|
||||
request.resourceType(),
|
||||
resourceId,
|
||||
request.principalType(),
|
||||
principalId,
|
||||
request.principalId(),
|
||||
permission,
|
||||
admin);
|
||||
return ResponseEntity.ok(toDto(grant));
|
||||
@@ -100,14 +79,6 @@ public class ResourceGrantController {
|
||||
return ResponseEntity.ok(Map.of("message", "Grant revoked"));
|
||||
}
|
||||
|
||||
// Rejects grants to nonexistent principals (dead rows otherwise).
|
||||
private String validatePrincipalExists(PrincipalType type, Long id) {
|
||||
return switch (type) {
|
||||
case USER -> userRepository.existsById(id) ? null : "User " + id + " does not exist";
|
||||
case TEAM -> teamRepository.existsById(id) ? null : "Team " + id + " does not exist";
|
||||
};
|
||||
}
|
||||
|
||||
private Map<String, Object> toDto(ResourceGrant g) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", g.getId());
|
||||
|
||||
-11
@@ -54,15 +54,4 @@ public abstract class OwnedResource {
|
||||
public Long getOwnerTeamId() {
|
||||
return ownerTeam != null ? ownerTeam.getId() : null;
|
||||
}
|
||||
|
||||
/** Owner as a principal ref; null when server-owned (admin-only ownership). */
|
||||
public PrincipalRef getOwnerRef() {
|
||||
if (getOwnerUserId() != null) {
|
||||
return PrincipalRef.user(getOwnerUserId());
|
||||
}
|
||||
if (getOwnerTeamId() != null) {
|
||||
return PrincipalRef.team(getOwnerTeamId());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** A (type, id) principal pair; the atom grants and ownership are expressed in. */
|
||||
public record PrincipalRef(PrincipalType type, Long id) {
|
||||
|
||||
public static PrincipalRef user(Long id) {
|
||||
return new PrincipalRef(PrincipalType.USER, id);
|
||||
}
|
||||
|
||||
public static PrincipalRef team(Long id) {
|
||||
return new PrincipalRef(PrincipalType.TEAM, id);
|
||||
}
|
||||
|
||||
/** Canonical engine wire form, e.g. "user:12". */
|
||||
public String token() {
|
||||
return type.name().toLowerCase(Locale.ROOT) + ":" + id;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Who a {@link ResourceGrant} is granted to. */
|
||||
/** Who a {@link ResourceGrant} is granted to. Org-wide access is expressed via default policy. */
|
||||
public enum PrincipalType {
|
||||
USER,
|
||||
TEAM
|
||||
|
||||
+1
-2
@@ -2,8 +2,7 @@ package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Types of resources whose access can be gated by {@link ResourceGrant}. */
|
||||
public enum ResourceType {
|
||||
// The admin portal / processor (frontend/editor/src/portal). Singleton resource (empty
|
||||
// resourceId).
|
||||
// The admin portal / processor (frontend/portal). Singleton resource (empty resourceId).
|
||||
PORTAL,
|
||||
// A stored S3/MCP/API integration configuration.
|
||||
INTEGRATION_CONFIG
|
||||
|
||||
-16
@@ -3,15 +3,11 @@ package stirling.software.proprietary.access.repository;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.access.model.PrincipalType;
|
||||
import stirling.software.proprietary.access.model.ResourceGrant;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Repository
|
||||
public interface ResourceGrantRepository extends JpaRepository<ResourceGrant, Long> {
|
||||
@@ -22,20 +18,8 @@ public interface ResourceGrantRepository extends JpaRepository<ResourceGrant, Lo
|
||||
List<ResourceGrant> findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
ResourceType resourceType, PrincipalType principalType, Long principalId);
|
||||
|
||||
/** All grants held by a principal, across resource types (for the manage-access view). */
|
||||
List<ResourceGrant> findByPrincipalTypeAndPrincipalId(
|
||||
PrincipalType principalType, Long principalId);
|
||||
|
||||
void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId);
|
||||
|
||||
/** Removes every grant held by a principal; used when the user/team behind it is deleted. */
|
||||
void deleteByPrincipalTypeAndPrincipalId(PrincipalType principalType, Long principalId);
|
||||
|
||||
// Detach issued grants so deleting the granting user does not hit the FK.
|
||||
@Modifying
|
||||
@Query("update ResourceGrant g set g.grantedBy = null where g.grantedBy = :user")
|
||||
void clearGrantedBy(@Param("user") User user);
|
||||
|
||||
boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId(
|
||||
ResourceType resourceType,
|
||||
String resourceId,
|
||||
|
||||
+1
-6
@@ -11,12 +11,7 @@ import stirling.software.proprietary.access.service.ResourceAccessService;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. Convention:
|
||||
* every portal-exclusive endpoint is gated with
|
||||
* {@code @PreAuthorize("@resourceAccess.canUsePortal()")}; endpoints shared with the editor (e.g.
|
||||
* the policies API) must NOT be.
|
||||
*/
|
||||
/** {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. */
|
||||
@Component("resourceAccess")
|
||||
@RequiredArgsConstructor
|
||||
public class ResourceAccessSecurity {
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import stirling.software.proprietary.access.model.PrincipalRef;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/**
|
||||
* Self-hosted projection: the user and their team. One deployment = one org, so ORG_ALL is open.
|
||||
*/
|
||||
public class DefaultPrincipalResolver implements PrincipalResolver {
|
||||
|
||||
@Override
|
||||
public Set<PrincipalRef> principalsOf(User user) {
|
||||
if (user == null) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<PrincipalRef> principals = new HashSet<>();
|
||||
principals.add(PrincipalRef.user(user.getId()));
|
||||
if (user.getTeam() != null) {
|
||||
principals.add(PrincipalRef.team(user.getTeam().getId()));
|
||||
}
|
||||
return principals;
|
||||
}
|
||||
|
||||
// Self-hosted is a single deployment-wide org, so ORG_ALL admits every authenticated user.
|
||||
@Override
|
||||
public boolean allowsDeploymentWideAccess() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
|
||||
/** Real lookup backed by team_memberships LEADER rows; wins over the no-op default bean. */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MembershipTeamLeadLookup implements TeamLeadLookup {
|
||||
|
||||
private final TeamMembershipRepository memberships;
|
||||
|
||||
@Override
|
||||
public boolean isAnyTeamLeader(User user) {
|
||||
return user != null
|
||||
&& user.getId() != null
|
||||
&& memberships.existsByUserIdAndRole(user.getId(), TeamRole.LEADER);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLeaderOfTeam(User user, Long teamId) {
|
||||
return user != null
|
||||
&& user.getId() != null
|
||||
&& teamId != null
|
||||
&& memberships.existsByTeamIdAndUserIdAndRole(
|
||||
teamId, user.getId(), TeamRole.LEADER);
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -36,19 +36,15 @@ public class OwnershipService {
|
||||
return accessService.canUseResource(
|
||||
type,
|
||||
String.valueOf(resource.getId()),
|
||||
resource.getOwnerRef(),
|
||||
resource.getOwnerUserId(),
|
||||
resource.getDefaultAccess(),
|
||||
user);
|
||||
}
|
||||
|
||||
/** Whether the user may manage the resource. */
|
||||
public boolean canManage(ResourceType type, OwnedResource resource, User user) {
|
||||
// Disabled resources bypass grants for MANAGE too: admin/owner only.
|
||||
if (!resource.isEnabled()) {
|
||||
return isAdmin(user) || isOwner(resource, user);
|
||||
}
|
||||
return accessService.canManageResource(
|
||||
type, String.valueOf(resource.getId()), resource.getOwnerRef(), user);
|
||||
type, String.valueOf(resource.getId()), resource.getOwnerUserId(), user);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import stirling.software.proprietary.access.model.PrincipalRef;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Projects a user onto the set of principals they act as. */
|
||||
public interface PrincipalResolver {
|
||||
|
||||
/** Every principal the user acts as; empty for a null user. */
|
||||
Set<PrincipalRef> principalsOf(User user);
|
||||
|
||||
/**
|
||||
* Whether this deployment treats every authenticated user as one org, so the {@code ORG_ALL}
|
||||
* default policy admits anyone. Self-hosted: true. Multi-tenant saas: false, so an {@code
|
||||
* ORG_ALL} resource can't leak across tenants. Defaults to false (deny) for safety.
|
||||
*/
|
||||
default boolean allowsDeploymentWideAccess() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Canonical wire tokens for the engine, e.g. "user:12". */
|
||||
default Set<String> principalTokens(User user) {
|
||||
return principalsOf(user).stream().map(PrincipalRef::token).collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
+27
-44
@@ -14,7 +14,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.access.model.AccessPermission;
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.PrincipalRef;
|
||||
import stirling.software.proprietary.access.model.PrincipalType;
|
||||
import stirling.software.proprietary.access.model.ResourceGrant;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
@@ -30,7 +29,6 @@ public class ResourceAccessService {
|
||||
|
||||
private final ResourceGrantRepository grantRepository;
|
||||
private final TeamLeadLookup teamLeadLookup;
|
||||
private final PrincipalResolver principalResolver;
|
||||
|
||||
@Value("${security.portal.defaultAccess:ADMINS_AND_TEAM_LEADS}")
|
||||
private DefaultAccessPolicy portalDefaultPolicy;
|
||||
@@ -46,28 +44,28 @@ public class ResourceAccessService {
|
||||
public boolean canUseResource(
|
||||
ResourceType type,
|
||||
String resourceId,
|
||||
PrincipalRef owner,
|
||||
Long ownerUserId,
|
||||
DefaultAccessPolicy defaultPolicy,
|
||||
User user) {
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
if (isOwner(owner, user) || isAdmin(user)) {
|
||||
if (isOwner(ownerUserId, user) || isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
if (hasGrant(type, normalize(resourceId), user, AccessPermission.USE)) {
|
||||
return true;
|
||||
}
|
||||
return matchesDefault(defaultPolicy, owner, user);
|
||||
return matchesDefault(defaultPolicy, user);
|
||||
}
|
||||
|
||||
/** Whether the user may manage (edit/delete/share) a resource. No default-policy fallback. */
|
||||
public boolean canManageResource(
|
||||
ResourceType type, String resourceId, PrincipalRef owner, User user) {
|
||||
ResourceType type, String resourceId, Long ownerUserId, User user) {
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
if (isOwner(owner, user) || isAdmin(user)) {
|
||||
if (isOwner(ownerUserId, user) || isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
return hasGrant(type, normalize(resourceId), user, AccessPermission.MANAGE);
|
||||
@@ -112,22 +110,21 @@ public class ResourceAccessService {
|
||||
return grantRepository.findByResourceTypeAndResourceId(type, normalize(resourceId));
|
||||
}
|
||||
|
||||
/** Every grant a principal holds, for the per-user/per-team manage-access view. */
|
||||
public List<ResourceGrant> listGrantsForPrincipal(
|
||||
PrincipalType principalType, Long principalId) {
|
||||
return grantRepository.findByPrincipalTypeAndPrincipalId(principalType, principalId);
|
||||
}
|
||||
|
||||
/** Resource ids of the given type that any of the user's principals holds a grant on. */
|
||||
/** Resource ids of the given type that this user (or their team) holds any grant on. */
|
||||
public Set<String> grantedResourceIds(ResourceType type, User user) {
|
||||
if (user == null) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (PrincipalRef principal : principalResolver.principalsOf(user)) {
|
||||
for (ResourceGrant g :
|
||||
grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
type, PrincipalType.USER, user.getId())) {
|
||||
ids.add(g.getResourceId());
|
||||
}
|
||||
if (user.getTeam() != null) {
|
||||
for (ResourceGrant g :
|
||||
grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
type, principal.type(), principal.id())) {
|
||||
type, PrincipalType.TEAM, user.getTeam().getId())) {
|
||||
ids.add(g.getResourceId());
|
||||
}
|
||||
}
|
||||
@@ -138,12 +135,18 @@ public class ResourceAccessService {
|
||||
|
||||
private boolean hasGrant(
|
||||
ResourceType type, String resourceId, User user, AccessPermission required) {
|
||||
Set<PrincipalRef> principals = principalResolver.principalsOf(user);
|
||||
Long teamId = user.getTeam() != null ? user.getTeam().getId() : null;
|
||||
for (ResourceGrant g : grantRepository.findByResourceTypeAndResourceId(type, resourceId)) {
|
||||
if (!permissionSatisfies(g.getPermission(), required)) {
|
||||
continue;
|
||||
}
|
||||
if (principals.contains(new PrincipalRef(g.getPrincipalType(), g.getPrincipalId()))) {
|
||||
if (g.getPrincipalType() == PrincipalType.USER
|
||||
&& g.getPrincipalId().equals(user.getId())) {
|
||||
return true;
|
||||
}
|
||||
if (g.getPrincipalType() == PrincipalType.TEAM
|
||||
&& teamId != null
|
||||
&& g.getPrincipalId().equals(teamId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -158,40 +161,20 @@ public class ResourceAccessService {
|
||||
return held == AccessPermission.MANAGE;
|
||||
}
|
||||
|
||||
private boolean matchesDefault(DefaultAccessPolicy policy, PrincipalRef owner, User user) {
|
||||
private boolean matchesDefault(DefaultAccessPolicy policy, User user) {
|
||||
if (policy == null) {
|
||||
return false;
|
||||
}
|
||||
return switch (policy) {
|
||||
// Deployment-wide only where the resolver treats everyone as one org; saas resolvers
|
||||
// return false, so ORG_ALL cannot leak a tenant's resource to another tenant's users.
|
||||
case ORG_ALL -> principalResolver.allowsDeploymentWideAccess();
|
||||
// Admins already pass above; only team leads here, scoped to the owning team.
|
||||
case ADMINS_AND_TEAM_LEADS -> matchesTeamLeadDefault(owner, user);
|
||||
case ORG_ALL -> true;
|
||||
// Admins already pass above; only team leads here.
|
||||
case ADMINS_AND_TEAM_LEADS -> teamLeadLookup.isAnyTeamLeader(user);
|
||||
case EXPLICIT_ONLY -> false;
|
||||
};
|
||||
}
|
||||
|
||||
// Portal (no owner) admits any team lead; a team-owned resource admits only that team's
|
||||
// leads; a user-owned resource admits no extra leads.
|
||||
private boolean matchesTeamLeadDefault(PrincipalRef owner, User user) {
|
||||
if (owner == null) {
|
||||
return teamLeadLookup.isAnyTeamLeader(user);
|
||||
}
|
||||
return owner.type() == PrincipalType.TEAM
|
||||
&& owner.id() != null
|
||||
&& teamLeadLookup.isLeaderOfTeam(user, owner.id());
|
||||
}
|
||||
|
||||
// Team owners are the owning team's leaders; plain members are not.
|
||||
private boolean isOwner(PrincipalRef owner, User user) {
|
||||
if (owner == null || owner.id() == null) {
|
||||
return false;
|
||||
}
|
||||
return switch (owner.type()) {
|
||||
case USER -> owner.id().equals(user.getId());
|
||||
case TEAM -> teamLeadLookup.isLeaderOfTeam(user, owner.id());
|
||||
};
|
||||
private boolean isOwner(Long ownerUserId, User user) {
|
||||
return ownerUserId != null && ownerUserId.equals(user.getId());
|
||||
}
|
||||
|
||||
private boolean isAdmin(User user) {
|
||||
|
||||
+5
-22
@@ -18,26 +18,15 @@ public class SecretMasker {
|
||||
// Cap recursion so a pathologically nested payload cannot overflow the stack.
|
||||
private static final int MAX_DEPTH = 32;
|
||||
|
||||
// Key-name substrings that mark a value sensitive. Over-masking a non-secret is
|
||||
// safe; leaking a secret is not, so this errs broad - but a per-type schema
|
||||
// whitelist would be a stronger boundary for free-form config (follow-up).
|
||||
private static final Set<String> SENSITIVE_HINTS =
|
||||
Set.of(
|
||||
"secret",
|
||||
"password",
|
||||
"passphrase",
|
||||
"pwd",
|
||||
"token",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"credential",
|
||||
"privatekey",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"session",
|
||||
"connectionstring",
|
||||
"bearer",
|
||||
"signature");
|
||||
"privatekey");
|
||||
|
||||
/** Replace sensitive values with the mask (recursively) for safe display. */
|
||||
public Map<String, Object> mask(Map<String, Object> config) {
|
||||
@@ -84,24 +73,18 @@ public class SecretMasker {
|
||||
|
||||
private Map<String, Object> merge(
|
||||
Map<String, Object> stored, Map<String, Object> incoming, int depth) {
|
||||
// Replace semantics (PUT): the result is the incoming document, except a redacted secret
|
||||
// keeps its stored value. Keys absent from incoming are dropped, so edits can remove them.
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
Map<String, Object> out = new LinkedHashMap<>(stored);
|
||||
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
||||
String key = e.getKey();
|
||||
Object value = e.getValue();
|
||||
if (isSensitive(key)) {
|
||||
if (isRedacted(value, depth)) {
|
||||
if (stored.containsKey(key)) {
|
||||
out.put(key, stored.get(key)); // keep the stored secret
|
||||
}
|
||||
} else {
|
||||
if (!isRedacted(value, depth)) {
|
||||
out.put(key, value); // a real new secret replaces the stored one
|
||||
}
|
||||
continue;
|
||||
continue; // redacted (blank / mask) -> keep stored
|
||||
}
|
||||
if (depth < MAX_DEPTH
|
||||
&& stored.get(key) instanceof Map<?, ?> s
|
||||
&& out.get(key) instanceof Map<?, ?> s
|
||||
&& value instanceof Map<?, ?> i) {
|
||||
out.put(key, merge(castMap(s), castMap(i), depth + 1));
|
||||
} else {
|
||||
|
||||
+15
-113
@@ -6,7 +6,6 @@ import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@@ -15,31 +14,25 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
|
||||
* A").
|
||||
*
|
||||
* <p>Calls:
|
||||
* <p>Two calls:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
|
||||
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
|
||||
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
|
||||
* GET /api/v1/instance/entitlement}; what the local gate consults.
|
||||
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
|
||||
* cumulative units and returns the refreshed entitlement.
|
||||
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
|
||||
* /api/v1/instance/revoke-self}).
|
||||
* </ul>
|
||||
*
|
||||
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
|
||||
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
|
||||
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see
|
||||
* {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS
|
||||
* endpoint.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -93,9 +86,11 @@ public class AccountLinkClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
|
||||
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
|
||||
* this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
|
||||
* Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked
|
||||
* or invalid. Distinct from a transport/server failure (which returns {@code null} and fails
|
||||
* open): the cache must BLOCK billable work on this rather than serve a stale entitled
|
||||
* snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport
|
||||
* try/catch.
|
||||
*/
|
||||
public static final class RevokedException extends RuntimeException {
|
||||
private final int status;
|
||||
@@ -147,9 +142,11 @@ public class AccountLinkClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
|
||||
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
|
||||
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
|
||||
* Revokes this instance's own credential on the SaaS side ({@code POST
|
||||
* /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is
|
||||
* allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable
|
||||
* or rejects the call, so the caller (local unlink) can still clear locally and log the orphan
|
||||
* row for follow-up. Idempotent on SaaS (already-revoked → still 204).
|
||||
*/
|
||||
public boolean revokeSelf(String deviceId, String deviceSecret) {
|
||||
try {
|
||||
@@ -221,63 +218,6 @@ public class AccountLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
|
||||
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
|
||||
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
|
||||
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
|
||||
* not advance its last-synced markers so the usage retries next sync.
|
||||
*/
|
||||
public InstanceEntitlement reportUsage(
|
||||
String deviceId,
|
||||
String deviceSecret,
|
||||
long syncSeq,
|
||||
LocalDateTime periodStart,
|
||||
long apiUnits,
|
||||
long aiUnits,
|
||||
long automationUnits) {
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
ObjectNode root = mapper.createObjectNode();
|
||||
root.put("syncSeq", syncSeq);
|
||||
// Explicit ISO-8601 string so it round-trips regardless of the mapper's time config.
|
||||
root.put("periodStart", periodStart.toString());
|
||||
ObjectNode units = root.putObject("cumulativeUnits");
|
||||
units.put("api", apiUnits);
|
||||
units.put("ai", aiUnits);
|
||||
units.put("automation", automationUnits);
|
||||
String body = mapper.writeValueAsString(root);
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri("/api/v1/instance/sync"))
|
||||
.header(HEADER_DEVICE_ID, deviceId)
|
||||
.header(HEADER_DEVICE_SECRET, deviceSecret)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout())
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
response = send(request);
|
||||
} catch (Exception e) {
|
||||
log.debug("Usage sync failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
int status = response.statusCode();
|
||||
if (status == 401 || status == 403) {
|
||||
throw new RevokedException(status);
|
||||
}
|
||||
if (status / 100 != 2) {
|
||||
log.debug("Usage sync returned HTTP {}", status);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseEntitlement(response.body());
|
||||
} catch (IOException e) {
|
||||
log.debug("Usage sync parse failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private InstanceEntitlement parseEntitlement(String body) throws IOException {
|
||||
JsonNode root = mapper.readTree(body);
|
||||
boolean subscribed = root.path("subscribed").asBoolean(false);
|
||||
@@ -286,45 +226,7 @@ public class AccountLinkClient {
|
||||
Long periodCap =
|
||||
root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null;
|
||||
EntitlementState state = mapState(root.path("state").asText(null));
|
||||
return new InstanceEntitlement(
|
||||
subscribed,
|
||||
freeRemaining,
|
||||
periodSpend,
|
||||
periodCap,
|
||||
state,
|
||||
parseUnitCalcPolicy(root),
|
||||
parseDateTime(root, "periodStart"),
|
||||
parseDateTime(root, "periodEnd"));
|
||||
}
|
||||
|
||||
/** Parses the nested unit-calc policy; null if absent or any knob is invalid (e.g. zero). */
|
||||
private static UnitCalcPolicy parseUnitCalcPolicy(JsonNode root) {
|
||||
if (!root.hasNonNull("unitCalcPolicy")) {
|
||||
return null;
|
||||
}
|
||||
JsonNode node = root.get("unitCalcPolicy");
|
||||
try {
|
||||
return new UnitCalcPolicy(
|
||||
node.path("docPagesPerUnit").asInt(),
|
||||
node.path("docBytesPerUnit").asLong(),
|
||||
node.path("minChargeUnits").asInt(),
|
||||
node.path("fileUnitCap").asInt());
|
||||
} catch (RuntimeException e) {
|
||||
// Malformed policy → degrade to "none" rather than fail the whole entitlement parse.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** ISO date-time field → LocalDateTime; null if absent or unparseable. */
|
||||
private static LocalDateTime parseDateTime(JsonNode root, String field) {
|
||||
if (!root.hasNonNull(field)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.parse(root.get(field).asText(null));
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state);
|
||||
}
|
||||
|
||||
/** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */
|
||||
|
||||
+2
-38
@@ -2,7 +2,6 @@ package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -24,9 +23,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
|
||||
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
|
||||
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
|
||||
* the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
|
||||
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
|
||||
* / test aid).
|
||||
* the portal's link card.
|
||||
*
|
||||
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
|
||||
* stirling.billing.account-link.enabled} — off → bean absent → 404.
|
||||
@@ -41,17 +38,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class AccountLinkController {
|
||||
|
||||
private final AccountLinkService service;
|
||||
private final LocalUsageService localUsageService;
|
||||
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
|
||||
private final ObjectProvider<UsageSyncService> syncServiceProvider;
|
||||
|
||||
public AccountLinkController(
|
||||
AccountLinkService service,
|
||||
LocalUsageService localUsageService,
|
||||
ObjectProvider<UsageSyncService> syncServiceProvider) {
|
||||
public AccountLinkController(AccountLinkService service) {
|
||||
this.service = service;
|
||||
this.localUsageService = localUsageService;
|
||||
this.syncServiceProvider = syncServiceProvider;
|
||||
}
|
||||
|
||||
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
|
||||
@@ -96,29 +85,4 @@ public class AccountLinkController {
|
||||
service.unlink();
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend
|
||||
* so "current usage" includes work done since the last daily sync.
|
||||
*/
|
||||
@GetMapping("/usage")
|
||||
public ResponseEntity<LocalUsageService.LocalUsage> usage() {
|
||||
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
|
||||
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
|
||||
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
|
||||
* {@code 409} when metering is off (the sync bean is absent).
|
||||
*/
|
||||
@PostMapping("/sync-now")
|
||||
public ResponseEntity<Void> syncNow() {
|
||||
UsageSyncService sync = syncServiceProvider.getIfAvailable();
|
||||
if (sync == null) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
sync.syncNow();
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
-37
@@ -1,7 +1,5 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -38,39 +36,4 @@ public class AccountLinkProperties {
|
||||
|
||||
/** Connect/read timeout for the outbound SaaS calls. */
|
||||
private int requestTimeoutSeconds = 10;
|
||||
|
||||
/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
|
||||
private final Metering metering = new Metering();
|
||||
|
||||
/**
|
||||
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
|
||||
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
|
||||
* enforcement. Both default off; metering requires the master flag too. This is the production
|
||||
* safety key — flipping it on is what actually bills linked instances.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Metering {
|
||||
|
||||
/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* How often the instance syncs usage + refreshes entitlement (matches the licence sync).
|
||||
*/
|
||||
private int syncIntervalHours = 24;
|
||||
|
||||
/**
|
||||
* Block billable work after this many days with no successful sync (fail-open → closed).
|
||||
*/
|
||||
private int graceDays = 3;
|
||||
|
||||
/**
|
||||
* Dedup window for identical input sets. A re-run of the same inputs within this window is
|
||||
* treated as workflow chaining and not re-charged; the same inputs run again after it are
|
||||
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
|
||||
* costs the same on the instance and in the cloud.
|
||||
*/
|
||||
private Duration workflowWindow = Duration.ofMinutes(5);
|
||||
}
|
||||
}
|
||||
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
|
||||
*
|
||||
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
|
||||
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
|
||||
* so a never-decreasing seq is the contract. {@link #lastSuccessAt} is the wall-clock of the last
|
||||
* sync SaaS accepted and drives the fail-open→closed grace window.
|
||||
*
|
||||
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated sync.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "account_link_sync_state")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class AccountLinkSyncState {
|
||||
|
||||
/** One instance links to one team → one bookkeeping row. */
|
||||
public static final long SINGLETON_ID = 1L;
|
||||
|
||||
@Id private Long id;
|
||||
|
||||
// columnDefinition default keeps the ddl-auto ADD COLUMN safe on a populated external Postgres.
|
||||
@Column(
|
||||
name = "last_sync_seq",
|
||||
nullable = false,
|
||||
columnDefinition = "bigint not null default 0")
|
||||
private long lastSyncSeq;
|
||||
|
||||
/** Null until the first sync SaaS accepts. */
|
||||
@Column(name = "last_success_at")
|
||||
private LocalDateTime lastSuccessAt;
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
|
||||
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
|
||||
+10
-28
@@ -3,26 +3,14 @@ package stirling.software.proprietary.accountlink;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
import stirling.software.proprietary.billing.BillingCategoryClassifier;
|
||||
|
||||
/**
|
||||
* Buckets a request into a {@link BillingCategory} for the account-link gate + meter, using only
|
||||
* HTTP-level signals (no dependency on the saas module):
|
||||
* Classifies a request as <b>billable</b> (AI / automation) or free (a manual tool).
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>AUTOMATION</b> — the automation marker header ({@link
|
||||
* InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy sub-steps);
|
||||
* <li><b>AI</b> — the AI surface ({@code /api/v1/ai/**});
|
||||
* <li><b>API</b> — an API-key authenticated tool call;
|
||||
* <li><b>BYPASSED</b> — a manual interactive tool call, never billed.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Same precedence as the SaaS classifier (AUTOMATION → AI → API → BYPASSED) via the shared
|
||||
* {@link BillingCategoryClassifier}; the AI signal is resolved by path prefix rather than the
|
||||
* saas-only {@code @RequiresFeature} annotation. The {@code apiKey} signal is supplied by the
|
||||
* caller (resolved from the security context), so this class stays free of any security-type
|
||||
* dependency.
|
||||
* <p>Mirrors the saas billing categorisation at a coarse level, without depending on the saas
|
||||
* module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation
|
||||
* marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy
|
||||
* sub-steps). Everything else — interactive manual PDF tools — is always free.
|
||||
*/
|
||||
public final class BillableOperationClassifier {
|
||||
|
||||
@@ -30,22 +18,16 @@ public final class BillableOperationClassifier {
|
||||
|
||||
private BillableOperationClassifier() {}
|
||||
|
||||
/**
|
||||
* @param apiKey whether the request authenticated via an API key (an {@code
|
||||
* ApiKeyAuthenticationToken} principal), resolved by the caller from the security context.
|
||||
*/
|
||||
public static BillingCategory categorize(HttpServletRequest request, boolean apiKey) {
|
||||
boolean automation = request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null;
|
||||
return BillingCategoryClassifier.classify(automation, isAiSurface(request), apiKey);
|
||||
}
|
||||
|
||||
private static boolean isAiSurface(HttpServletRequest request) {
|
||||
public static boolean isBillable(HttpServletRequest request) {
|
||||
if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) {
|
||||
return true;
|
||||
}
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null) {
|
||||
return false;
|
||||
}
|
||||
// Prefix-match the AI surface (not a loose substring contains), stripping a deployment
|
||||
// context path so /<ctx>/api/v1/ai/** still classifies as AI.
|
||||
// context path so /<ctx>/api/v1/ai/** still classifies as billable.
|
||||
String ctx = request.getContextPath();
|
||||
String path =
|
||||
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user