diff --git a/.editorconfig b/.editorconfig index 665a74a09a..e6bda814c1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -22,26 +22,15 @@ indent_size = 4 [*.html] indent_size = 2 -insert_final_newline = false -trim_trailing_whitespace = false -[{*.js,*.jsx,*.mjs,*.ts,*.tsx}] +[{*.js,*.jsx,*.mjs,*.ts,*.tsx,*.mts}] indent_size = 2 [*.css] -# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards. indent_size = 2 [*.{yml,yaml}] -# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices. -indent_size = 2 -insert_final_newline = false -trim_trailing_whitespace = false - -[*.json] -# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting. indent_size = 2 -[*.jsonc] -# JSONC (JSON with comments) files also follow the standard JSON formatting with an indent size of 2 spaces. +[*.{json,jsonc}] indent_size = 2 diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index f5325c9ed7..844306824d 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -375,13 +375,13 @@ tasks: desc: "Auto-fix code formatting" deps: [install] cmds: - - npx prettier --write . + - npx oxfmt --write . format:check: desc: "Check code formatting" deps: [install] cmds: - - npx prettier --check . + - npx oxfmt --check . fix: desc: "Auto-fix lint and format" @@ -554,6 +554,7 @@ tasks: deps: [install, ":backend:swagger"] cmds: - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts + - task: format sources: - editor/scripts/generate-tool-api-types.mts - ../SwaggerDoc.json @@ -563,9 +564,9 @@ tasks: 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 --io-output editor/src/core/types/toolIO.ts --check + - task: tool-models + - git diff --exit-code -- editor/src/core/types/toolApiTypes.ts editor/src/core/types/toolIO.ts licenses:generate: desc: "Generate frontend license report" diff --git a/app/common/build.gradle b/app/common/build.gradle index bb956504cc..8af68bcb76 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -18,7 +18,7 @@ dependencies { api "org.apache.pdfbox:preflight:$pdfboxVersion" api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files api 'jakarta.servlet:jakarta.servlet-api:6.1.0' - api 'org.snakeyaml:snakeyaml-engine:3.0.1' + api 'org.snakeyaml:snakeyaml-engine:3.1.1' api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" // Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage) api 'org.simplejavamail:simple-java-mail:9.3.2' diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java index 798de71df9..dc422433f0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java @@ -9,6 +9,7 @@ import java.util.Map; import java.util.Set; import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; @@ -20,12 +21,13 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import io.github.pixee.security.Filenames; -import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.PdfMetadataService; import stirling.software.common.service.UserServiceInterface; @@ -48,11 +50,13 @@ import tools.jackson.databind.node.ObjectNode; *

Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI * engine to classify the document against the built-in label set, and stores the engine's JSON * answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key - * {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct - * client use. + * {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. + * + *

Published in the API spec rather than hidden, so the tool-model generator emits it and a + * pipeline can name it as a step like any other tool. Classification is a thing a pipeline does, + * not a thing only the Classification policy may do. */ @Slf4j -@Hidden @RestController @RequestMapping("/api/v1/ai/tools") @Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.") @@ -99,19 +103,31 @@ public class ClassifyLabelController { } @PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + // PDF in, the same PDF out with a verdict on it, so a chain can be checked across this step. + @ToolIO(accepts = ToolFormat.PDF, produces = ToolFormat.PDF) @Operation( summary = "Classify a PDF and label its metadata", description = "Reads the first two and last two pages, classifies the document via the AI" + " engine, and stores the result in the StirlingPDFClassification" - + " metadata field. Dispatched by the Classification policy; not" - + " intended for direct client use.") + + " metadata field. A document that already carries a verdict is" + + " passed through untouched unless reclassify=true.") public ResponseEntity classifyAndLabel( - @RequestParam("fileInput") MultipartFile fileInput) throws IOException { + @RequestParam("fileInput") MultipartFile fileInput, + @RequestParam(value = "reclassify", defaultValue = "false") boolean reclassify) + throws IOException { aiFeatureGate.requireClassify(); try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) { String fileName = safeFileName(fileInput.getOriginalFilename()); + if (!reclassify && isClassified(document)) { + // Classifying twice costs a second engine call and charges for it, and a document + // that already carries a verdict has nothing new to learn. A pipeline can run this + // step over a mixed batch without paying for the ones already done. + log.debug("[classify-and-label] {} already classified; passing through", fileName); + return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager); + } + List allowed = resolveAllowedLabels(); if (allowed.isEmpty()) { // No vocabulary to classify against: pass the file through unlabelled rather than @@ -135,6 +151,25 @@ public class ClassifyLabelController { } } + /** + * Whether a verdict is already on the document. + * + *

This only reads back what a previous run of this step wrote. It is not a statement that + * the verdict is trustworthy: the key is ordinary PDF metadata that whoever supplied the file + * can set. Skipping the engine on the strength of it is safe because the cost of being wrong is + * a missing re-classification, not a wrong decision. Anything that makes a SECURITY decision + * from this field - routing a document somewhere on the strength of its label, say - must + * classify with {@code reclassify=true} rather than trust what arrived. + */ + private static boolean isClassified(PDDocument document) { + PDDocumentInformation info = document.getDocumentInformation(); + if (info == null) { + return false; + } + String existing = info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY); + return existing != null && !existing.isBlank(); + } + private List extractWindow(PDDocument document) throws IOException { List pages = new ArrayList<>(); for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index a3d43b712d..14b1eb325c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -85,6 +85,11 @@ public record Policy( return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId); } + /** A copy under a different owner (e.g. moving a seed off a placeholder name). */ + public Policy withOwner(String newOwner) { + return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId); + } + /** A copy referencing the given saved output destinations. */ public Policy withOutputIds(List newOutputIds) { return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java index 63ebae833a..9b347366bc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java @@ -22,8 +22,8 @@ import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.TeamService; /** - * Seeds an enabled Classification policy per team so classification is on by default. Idempotent; - * skips the internal team. + * Seeds an enabled Classification policy per team; idempotent, skips the internal team. Left + * unowned: nobody created it, and an owner here would have to name a real user. */ @Slf4j @Component @@ -34,6 +34,11 @@ public class DefaultClassificationPolicySeeder { private static final String CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label"; private static final String POLICY_NAME = "Classification Policy"; + /** + * Pre-existing seeds used this placeholder, which was never a user; see {@link #repairOwner}. + */ + private static final String LEGACY_OWNER = "system"; + private final PolicyStore policyStore; private final TeamRepository teamRepository; @@ -46,9 +51,8 @@ public class DefaultClassificationPolicySeeder { .ifPresent(team -> seedIfMissing(team.getId(), team.getName())); } - // Any team created at runtime (admin-created, SaaS sign-ups). Seeds inside the team's own - // transaction: rollback still leaves no policy behind, and the store's pessimistic lock needs a - // live transaction, which AFTER_COMMIT cannot offer. + // Seeds inside the new team's own transaction: rollback leaves no policy behind, and the + // store's pessimistic lock needs a live transaction, which AFTER_COMMIT cannot offer. @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void onTeamCreated(TeamCreatedEvent event) { seedIfMissing(event.teamId(), event.teamName()); @@ -58,16 +62,33 @@ public class DefaultClassificationPolicySeeder { if (teamId == null || TeamService.INTERNAL_TEAM_NAME.equals(teamName)) { return; } - boolean alreadySeeded = + Policy existing = policyStore.findByTeam(teamId).stream() - .anyMatch(DefaultClassificationPolicySeeder::isClassification); - if (alreadySeeded) { + .filter(DefaultClassificationPolicySeeder::isClassification) + .findFirst() + .orElse(null); + if (existing != null) { + repairOwner(existing); return; } policyStore.save(defaultPolicy(teamId)); log.info("Seeded default Classification policy for team {}", teamId); } + /** + * Clear an owner seeded as a placeholder name. An owner someone deliberately set is left alone. + */ + private void repairOwner(Policy policy) { + if (!LEGACY_OWNER.equals(policy.owner())) { + return; + } + policyStore.save(policy.withOwner(null)); + log.info( + "Cleared placeholder owner '{}' on Classification policy {}", + LEGACY_OWNER, + policy.id()); + } + private static boolean isClassification(Policy policy) { return policy.output() != null && CATEGORY.equals(policy.output().options().get("categoryId")); @@ -85,7 +106,9 @@ public class DefaultClassificationPolicySeeder { return new Policy( null, POLICY_NAME, - "system", + // Nobody created this - it is seeded. A name here would have to be a real user, and + // every consumer of owner already handles its absence. + null, true, List.of(), List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())), diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java index 74749289a7..637f05b284 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java @@ -14,6 +14,7 @@ import static org.mockito.Mockito.when; import java.util.List; import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -76,7 +77,7 @@ class ClassifyLabelControllerTest { .thenReturn("{\"outcome\":\"classification\",\"labels\":[\"invoice\"]}"); try { - controller.classifyAndLabel(file); + controller.classifyAndLabel(file, false); } catch (Exception ignored) { // WebResponseUtils.pdfDocToWebResponse needs a real temp file; the engine call and // metadata write we assert on have already happened by the time it runs. @@ -89,6 +90,52 @@ class ClassifyLabelControllerTest { return objectMapper.readTree(body.getValue()); } + /** Stubs a document that already carries a verdict, as a second run over a batch would see. */ + private MultipartFile alreadyClassifiedDocument() throws Exception { + PDDocument document = mock(PDDocument.class); + PDDocumentInformation info = mock(PDDocumentInformation.class); + when(document.getDocumentInformation()).thenReturn(info); + when(info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY)) + .thenReturn("{\"labels\":[\"invoice\"]}"); + MultipartFile file = mock(MultipartFile.class); + when(file.getOriginalFilename()).thenReturn("invoice.pdf"); + when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(document); + return file; + } + + @Test + void classifyAndLabel_skipsADocumentThatAlreadyCarriesAVerdict() throws Exception { + withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null))); + MultipartFile file = alreadyClassifiedDocument(); + + try { + controller.classifyAndLabel(file, false); + } catch (Exception ignored) { + // The response needs a real temp file; the decision under test happens before it. + } + + // No second engine call, and no charge for one: re-classifying buys the same answer twice. + verify(aiEngineClient, never()).post(anyString(), anyString(), any()); + verify(pdfMetadataService, never()).setClassificationMetadata(any(), anyString()); + } + + @Test + void classifyAndLabel_reclassifiesWhenAskedTo() throws Exception { + withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null))); + MultipartFile file = alreadyClassifiedDocument(); + when(pdfContentExtractor.extractPageTextRaw(any(), eq(1))).thenReturn("Invoice total"); + when(aiEngineClient.post(eq("/api/v1/documents/classify"), anyString(), isNull())) + .thenReturn("{\"outcome\":\"classification\",\"labels\":[\"receipt\"]}"); + + try { + controller.classifyAndLabel(file, true); + } catch (Exception ignored) { + // As above. + } + + verify(aiEngineClient).post(eq("/api/v1/documents/classify"), anyString(), isNull()); + } + @Test void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception { withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null))); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java index 49159e7d6e..f6e82bd011 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java @@ -36,10 +36,14 @@ class DefaultClassificationPolicySeederTest { } private static Policy classificationPolicy(Long teamId) { + return classificationPolicy(teamId, null); + } + + private static Policy classificationPolicy(Long teamId, String owner) { return new Policy( "p1", "Classification Policy", - "system", + owner, true, List.of(), List.of(), @@ -77,6 +81,29 @@ class DefaultClassificationPolicySeederTest { verify(policyStore, never()).save(any()); } + @Test + void clearsAPlaceholderOwnerSeededBeforeOwnersHadToBeReal() { + when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "system"))); + + seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme")); + + // "system" was never a user row, and a step dispatch authenticates as the owner. Absence + // is handled everywhere; a placeholder name is not. + ArgumentCaptor saved = ArgumentCaptor.forClass(Policy.class); + verify(policyStore).save(saved.capture()); + assertThat(saved.getValue().owner()).isNull(); + assertThat(saved.getValue().id()).isEqualTo("p1"); + } + + @Test + void leavesADeliberatelyChosenOwnerAlone() { + when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "alice"))); + + seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme")); + + verify(policyStore, never()).save(any()); + } + @Test void doesNotSeedForTheInternalTeam() { seeder().onTeamCreated(new TeamCreatedEvent(2L, "Internal")); diff --git a/frontend/.oxfmtrc.json b/frontend/.oxfmtrc.json new file mode 100644 index 0000000000..cb801651b8 --- /dev/null +++ b/frontend/.oxfmtrc.json @@ -0,0 +1,33 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "endOfLine": "lf", + "sortPackageJson": false, + "ignorePatterns": [ + "dist/", + "editor/dist/", + "editor/src-tauri/**/target/", + "editor/src-tauri/gen/", + "node_modules/", + "editor/public/vendor/", + "editor/public/mockServiceWorker.js", + "editor/public/og-metadata.json", + "editor/public/og-metadata.saas.json", + "editor/src/core/data/ogImageMap.json", + "editor/src/portal/generated/docsManifest.json", + "editor/public/pdfjs*/", + "editor/public/js/thirdParty/", + "editor/public/css/cookieconsent.css", + "storybook-static/", + "playwright-report/", + "editor/playwright-report/", + "test-results/", + "editor/test-results/", + "*.min.*", + "*.md", + "*.wxs", + "*.toml", + "editor/src/output.css" + ] +} diff --git a/frontend/.prettierignore b/frontend/.prettierignore deleted file mode 100644 index fa8ab6c502..0000000000 --- a/frontend/.prettierignore +++ /dev/null @@ -1,30 +0,0 @@ -dist/ -editor/dist/ -# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier). -# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each -# have their own Cargo workspace under src-tauri/. -editor/src-tauri/**/target/ -editor/src-tauri/gen/ -node_modules/ -editor/public/vendor/ -# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted. -editor/public/mockServiceWorker.js -# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim. -editor/public/og-metadata.json -editor/public/og-metadata.saas.json -editor/src/core/data/ogImageMap.json -# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim. -editor/src/portal/generated/docsManifest.json -editor/public/pdfjs*/ -editor/public/js/thirdParty/ -editor/public/css/cookieconsent.css -# Build / test artifacts that may exist locally even though they're gitignored -storybook-static/ -playwright-report/ -editor/playwright-report/ -test-results/ -editor/test-results/ -*.min.* -*.md -*.wxs -editor/src/output.css diff --git a/frontend/.prettierrc b/frontend/.prettierrc deleted file mode 100644 index 58bc875631..0000000000 --- a/frontend/.prettierrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "printWidth": 80, - "tabWidth": 2, - "useTabs": false, - "endOfLine": "lf" -} diff --git a/frontend/README.md b/frontend/README.md index ea759538e6..a4945ffae1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -18,7 +18,7 @@ For desktop app development, see the [Tauri](#tauri) section below. `frontend/` is a workspace containing one or more apps. Today it holds the PDF editor under `frontend/editor/`; new apps (the developer portal, etc.) will sit alongside it as siblings. Shared tooling — `package.json`, `node_modules`, -`.storybook/`, oxlint, Prettier — lives at `frontend/` so every app installs +`.storybook/`, oxlint, oxfmt — lives at `frontend/` so every app installs once and lints with the same config. ## Environment Variables diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 25544b3299..2c61890790 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4592,6 +4592,10 @@ desc = "Change document restrictions and permissions" tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights" title = "Change Permissions" +[home.classify] +desc = "Identify what kind of document this is and tag it." +title = "Classify" + [home.compare] desc = "Compares and shows the differences between 2 PDF Documents" tags = "difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta" @@ -10869,6 +10873,7 @@ searchPlaceholder = "Search tools..." [toolPicker.subcategories] advancedFormatting = "Advanced Formatting" +ai = "AI" automation = "Automation" developerTools = "Developer Tools" documentReview = "Document Review" diff --git a/frontend/editor/public/og-metadata.json b/frontend/editor/public/og-metadata.json index f9733bd83f..b1eadb3bce 100644 --- a/frontend/editor/public/og-metadata.json +++ b/frontend/editor/public/og-metadata.json @@ -195,6 +195,11 @@ "title": "Compress - Stirling PDF", "description": "Compress PDFs to reduce their file size." }, + "classify": { + "image": "/og_images/home.png", + "title": "Classify - Stirling PDF", + "description": "Identify what kind of document this is and tag it." + }, "extractPages": { "image": "/og_images/extract-pages.png", "title": "Extract Pages - Stirling PDF", @@ -575,6 +580,7 @@ "/remove-cert-sign": "removeCertSign", "/unlock-p-d-f-forms": "unlockPDFForms", "/compress": "compress", + "/classify": "classify", "/extract-pages": "extractPages", "/reorganize-pages": "reorganizePages", "/extract-images": "extractImages", diff --git a/frontend/editor/public/og-metadata.saas.json b/frontend/editor/public/og-metadata.saas.json index be2e7a3e92..14e30f827c 100644 --- a/frontend/editor/public/og-metadata.saas.json +++ b/frontend/editor/public/og-metadata.saas.json @@ -196,6 +196,11 @@ "title": "Compress - Stirling PDF", "description": "Compress PDFs to reduce their file size." }, + "classify": { + "image": "/og_images/home.png", + "title": "Classify - Stirling PDF", + "description": "Identify what kind of document this is and tag it." + }, "extractPages": { "image": "/og_images/extract-pages.png", "title": "Extract Pages - Stirling PDF", @@ -588,6 +593,7 @@ "/remove-cert-sign": "removeCertSign", "/unlock-p-d-f-forms": "unlockPDFForms", "/compress": "compress", + "/classify": "classify", "/extract-pages": "extractPages", "/reorganize-pages": "reorganizePages", "/extract-images": "extractImages", diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts index 38eb7fe7b6..ec38dc37d7 100644 --- a/frontend/editor/scripts/generate-tool-api-types.mts +++ b/frontend/editor/scripts/generate-tool-api-types.mts @@ -10,15 +10,9 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { parseArgs } from "node:util"; import { compile, type JSONSchema } from "json-schema-to-typescript"; -import * as prettier from "prettier"; -// The API namespaces whose endpoints a pipeline can reference. `/api/v1/ai/tools/` -// is absent from the spec, so it cannot appear here. Extend this list when other -// namespaces become tools. -// -// `/api/v1/filter/` and `/api/v1/integration/` are included even though neither is a -// user-facing tool: a stored pipeline can contain one, and ToolEndpoint keys the I/O -// table, so leaving them out would stop a chain being checked past such a step. +// Endpoints a pipeline can reference. filter/integration are not user-facing tools but a stored +// pipeline can contain one; the AI namespace is admitted one endpoint at a time, not wholesale. const ALLOWED_PATH_PREFIXES = [ "/api/v1/general/", "/api/v1/misc/", @@ -26,6 +20,7 @@ const ALLOWED_PATH_PREFIXES = [ "/api/v1/convert/", "/api/v1/filter/", "/api/v1/integration/", + "/api/v1/ai/tools/classify-and-label", ]; // File plumbing, not user parameters: `fileInput` and `file` are the uploaded primary document @@ -227,11 +222,7 @@ function collectToolIO( return { table, dropped }; } -async function renderToolIO( - spec: Json, - table: Record, - outputPath: string, -): Promise { +function renderToolIO(spec: Json, table: Record): string { if (Object.keys(table).length === 0) { throw new Error( `No ${IO_EXTENSION} declarations in the spec. The backend publishes these from @ToolIO; regenerate with 'task backend:swagger'.`, @@ -312,33 +303,12 @@ export function toolIOFor( } `; - const prettierConfig = await prettier.resolveConfig(outputPath); - return prettier.format(body, { ...prettierConfig, parser: "typescript" }); + return body; } -/** In check mode, fail when the committed file is out of date. */ -function writeOrCheck( - outputPath: string, - formatted: string, - check: boolean, - task: string, -): void { - if (check) { - let current = ""; - try { - current = readFileSync(outputPath, "utf-8"); - } catch { - // Missing file counts as out of date. - } - if (current !== formatted) { - throw new Error( - `${outputPath} is out of date. Run '${task}' and commit the result.`, - ); - } - return; - } +function writeOutput(outputPath: string, contents: string): void { mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, formatted, "utf-8"); + writeFileSync(outputPath, contents, "utf-8"); } async function main(): Promise { @@ -347,12 +317,11 @@ async function main(): Promise { spec: { type: "string" }, output: { type: "string" }, "io-output": { type: "string" }, - check: { type: "boolean", default: false }, }, }); if (!values.spec || !values.output || !values["io-output"]) { throw new Error( - "Usage: generate-tool-api-types.mts --spec --output --io-output [--check]", + "Usage: generate-tool-api-types.mts --spec --output --io-output ", ); } const specPath = resolve(values.spec); @@ -478,14 +447,9 @@ async function main(): Promise { `Dropped ${dropped.length} @ToolIO declaration(s) on paths that are not tool endpoints. Add the namespace to ALLOWED_PATH_PREFIXES if a pipeline can contain these steps:\n ${dropped.join("\n ")}`, ); } - writeOrCheck( - ioOutputPath, - await renderToolIO(spec, ioDeclarations, ioOutputPath), - values.check ?? false, - "task frontend:tool-models", - ); + writeOutput(ioOutputPath, renderToolIO(spec, ioDeclarations)); console.log( - `${values.check ? "Up to date" : "Generated"}: ${Object.keys(ioDeclarations).length} tool I/O declarations.`, + `Generated ${Object.keys(ioDeclarations).length} tool I/O declarations.`, ); // Transitively inline every referenced component into `definitions`, rewriting its refs too. @@ -508,7 +472,6 @@ async function main(): Promise { definitions, fileFieldsByClass, outputPath, - values.check ?? false, skipped, ); } @@ -518,7 +481,6 @@ async function compileAndWrite( definitions: Record, fileFieldsByClass: Record, outputPath: string, - check: boolean, skipped: string[], ): Promise { // json-schema-to-typescript only emits a named, exported interface per schema @@ -597,17 +559,9 @@ async function compileAndWrite( ].join("\n"); const body = `${FILE_HEADER}\n\n${models}\n\n${footer}\n`; - const prettierConfig = await prettier.resolveConfig(outputPath); - const formatted = await prettier.format(body, { - ...prettierConfig, - parser: "typescript", - }); - - writeOrCheck(outputPath, formatted, check, "task frontend:tool-models"); - console.log( - `${check ? "Up to date" : "Generated"}: ${tools.length} tool endpoints.`, - ); - if (!check && skipped.length > 0) { + writeOutput(outputPath, body); + console.log(`Generated ${tools.length} tool endpoints.`); + if (skipped.length > 0) { console.log( `Skipped ${skipped.length} POST endpoint(s) with no request body: ${skipped.join(", ")}`, ); diff --git a/frontend/editor/src/core/components/shared/BulkShareModal.tsx b/frontend/editor/src/core/components/shared/BulkShareModal.tsx index e210d2f201..f68a154e89 100644 --- a/frontend/editor/src/core/components/shared/BulkShareModal.tsx +++ b/frontend/editor/src/core/components/shared/BulkShareModal.tsx @@ -153,7 +153,7 @@ const BulkShareModal: React.FC = ({ if (onShared) { await onShared(); } - } catch (error: any) { + } catch (error: unknown) { console.error("Failed to generate share link:", error); setErrorMessage( t( diff --git a/frontend/editor/src/core/components/shared/CardSelector.tsx b/frontend/editor/src/core/components/shared/CardSelector.tsx index 75e9071d81..79a94e7b40 100644 --- a/frontend/editor/src/core/components/shared/CardSelector.tsx +++ b/frontend/editor/src/core/components/shared/CardSelector.tsx @@ -1,5 +1,6 @@ import { Stack, Card, Text, Flex } from "@mantine/core"; import { Tooltip } from "@app/components/shared/Tooltip"; +import { TooltipTip } from "@app/types/tips"; import { useTranslation } from "react-i18next"; export interface CardOption { @@ -7,14 +8,14 @@ export interface CardOption { prefixKey: string; nameKey: string; tooltipKey?: string; - tooltipContent?: any[]; + tooltipContent?: TooltipTip[]; } export interface CardSelectorProps> { options: K[]; onSelect: (value: T) => void; disabled?: boolean; - getTooltipContent?: (option: K) => any[]; + getTooltipContent?: (option: K) => TooltipTip[]; } const CardSelector = >({ diff --git a/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx b/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx index 17502ee9b6..2b84105ba8 100644 --- a/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx +++ b/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx @@ -269,7 +269,7 @@ export function FileSelectorPicker({ responseType: "blob", suppressErrorToast: true, skipAuthRedirect: true, - } as any, + }, ); const ct = readResponseHeader(res.headers, "content-type"); const disp = readResponseHeader(res.headers, "content-disposition"); @@ -287,7 +287,7 @@ export function FileSelectorPicker({ responseType: "blob", suppressErrorToast: true, skipAuthRedirect: true, - } as any, + }, ); const ct = readResponseHeader(res.headers, "content-type"); const disp = readResponseHeader(res.headers, "content-disposition"); diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 3979a40041..f9337bd880 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -208,7 +208,7 @@ const FileSidebar = forwardRef( const openWatchedFolders = useCallback(() => { if (collapsed && onToggleCollapse) onToggleCollapse(); setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId: null }); - navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any); + navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID); }, [collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions]); // Clicking a file's membership dot jumps straight into that folder. @@ -216,7 +216,7 @@ const FileSidebar = forwardRef( (folderId: string) => { if (collapsed && onToggleCollapse) onToggleCollapse(); setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId }); - navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any); + navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID); }, [collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions], ); diff --git a/frontend/editor/src/core/components/shared/FirstLoginModal.tsx b/frontend/editor/src/core/components/shared/FirstLoginModal.tsx index be91ccc03d..8d2e6a7c67 100644 --- a/frontend/editor/src/core/components/shared/FirstLoginModal.tsx +++ b/frontend/editor/src/core/components/shared/FirstLoginModal.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import axios from "axios"; import { Modal, Stack, Text, PasswordInput, Alert } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; @@ -95,10 +96,13 @@ export default function FirstLoginModal({ setTimeout(() => { onPasswordChanged(); }, 1500); - } catch (err: any) { + } catch (err: unknown) { console.error("Failed to change password:", err); + const message = axios.isAxiosError<{ message?: string }>(err) + ? err.response?.data?.message + : undefined; setError( - err.response?.data?.message || + message || t( "firstLogin.passwordChangeFailed", "Failed to change password. Please check your current password.", diff --git a/frontend/editor/src/core/components/shared/FitText.tsx b/frontend/editor/src/core/components/shared/FitText.tsx index c3a2ead2f2..97afc8fdb9 100644 --- a/frontend/editor/src/core/components/shared/FitText.tsx +++ b/frontend/editor/src/core/components/shared/FitText.tsx @@ -1,4 +1,4 @@ -import React, { CSSProperties, useMemo, useRef } from "react"; +import React, { CSSProperties, useCallback, useMemo, useRef } from "react"; import { useAdjustFontSizeToFit } from "@app/components/shared/fitText/textFit"; type FitTextProps = { @@ -28,8 +28,14 @@ const FitText: React.FC = ({ }) => { const ref = useRef(null); + // Callback ref: an HTMLElement handler satisfies span's/div's differing ref + // types (callback refs are contravariant), so the tag can stay polymorphic. + const setRef = useCallback((node: HTMLElement | null) => { + ref.current = node; + }, []); + // Hook runs after mount and on size/text changes; uses observers internally - useAdjustFontSizeToFit(ref as any, { + useAdjustFontSizeToFit(ref, { maxFontSizePx: fontSize, minFontScale: minimumFontScale, maxLines: lines, @@ -38,7 +44,7 @@ const FitText: React.FC = ({ // Memoize the HTML tag to render (span/div) from the `as` prop so // React doesn't create a new component function on each render. - const ElementTag: any = useMemo(() => as, [as]); + const ElementTag: React.ElementType = useMemo(() => as, [as]); // For the / character, insert zero-width soft breaks to prefer wrapping at them const displayText = useMemo(() => { @@ -70,7 +76,7 @@ const FitText: React.FC = ({ return ( diff --git a/frontend/editor/src/core/components/shared/ShareFileModal.tsx b/frontend/editor/src/core/components/shared/ShareFileModal.tsx index 1430f9df20..009345c274 100644 --- a/frontend/editor/src/core/components/shared/ShareFileModal.tsx +++ b/frontend/editor/src/core/components/shared/ShareFileModal.tsx @@ -165,7 +165,7 @@ const ShareFileModal: React.FC = ({ if (onUploaded) { await onUploaded(); } - } catch (error: any) { + } catch (error: unknown) { console.error("Failed to generate share link:", error); setErrorMessage( t( diff --git a/frontend/editor/src/core/components/shared/ShareManagementModal.tsx b/frontend/editor/src/core/components/shared/ShareManagementModal.tsx index e0e5899f8d..64760da8d7 100644 --- a/frontend/editor/src/core/components/shared/ShareManagementModal.tsx +++ b/frontend/editor/src/core/components/shared/ShareManagementModal.tsx @@ -226,7 +226,7 @@ const ShareManagementModal: React.FC = ({ durationMs: 2500, }); } - } catch (error: any) { + } catch (error: unknown) { console.error("Failed to create share link:", error); setErrorMessage( t( diff --git a/frontend/editor/src/core/components/shared/Tooltip.tsx b/frontend/editor/src/core/components/shared/Tooltip.tsx index 5403940751..55c06a1533 100644 --- a/frontend/editor/src/core/components/shared/Tooltip.tsx +++ b/frontend/editor/src/core/components/shared/Tooltip.tsx @@ -18,6 +18,18 @@ import { useLogoAssets } from "@app/hooks/useLogoAssets"; import styles from "@app/components/shared/tooltip/Tooltip.module.css"; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; +// The wrapped child's own event handlers, which Tooltip forwards to after +// running its own trigger logic. Kept partial since any given child may set none. +interface ForwardedHandlers { + onPointerEnter?: React.PointerEventHandler; + onPointerLeave?: React.PointerEventHandler; + onMouseDown?: React.MouseEventHandler; + onMouseUp?: React.MouseEventHandler; + onClick?: React.MouseEventHandler; + onFocus?: React.FocusEventHandler; + onBlur?: React.FocusEventHandler; +} + export interface TooltipProps { sidebarTooltip?: boolean; position?: "right" | "left" | "top" | "bottom"; @@ -218,7 +230,7 @@ export const Tooltip: React.FC = ({ const handlePointerEnter = useCallback( (e: React.PointerEvent) => { if (!isPinned && !disabled) openWithDelay(); - (children.props as any)?.onPointerEnter?.(e); + (children.props as ForwardedHandlers).onPointerEnter?.(e); }, [isPinned, openWithDelay, children.props, disabled], ); @@ -233,19 +245,19 @@ export const Tooltip: React.FC = ({ tooltipRef.current && tooltipRef.current.contains(related) ) { - (children.props as any)?.onPointerLeave?.(e); + (children.props as ForwardedHandlers).onPointerLeave?.(e); return; } // Ignore transient leave between mousedown and click if (clickPendingRef.current) { - (children.props as any)?.onPointerLeave?.(e); + (children.props as ForwardedHandlers).onPointerLeave?.(e); return; } clearTimers(); if (allowAutoClose && !isPinned) setOpen(false); - (children.props as any)?.onPointerLeave?.(e); + (children.props as ForwardedHandlers).onPointerLeave?.(e); }, [clearTimers, isPinned, setOpen, children.props, allowAutoClose], ); @@ -253,7 +265,7 @@ export const Tooltip: React.FC = ({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { clickPendingRef.current = true; - (children.props as any)?.onMouseDown?.(e); + (children.props as ForwardedHandlers).onMouseDown?.(e); }, [children.props], ); @@ -262,7 +274,7 @@ export const Tooltip: React.FC = ({ (e: React.MouseEvent) => { // allow microtask turn so click can see this false queueMicrotask(() => (clickPendingRef.current = false)); - (children.props as any)?.onMouseUp?.(e); + (children.props as ForwardedHandlers).onMouseUp?.(e); }, [children.props], ); @@ -279,7 +291,7 @@ export const Tooltip: React.FC = ({ return; } clickPendingRef.current = false; - (children.props as any)?.onClick?.(e); + (children.props as ForwardedHandlers).onClick?.(e); }, [clearTimers, pinOnClick, open, setOpen, children.props], ); @@ -288,7 +300,7 @@ export const Tooltip: React.FC = ({ const handleFocus = useCallback( (e: React.FocusEvent) => { if (!isPinned && !disabled && openOnFocus) openWithDelay(); - (children.props as any)?.onFocus?.(e); + (children.props as ForwardedHandlers).onFocus?.(e); }, [isPinned, openWithDelay, children.props, disabled, openOnFocus], ); @@ -301,12 +313,12 @@ export const Tooltip: React.FC = ({ tooltipRef.current && tooltipRef.current.contains(related) ) { - (children.props as any)?.onBlur?.(e); + (children.props as ForwardedHandlers).onBlur?.(e); return; } clearTimers(); if (allowAutoClose && !isPinned) setOpen(false); - (children.props as any)?.onBlur?.(e); + (children.props as ForwardedHandlers).onBlur?.(e); }, [isPinned, setOpen, children.props, allowAutoClose, clearTimers], ); @@ -339,24 +351,30 @@ export const Tooltip: React.FC = ({ ); // Enhance child with handlers and ref - const childWithHandlers = React.cloneElement(children as any, { - ref: (node: HTMLElement | null) => { - triggerRef.current = node || null; - const originalRef = (children as any).ref; - if (typeof originalRef === "function") originalRef(node); - else if (originalRef && typeof originalRef === "object") - (originalRef as any).current = node; + const childWithHandlers = React.cloneElement( + children as React.ReactElement>, + { + ref: (node: HTMLElement | null) => { + triggerRef.current = node || null; + const originalRef = ( + children as React.ReactElement & { ref?: React.Ref } + ).ref; + if (typeof originalRef === "function") originalRef(node); + else if (originalRef && typeof originalRef === "object") + (originalRef as React.MutableRefObject).current = + node; + }, + "aria-describedby": open ? tooltipIdRef.current : undefined, + onPointerEnter: handlePointerEnter, + onPointerLeave: handlePointerLeave, + onMouseDown: handleMouseDown, + onMouseUp: handleMouseUp, + onClick: handleClick, + onFocus: handleFocus, + onBlur: handleBlur, + onKeyDown: handleKeyDown, }, - "aria-describedby": open ? tooltipIdRef.current : undefined, - onPointerEnter: handlePointerEnter, - onPointerLeave: handlePointerLeave, - onMouseDown: handleMouseDown, - onMouseUp: handleMouseUp, - onClick: handleClick, - onFocus: handleFocus, - onBlur: handleBlur, - onKeyDown: handleKeyDown, - }); + ); const shouldShowTooltip = open; const shouldShowCloseButton = showCloseButton || isPinned; diff --git a/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx b/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx index 65c50a3481..0e4926adbe 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx @@ -82,7 +82,7 @@ export default function AutomationCreation({ setConfigModalOpen(true); }; - const handleToolConfigSave = (parameters: Record) => { + const handleToolConfigSave = (parameters: Record) => { if (configuraingToolIndex >= 0) { updateTool(configuraingToolIndex, { configured: true, diff --git a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx index 8ffbfeda81..a4a7c1d2ca 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx @@ -19,7 +19,7 @@ interface AutomationEntryProps { /** Optional description for tooltip */ description?: string; /** MUI Icon component for the badge */ - badgeIcon?: React.ComponentType; + badgeIcon?: React.ComponentType; /** Array of tool operation names in the workflow */ operations: string[]; /** Click handler */ diff --git a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx index 2e3e5f1539..675372c12b 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx @@ -9,11 +9,12 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; import { AutomationConfig, ExecutionStep } from "@app/types/automation"; import { EXECUTION_STATUS } from "@app/constants/automation"; import { useResourceCleanup } from "@app/utils/resourceManager"; +import type { useAutomateOperation } from "@app/hooks/tools/automate/useAutomateOperation"; interface AutomationRunProps { automation: AutomationConfig; onComplete: () => void; - automateOperation?: any; // TODO: Type this properly when available + automateOperation?: ReturnType; } export default function AutomationRun({ @@ -34,13 +35,13 @@ export default function AutomationRun({ // Use the operation hook's loading state const isExecuting = automateOperation?.isLoading || false; const hasResults = - automateOperation?.files.length > 0 || + (automateOperation?.files.length ?? 0) > 0 || automateOperation?.downloadUrl !== null; // Initialize execution steps from automation useEffect(() => { if (automation?.operations) { - const steps = automation.operations.map((op: any, index: number) => { + const steps = automation.operations.map((op, index) => { const tool = toolRegistry[op.operation as keyof typeof toolRegistry]; return { id: `${op.operation}-${index}`, @@ -125,7 +126,7 @@ export default function AutomationRun({ // Mark all as completed and reset current step setCurrentStepIndex(-1); console.log(`✅ Automation completed successfully`); - } catch (error: any) { + } catch (error: unknown) { console.error("Automation execution failed:", error); setCurrentStepIndex(-1); } diff --git a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx index ad7bb33a6e..2dd482863d 100644 --- a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx +++ b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx @@ -4,6 +4,7 @@ import { Stack, Text, ScrollArea } from "@mantine/core"; import { ToolRegistryEntry, ToolRegistry, + SubcategoryId, getToolSupportsAutomate, } from "@app/data/toolsTaxonomy"; import { useToolSections } from "@app/hooks/useToolSections"; @@ -81,9 +82,7 @@ export default function ToolSelector({ }, [filteredTools]); // Use the same tool sections logic as the main ToolPicker - const { sections, searchGroups } = useToolSections( - transformedFilteredTools as any /* FIX ME */, - ); + const { sections, searchGroups } = useToolSections(transformedFilteredTools); // Determine what to display: search results or organized sections const isSearching = searchTerm.trim().length > 0; @@ -98,7 +97,9 @@ export default function ToolSelector({ return [ { name: "Tools", - subcategoryId: "all" as any, + // Synthetic "all tools" group used only as a fallback when the + // taxonomy produces no sections; "all" is not a real SubcategoryId. + subcategoryId: "all" as unknown as SubcategoryId, tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool })), }, ]; @@ -125,7 +126,7 @@ export default function ToolSelector({ displayGroups.map((subcategory) => renderToolButtons( t, - subcategory as any, + subcategory, null, handleToolSelect, !isSearching, diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 187a48ad16..80feb0f91e 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -13,6 +13,7 @@ import { useFileActions, } from "@app/contexts/FileContext"; import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; +import { ZoomMode } from "@embedpdf/plugin-zoom/react"; import { useViewer } from "@app/contexts/ViewerContext"; import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF"; import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar"; @@ -418,7 +419,7 @@ const EmbedPdfViewerContent = ({ return; case "0": event.preventDefault(); - zoomActions.requestZoom("fit-width"); + zoomActions.requestZoom(ZoomMode.FitWidth); return; } } diff --git a/frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx b/frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx index 36d0c59cf7..3efb250de1 100644 --- a/frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx +++ b/frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx @@ -118,7 +118,6 @@ export function SelectionAPIBridge() { const buildApi = () => ({ copyToClipboard: () => selection.copyToClipboard(), - getSelectedText: () => selection.getSelectedText(), getFormattedSelection: () => selection.getFormattedSelection(), selectAll: async (totalPages: number) => { const docId = activeDocumentId; diff --git a/frontend/editor/src/core/contexts/viewer/viewerActions.ts b/frontend/editor/src/core/contexts/viewer/viewerActions.ts index 2e0231d766..ae5679c9b6 100644 --- a/frontend/editor/src/core/contexts/viewer/viewerActions.ts +++ b/frontend/editor/src/core/contexts/viewer/viewerActions.ts @@ -6,6 +6,8 @@ import { ZoomState, } from "@app/contexts/viewer/viewerBridges"; import { PdfBookmarkObject, PdfAttachmentObject } from "@embedpdf/models"; +import { ZoomLevel, Point } from "@embedpdf/plugin-zoom"; +import { FormattedSelection } from "@embedpdf/plugin-selection"; export interface ScrollActions { scrollToPage: (page: number, behavior?: "smooth" | "instant") => void; @@ -19,7 +21,7 @@ export interface ZoomActions { zoomIn: () => void; zoomOut: () => void; toggleMarqueeZoom: () => void; - requestZoom: (level: any, center?: any) => void; + requestZoom: (level: ZoomLevel, center?: Point) => void; setZoomLevel: (factor: number) => void; } @@ -31,8 +33,7 @@ export interface PanActions { export interface SelectionActions { copyToClipboard: () => void; - getSelectedText: () => string; - getFormattedSelection: () => any; + getFormattedSelection: () => FormattedSelection[] | null; selectAll: (totalPages: number) => Promise; selectWordAt: (pageIndex: number, x: number, y: number) => boolean; } @@ -51,7 +52,7 @@ export interface RotationActions { } export interface SearchActions { - search: (query: string) => Promise | undefined; + search: (query: string) => Promise | undefined; next: () => void; previous: () => void; clear: () => void; @@ -214,7 +215,7 @@ export function createViewerActions({ api.toggleMarqueeZoom(); } }, - requestZoom: (level: any, center?: any) => { + requestZoom: (level: ZoomLevel, center?: Point) => { const api = registry.current.zoom?.api; if (api?.requestZoom) { api.requestZoom(level, center); @@ -257,13 +258,6 @@ export function createViewerActions({ api.copyToClipboard(); } }, - getSelectedText: () => { - const api = registry.current.selection?.api; - if (api?.getSelectedText) { - return api.getSelectedText() ?? ""; - } - return ""; - }, getFormattedSelection: () => { const api = registry.current.selection?.api; if (api?.getFormattedSelection) { diff --git a/frontend/editor/src/core/contexts/viewer/viewerBridges.ts b/frontend/editor/src/core/contexts/viewer/viewerBridges.ts index c003013cb3..7a9f177e03 100644 --- a/frontend/editor/src/core/contexts/viewer/viewerBridges.ts +++ b/frontend/editor/src/core/contexts/viewer/viewerBridges.ts @@ -1,5 +1,7 @@ import { SpreadMode } from "@embedpdf/plugin-spread/react"; import { PdfBookmarkObject, PdfAttachmentObject } from "@embedpdf/models"; +import { ZoomLevel, Point } from "@embedpdf/plugin-zoom"; +import { FormattedSelection } from "@embedpdf/plugin-selection"; export enum PdfPermissionFlag { Print = 0x0004, @@ -46,7 +48,7 @@ export interface ZoomAPIWrapper { zoomIn: () => void; zoomOut: () => void; toggleMarqueeZoom: () => void; - requestZoom: (level: any, center?: any) => void; + requestZoom: (level: ZoomLevel, center?: Point) => void; } export interface PanAPIWrapper { @@ -58,8 +60,7 @@ export interface PanAPIWrapper { export interface SelectionAPIWrapper { copyToClipboard: () => void; - getSelectedText: () => string | any; - getFormattedSelection: () => any; + getFormattedSelection: () => FormattedSelection[]; selectAll: (totalPages: number) => Promise; selectWordAt: (pageIndex: number, x: number, y: number) => boolean; } @@ -79,7 +80,7 @@ export interface RotationAPIWrapper { } export interface SearchAPIWrapper { - search: (query: string) => Promise; + search: (query: string) => Promise; clear: () => void; next: () => void; previous: () => void; diff --git a/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx b/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx new file mode 100644 index 0000000000..f5281f80ae --- /dev/null +++ b/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx @@ -0,0 +1,76 @@ +/** + * Classification is a thing a pipeline can do, not a thing only the Classification policy may do. + * + * The chain that used to stop it: `getExecutableTools` drops any tool whose endpoint is not a + * member of the generated `ToolEndpoint` union; that union comes from the OpenAPI spec, gated by + * the generator's namespace allowlist; and the classify controller was `@Hidden`, so it never + * reached the spec at all. These tests pin each link, because any one of them silently removes the + * step from the builder's picker rather than failing loudly. + */ +import { describe, expect, test, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry"; +import { getExecutableTools } from "@app/hooks/tools/shared/toolAutomation"; +import { isToolEndpoint } from "@app/hooks/tools/shared/toolApiMapping"; +import { TOOL_IO } from "@app/types/toolIO"; +import { filterToolRegistryByQuery } from "@app/utils/toolSearch"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + i18n: { changeLanguage: vi.fn(), language: "en-US" }, + }), + Trans: ({ children }: { children?: unknown }) => children, +})); + +const CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label"; + +describe("classify as a pipeline task", () => { + test("the classify endpoint is a generated ToolEndpoint", () => { + // Fails if the controller goes back to @Hidden, or the generator's allowlist drops the + // /api/v1/ai/tools/ namespace, or nobody regenerated after either. + expect(isToolEndpoint(CLASSIFY_ENDPOINT)).toBe(true); + }); + + test("the builder offers it as a step", () => { + const { result } = renderHook(() => useTranslatedToolCatalog()); + + const executable = getExecutableTools(result.current.regularTools); + const classify = executable.find((tool) => tool.toolId === "classify"); + + expect(classify).toBeDefined(); + expect(classify?.endpoint).toBe(CLASSIFY_ENDPOINT); + }); + + test("it declares PDF in, PDF out, so a chain can be checked across it", () => { + // Without this the builder shows "Can't check what this step accepts" and validation stops + // dead at the step - the I/O table is keyed by endpoint and comes from @ToolIO in the spec. + expect(TOOL_IO[CLASSIFY_ENDPOINT]).toEqual({ + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }); + }); + + test("it is offered to pipelines but kept out of the editor's tool list", () => { + const { result } = renderHook(() => useTranslatedToolCatalog()); + + // There is no interactive classify tool to open - it only means something inside a pipeline. + expect(result.current.regularTools.classify?.hiddenFromToolList).toBe(true); + expect( + filterToolRegistryByQuery(result.current.regularTools, "").some( + (ranked) => ranked.item[0] === "classify", + ), + ).toBe(false); + }); + + test("it does not re-classify by default", () => { + const { result } = renderHook(() => useTranslatedToolCatalog()); + + const config = result.current.regularTools.classify?.operationConfig; + + // The step is idempotent unless asked otherwise: a second run on a classified document + // would be a second engine call, and a second charge, for the same answer. + expect(config?.defaultParameters).toEqual({ reclassify: false }); + }); +}); diff --git a/frontend/editor/src/core/data/toolsTaxonomy.ts b/frontend/editor/src/core/data/toolsTaxonomy.ts index f9c2dde02f..740b8a13ec 100644 --- a/frontend/editor/src/core/data/toolsTaxonomy.ts +++ b/frontend/editor/src/core/data/toolsTaxonomy.ts @@ -29,6 +29,7 @@ import { ProprietaryToolId } from "@app/types/proprietaryToolId"; import { PrototypeToolId } from "@app/types/prototypeToolId"; export enum SubcategoryId { + AI = "ai", SIGNING = "signing", DOCUMENT_SECURITY = "documentSecurity", VERIFICATION = "verification", @@ -71,6 +72,8 @@ export type ToolRegistryEntry = { > | null; // Whether this tool supports automation (defaults to true) supportsAutomate?: boolean; + // Keep out of the editor's tool list: a step only a pipeline runs, with no UI to open. + hiddenFromToolList?: boolean; // Synonyms for search (optional) synonyms?: string[]; // Version status indicator (e.g., "alpha", "beta") @@ -90,6 +93,8 @@ export type ProprietaryToolRegistry = Record< export type PrototypeToolRegistry = Record; export const SUBCATEGORY_ORDER: SubcategoryId[] = [ + // First: AI steps are the ones a user is least likely to know exist. + SubcategoryId.AI, SubcategoryId.SIGNING, SubcategoryId.DOCUMENT_SECURITY, SubcategoryId.VERIFICATION, @@ -104,6 +109,7 @@ export const SUBCATEGORY_ORDER: SubcategoryId[] = [ ]; export const SUBCATEGORY_COLOR_MAP: Record = { + [SubcategoryId.AI]: "var(--category-color-automation)", // Pink [SubcategoryId.SIGNING]: "var(--category-color-signing)", // Green [SubcategoryId.DOCUMENT_SECURITY]: "var(--category-color-security)", // Orange [SubcategoryId.VERIFICATION]: "var(--category-color-verification)", // Orange diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx index 5ae8075d0f..5e81427d55 100644 --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx @@ -20,6 +20,7 @@ import { import { adjustContrastOperationConfig } from "@app/hooks/tools/adjustContrast/useAdjustContrastOperation"; import { getSynonyms } from "@app/utils/toolSynonyms"; import { useProprietaryToolRegistry } from "@app/data/useProprietaryToolRegistry"; +import { classifyOperationConfig } from "@app/hooks/tools/classify/useClassifyOperation"; import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation"; import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation"; @@ -1338,6 +1339,30 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { synonyms: getSynonyms(t, "compare"), supportsAutomate: false, }, + classify: { + icon: ( + + ), + name: t("home.classify.title", "Classify"), + // No interactive UI: this is a pipeline step, registered so a pipeline can name it. + component: null, + description: t( + "home.classify.desc", + "Identify what kind of document this is and tag it.", + ), + categoryId: ToolCategoryId.ADVANCED_TOOLS, + subcategoryId: SubcategoryId.AI, + maxFiles: -1, + endpoints: ["classify-and-label"], + operationConfig: asRegistryConfig(classifyOperationConfig), + automationSettings: null, + // Pipeline-only: there is no interactive classify tool to open in the editor. + hiddenFromToolList: true, + }, compress: { icon: ( The tool reads a window of the document, asks the AI engine what kind of document it is, and + * writes the verdict into the PDF's metadata. It has no interactive UI - there is nothing for a + * user to set beyond whether to redo work already done - so it exists in the registry purely so a + * pipeline can name it, the same way the Classification policy always could. + */ +const ENDPOINT = "/api/v1/ai/tools/classify-and-label" satisfies ToolEndpoint; +type ClassifyApiParams = ToolApiParams[typeof ENDPOINT]; + +export interface ClassifyParameters { + /** + * Classify again even when the document already carries a verdict. Off by default: re-running + * costs a second engine call, and a document that has been classified has nothing new to say. + */ + reclassify: boolean; +} + +export const defaultParameters: ClassifyParameters = { reclassify: false }; + +export const classifyToApiParams = ( + parameters: ClassifyParameters, +): ClassifyApiParams => ({ reclassify: parameters.reclassify }); + +export const classifyFromApiParams = ( + apiParams: ClassifyApiParams, +): Partial => ({ + reclassify: apiParams.reclassify ?? defaultParameters.reclassify, +}); + +export const buildClassifyFormData = ( + parameters: ClassifyParameters, + file: File, +): FormData => + objectToFormData(classifyToApiParams(parameters), { fileInput: file }); + +export const classifyOperationConfig = defineSingleFileTool({ + buildFormData: buildClassifyFormData, + toApiParams: classifyToApiParams, + fromApiParams: classifyFromApiParams, + operationType: "classify", + endpoint: ENDPOINT, + defaultParameters, +}); diff --git a/frontend/editor/src/core/services/apiClientConfig.ts b/frontend/editor/src/core/services/apiClientConfig.ts index b476d1348a..89fa465743 100644 --- a/frontend/editor/src/core/services/apiClientConfig.ts +++ b/frontend/editor/src/core/services/apiClientConfig.ts @@ -11,11 +11,11 @@ */ export function getApiBaseUrl(): string { // Runtime override to fix hardcoded localhost in builds - if ( - typeof window !== "undefined" && - (window as any).STIRLING_PDF_API_BASE_URL - ) { - return (window as any).STIRLING_PDF_API_BASE_URL; + if (typeof window !== "undefined") { + const override = window.STIRLING_PDF_API_BASE_URL; + if (override) { + return override; + } } return import.meta.env.VITE_API_BASE_URL; diff --git a/frontend/editor/src/core/services/auditService.ts b/frontend/editor/src/core/services/auditService.ts index c748504c54..acf43ab1d5 100644 --- a/frontend/editor/src/core/services/auditService.ts +++ b/frontend/editor/src/core/services/auditService.ts @@ -17,7 +17,7 @@ export interface AuditEvent { eventType: string; username: string; ipAddress: string; - details: Record; + details: Record; } export interface AuditEventsResponse { diff --git a/frontend/editor/src/core/services/automationStorage.ts b/frontend/editor/src/core/services/automationStorage.ts index 826bf3ce30..196596de18 100644 --- a/frontend/editor/src/core/services/automationStorage.ts +++ b/frontend/editor/src/core/services/automationStorage.ts @@ -8,7 +8,7 @@ export interface AutomationConfig { description?: string; operations: Array<{ operation: string; - parameters: any; + parameters: Record; }>; createdAt: string; updatedAt: string; diff --git a/frontend/editor/src/core/services/enhancedPDFProcessingService.ts b/frontend/editor/src/core/services/enhancedPDFProcessingService.ts index e91c34cde2..a63651e193 100644 --- a/frontend/editor/src/core/services/enhancedPDFProcessingService.ts +++ b/frontend/editor/src/core/services/enhancedPDFProcessingService.ts @@ -5,6 +5,7 @@ import { ProcessingConfig, ProcessingMetrics, } from "@app/types/processing"; +import type { PDFPageProxy } from "pdfjs-dist"; import { ProcessingCache } from "@app/services/processingCache"; import { FileHasher } from "@app/utils/fileHash"; import { FileAnalyzer } from "@app/services/fileAnalyzer"; @@ -415,7 +416,7 @@ export class EnhancedPDFProcessingService { * Render a page thumbnail with specified quality */ private async renderPageThumbnail( - page: any, + page: PDFPageProxy, quality: "low" | "medium" | "high", ): Promise { const scales = { low: 0.2, medium: 0.5, high: 0.8 }; // Reduced low quality for page editor @@ -431,7 +432,7 @@ export class EnhancedPDFProcessingService { throw new Error("Could not get canvas context"); } - await page.render({ canvasContext: context, viewport }).promise; + await page.render({ canvasContext: context, viewport, canvas }).promise; return canvas.toDataURL("image/jpeg", 0.8); // Use JPEG for better compression } diff --git a/frontend/editor/src/core/services/errorUtils.ts b/frontend/editor/src/core/services/errorUtils.ts index cdfeb9f38f..747cd90b5a 100644 --- a/frontend/editor/src/core/services/errorUtils.ts +++ b/frontend/editor/src/core/services/errorUtils.ts @@ -5,7 +5,7 @@ export const FILE_EVENTS = { const UUID_REGEX = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g; -export function tryParseJson(input: unknown): T | undefined { +export function tryParseJson(input: unknown): T | undefined { if (typeof input !== "string") return input as T | undefined; try { return JSON.parse(input) as T; @@ -14,19 +14,20 @@ export function tryParseJson(input: unknown): T | undefined { } } -export async function normalizeAxiosErrorData(data: any): Promise { +export async function normalizeAxiosErrorData(data: unknown): Promise { if (!data) return undefined; - if (typeof data?.text === "function") { - const text = await data.text(); + const blobLike = data as { text?: () => Promise }; + if (typeof blobLike.text === "function") { + const text = await blobLike.text(); return tryParseJson(text) ?? text; } return data; } -export function extractErrorFileIds(payload: any): string[] | undefined { +export function extractErrorFileIds(payload: unknown): string[] | undefined { if (!payload) return undefined; - if (Array.isArray(payload?.errorFileIds)) - return payload.errorFileIds as string[]; + const errorFileIds = (payload as { errorFileIds?: unknown }).errorFileIds; + if (Array.isArray(errorFileIds)) return errorFileIds as string[]; if (typeof payload === "string") { const matches = payload.match(UUID_REGEX); if (matches && matches.length > 0) return Array.from(new Set(matches)); @@ -45,11 +46,11 @@ export function isZeroByte( file: File | { size?: number } | null | undefined, ): boolean { if (!file) return true; - const size = (file as any).size; + const size = file.size; return typeof size === "number" ? size <= 0 : true; } export function isEmptyOutput(files: File[] | null | undefined): boolean { if (!files || files.length === 0) return true; - return files.every((f) => (f as any)?.size === 0); + return files.every((f) => f?.size === 0); } diff --git a/frontend/editor/src/core/services/fileStorage.migration.test.ts b/frontend/editor/src/core/services/fileStorage.migration.test.ts index 472d83b49b..28ee69e8da 100644 --- a/frontend/editor/src/core/services/fileStorage.migration.test.ts +++ b/frontend/editor/src/core/services/fileStorage.migration.test.ts @@ -33,7 +33,7 @@ describe("legacyDerivedFromTool — IndexedDB backfill for pre-upgrade files", ( it("flags a legacy versioned edit (has tool history)", () => { expect( legacyDerivedFromTool( - record({ toolHistory: [{ toolId: "compress" as any, timestamp: 0 }] }), + record({ toolHistory: [{ toolId: "compress", timestamp: 0 }] }), ), ).toBe(true); }); diff --git a/frontend/editor/src/core/services/fileStorage.ts b/frontend/editor/src/core/services/fileStorage.ts index 27b8746604..b0cede48cb 100644 --- a/frontend/editor/src/core/services/fileStorage.ts +++ b/frontend/editor/src/core/services/fileStorage.ts @@ -10,6 +10,7 @@ import { StirlingFile, StirlingFileStub, createStirlingFile, + type ClassificationConfidence, } from "@app/types/fileContext"; import { indexedDBManager, @@ -36,6 +37,8 @@ export interface StoredStirlingFileRecord extends BaseFileMetadata { // group by label without re-reading PDF bytes, and it survives versioning. // See StirlingFileStub.classificationLabels. classificationLabels?: string[]; + // See StirlingFileStub.classificationConfidence. + classificationConfidence?: ClassificationConfidence; } export interface StorageStats { @@ -695,6 +698,7 @@ class FileStorageService { folderId: record.folderId ?? null, createdAt: record.createdAt || Date.now(), classificationLabels: record.classificationLabels, + classificationConfidence: record.classificationConfidence, }; resolve(stub); @@ -762,6 +766,7 @@ class FileStorageService { folderId: record.folderId ?? null, createdAt: record.createdAt || Date.now(), classificationLabels: record.classificationLabels, + classificationConfidence: record.classificationConfidence, }); } cursor.continue(); @@ -861,6 +866,7 @@ class FileStorageService { folderId: record.folderId ?? null, createdAt: record.createdAt || Date.now(), classificationLabels: record.classificationLabels, + classificationConfidence: record.classificationConfidence, }); } cursor.continue(); diff --git a/frontend/editor/src/core/services/fileSyncService.ts b/frontend/editor/src/core/services/fileSyncService.ts index 5e948f8ba7..a218a43428 100644 --- a/frontend/editor/src/core/services/fileSyncService.ts +++ b/frontend/editor/src/core/services/fileSyncService.ts @@ -131,7 +131,7 @@ export async function reconcileServerFiles( { suppressErrorToast: true, skipAuthRedirect: true, - } as any, + }, ); const serverFiles = Array.isArray(response.data) ? response.data : []; const serverMap = new Map(); @@ -280,7 +280,7 @@ export async function reconcileServerFiles( try { const response = await apiClient.get( "/api/v1/storage/share-links/accessed", - { suppressErrorToast: true, skipAuthRedirect: true } as any, + { suppressErrorToast: true, skipAuthRedirect: true }, ); const sharedLinks = Array.isArray(response.data) ? response.data : []; const allowed = new Set( @@ -426,7 +426,7 @@ export async function materializeServerStubs( responseType: "blob", suppressErrorToast: true, skipAuthRedirect: true, - } as any); + }); const rawHeaders = (response.headers ?? {}) as Record & { get?: (name: string) => string | null; }; diff --git a/frontend/editor/src/core/services/googleDrivePickerService.ts b/frontend/editor/src/core/services/googleDrivePickerService.ts index 9d91ffdeac..f54e900b01 100644 --- a/frontend/editor/src/core/services/googleDrivePickerService.ts +++ b/frontend/editor/src/core/services/googleDrivePickerService.ts @@ -5,6 +5,13 @@ import { loadScript } from "@app/utils/scriptLoader"; import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import { AppConfig } from "@app/types/appConfig"; + +// The GIS token client lets you reassign `callback` after init (to resolve the +// per-request promise), but @types/google.accounts only models it on the config. +type TokenClientWithCallback = google.accounts.oauth2.TokenClient & { + callback: (response: google.accounts.oauth2.TokenResponse) => void; +}; const SCOPES = "https://www.googleapis.com/auth/drive.readonly"; const SESSION_STORAGE_ID = "googleDrivePickerAccessToken"; @@ -59,7 +66,7 @@ function fileInputToGooglePickerMimeTypes(accept?: string): string | null { class GoogleDrivePickerService { private config: GoogleDriveConfig | null = null; - private tokenClient: any = null; + private tokenClient: TokenClientWithCallback | null = null; private accessToken: string | null = null; private gapiLoaded = false; private gisLoaded = false; @@ -119,7 +126,7 @@ class GoogleDrivePickerService { client_id: this.config.clientId, scope: SCOPES, callback: () => {}, // Will be overridden during picker creation - }); + }) as TokenClientWithCallback; this.gisLoaded = true; } @@ -149,7 +156,9 @@ class GoogleDrivePickerService { return; } - this.tokenClient.callback = (response: any) => { + this.tokenClient.callback = ( + response: google.accounts.oauth2.TokenResponse, + ) => { if (response.error !== undefined) { reject(new Error(response.error)); return; @@ -201,7 +210,9 @@ class GoogleDrivePickerService { .setOAuthToken(this.accessToken) .addView(view1) .addView(view2) - .setCallback((data: any) => this.pickerCallback(data, resolve, reject)); + .setCallback((data: google.picker.ResponseObject) => + this.pickerCallback(data, resolve, reject), + ); (builder as unknown as { setZIndex(z: number): void }).setZIndex( Z_INDEX_OVER_FILE_MANAGER_MODAL, @@ -220,37 +231,39 @@ class GoogleDrivePickerService { * Handle picker selection callback */ private async pickerCallback( - data: any, + data: google.picker.ResponseObject, resolve: (files: File[]) => void, reject: (error: Error) => void, ): Promise { - if (data.action === window.google.picker.Action.PICKED) { + const action = data[window.google.picker.Response.ACTION]; + if (action === window.google.picker.Action.PICKED) { try { + const documents = data[window.google.picker.Response.DOCUMENTS] ?? []; const files = await Promise.all( - data[window.google.picker.Response.DOCUMENTS].map( - async (pickedFile: any) => { - const fileId = pickedFile[window.google.picker.Document.ID]; - const res = await window.gapi.client.drive.files.get({ - fileId: fileId, - alt: "media", - }); + documents.map(async (pickedFile) => { + const fileId = pickedFile[window.google.picker.Document.ID]; + const res = await window.gapi.client.drive.files.get({ + fileId: fileId, + alt: "media", + }); - // Convert response body to File object - const file = new File( - [ - new Uint8Array(res.body.length).map((_: any, i: number) => - res.body.charCodeAt(i), - ), - ], - pickedFile.name, - { - type: pickedFile.mimeType, - lastModified: pickedFile.lastModified, - }, - ); - return file; - }, - ), + // Convert response body to File object + const file = new File( + [ + new Uint8Array(res.body.length).map((_, i) => + res.body.charCodeAt(i), + ), + ], + pickedFile[window.google.picker.Document.NAME] ?? "", + { + type: pickedFile[window.google.picker.Document.MIME_TYPE], + lastModified: + pickedFile[window.google.picker.Document.LAST_EDITED_UTC] ?? + Date.now(), + }, + ); + return file; + }), ); resolve(files); @@ -261,7 +274,7 @@ class GoogleDrivePickerService { : new Error("Failed to download files"), ); } - } else if (data.action === window.google.picker.Action.CANCEL) { + } else if (action === window.google.picker.Action.CANCEL) { resolve([]); // User cancelled, return empty array } } @@ -369,7 +382,7 @@ export function getGoogleDriveConfig( * Eliminates duplicated config construction pattern */ export function extractGoogleDriveBackendConfig( - appConfig: any, + appConfig: AppConfig | null, ): BackendGoogleDriveConfig { return { enabled: appConfig?.googleDriveEnabled, diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index 55b0a425f9..dd0c692d97 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -6,6 +6,7 @@ import { normalizeAxiosErrorData, } from "@app/services/errorUtils"; import { showSpecialErrorToast } from "@app/services/specialErrorToasts"; +import axios from "axios"; import { handleSaaSError } from "@app/services/saasErrorInterceptor"; import { clampText, @@ -95,15 +96,16 @@ if (typeof window !== "undefined") { * Handles HTTP errors with toast notifications and file error broadcasting * Returns true if the error should be suppressed (deduplicated), false otherwise */ -export async function handleHttpError(error: any): Promise { - const skipAuthRedirect = error?.config?.skipAuthRedirect === true; +export async function handleHttpError(error: unknown): Promise { + const axiosError = axios.isAxiosError(error) ? error : undefined; + const skipAuthRedirect = axiosError?.config?.skipAuthRedirect === true; // Check if this error should skip the global toast (component will handle it) - if (error?.config?.suppressErrorToast === true) { + if (axiosError?.config?.suppressErrorToast === true) { return false; // Don't show global toast, but continue rejection } // Handle 401 authentication errors - const status: number | undefined = error?.response?.status; + const status: number | undefined = axiosError?.response?.status; if (status === 401) { const pathname = window.location.pathname; @@ -119,7 +121,7 @@ export async function handleHttpError(error: any): Promise { if (loginRedirectRecentlyFired()) { console.warn( "[httpErrorHandler] 401 redirect already fired moments ago — suppressing repeat to avoid a login loop:", - error?.config?.url, + axiosError?.config?.url, ); return true; } @@ -151,7 +153,7 @@ export async function handleHttpError(error: any): Promise { const { title, body } = extractAxiosErrorMessage(error); // Normalize response data ONCE, reuse for both ID extraction and special-toast matching - const raw = error?.response?.data as any; + const raw = axiosError?.response?.data; let normalized: unknown = raw; try { normalized = await normalizeAxiosErrorData(raw); @@ -170,7 +172,7 @@ export async function handleHttpError(error: any): Promise { } // 2) Generic-vs-special dedupe by endpoint - const url: string | undefined = error?.config?.url; + const url: string | undefined = axiosError?.config?.url; const now = Date.now(); const isSpecial = status === 422 || diff --git a/frontend/editor/src/core/services/httpErrorUtils.ts b/frontend/editor/src/core/services/httpErrorUtils.ts index b3b54e2664..c4898266c3 100644 --- a/frontend/editor/src/core/services/httpErrorUtils.ts +++ b/frontend/editor/src/core/services/httpErrorUtils.ts @@ -25,14 +25,14 @@ function titleForStatus(status?: number): string { return "Request failed"; } -export function extractAxiosErrorMessage(error: any): { +export function extractAxiosErrorMessage(error: unknown): { title: string; body: string; } { if (axios.isAxiosError(error)) { const status = error.response?.status; const _statusText = error.response?.statusText || ""; - let parsed: any = undefined; + let parsed: unknown = undefined; const raw = error.response?.data; if (typeof raw === "string") { try { @@ -44,8 +44,8 @@ export function extractAxiosErrorMessage(error: any): { parsed = raw; } const extractIds = (): string[] | undefined => { - if (Array.isArray(parsed?.errorFileIds)) - return parsed.errorFileIds as string[]; + const errorFileIds = (parsed as { errorFileIds?: unknown })?.errorFileIds; + if (Array.isArray(errorFileIds)) return errorFileIds as string[]; const rawText = typeof raw === "string" ? raw : ""; const uuidMatches = rawText.match( /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g, @@ -60,7 +60,8 @@ export function extractAxiosErrorMessage(error: any): { if (!data) return typeof raw === "string" ? raw : ""; const ids = extractIds(); if (ids && ids.length > 0) return `Failed files: ${ids.join(", ")}`; - if (data?.message) return data.message as string; + const message = (data as { message?: unknown })?.message; + if (message) return message as string; if (typeof raw === "string") return raw; try { return JSON.stringify(data); @@ -82,7 +83,8 @@ export function extractAxiosErrorMessage(error: any): { return { title, body: bodyMsg }; } try { - const msg = (error?.message || String(error)) as string; + const msg = ((error as { message?: unknown })?.message || + String(error)) as string; return { title: "Network error", body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg, diff --git a/frontend/editor/src/core/services/pdfWorkerManager.ts b/frontend/editor/src/core/services/pdfWorkerManager.ts index 534c6fc3f1..6da74e1dd1 100644 --- a/frontend/editor/src/core/services/pdfWorkerManager.ts +++ b/frontend/editor/src/core/services/pdfWorkerManager.ts @@ -38,7 +38,7 @@ class PDFWorkerManager { "pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url, ).toString(); - (GlobalWorkerOptions as any).docBaseUrl = undefined; + (GlobalWorkerOptions as { docBaseUrl?: string }).docBaseUrl = undefined; this.isInitialized = true; } } @@ -62,7 +62,7 @@ class PDFWorkerManager { } // Normalize input data to PDF.js format - let pdfData: any; + let pdfData: string | { data: ArrayBuffer | Uint8Array }; if (data instanceof ArrayBuffer || data instanceof Uint8Array) { pdfData = { data }; } else if (typeof data === "string") { diff --git a/frontend/editor/src/core/services/signatureDetectionService.ts b/frontend/editor/src/core/services/signatureDetectionService.ts index 0062380a79..41bd9e267c 100644 --- a/frontend/editor/src/core/services/signatureDetectionService.ts +++ b/frontend/editor/src/core/services/signatureDetectionService.ts @@ -4,13 +4,6 @@ * without needing to make API calls */ -// PDF.js types (simplified) -declare global { - interface Window { - pdfjsLib?: any; - } -} - export interface SignatureDetectionResult { hasSignatures: boolean; signatureCount?: number; @@ -53,7 +46,7 @@ const detectSignaturesInFile = async ( // Count signature annotations (Type: /Sig) const signatureAnnotations = annotations.filter( - (annotation: any) => + (annotation: { subtype?: string; fieldType?: string }) => annotation.subtype === "Widget" && annotation.fieldType === "Sig", ); @@ -62,7 +55,11 @@ const detectSignaturesInFile = async ( // Also check for document-level signatures in AcroForm const metadata = await pdf.getMetadata(); - if (metadata?.info?.Signature || metadata?.metadata?.has("dc:signature")) { + const info = metadata?.info as { Signature?: unknown } | undefined; + const xmpMetadata = metadata?.metadata as + | { has?: (name: string) => boolean } + | undefined; + if (info?.Signature || xmpMetadata?.has?.("dc:signature")) { totalSignatures = Math.max(totalSignatures, 1); } diff --git a/frontend/editor/src/core/services/signatureStorageService.ts b/frontend/editor/src/core/services/signatureStorageService.ts index 1cc0ac9360..da9bb9332d 100644 --- a/frontend/editor/src/core/services/signatureStorageService.ts +++ b/frontend/editor/src/core/services/signatureStorageService.ts @@ -1,3 +1,4 @@ +import axios from "axios"; import apiClient from "@app/services/apiClient"; import type { SavedSignature } from "@app/types/signature"; import { readResponseHeader } from "@app/services/shareBundleUtils"; @@ -54,14 +55,17 @@ class SignatureStorageService { supportsBackend: true, storageType: "backend", }; - } catch (error: any) { + } catch (error: unknown) { + const status = axios.isAxiosError(error) + ? error.response?.status + : undefined; // Check if it's an HTTP error with status code - if (error?.response?.status === 401 || error?.response?.status === 403) { + if (status === 401 || status === 403) { // Backend exists but needs auth - gracefully fall back to localStorage console.log( "[SignatureStorage] Backend signature API requires authentication, using localStorage", ); - } else if (error?.response?.status === 404) { + } else if (status === 404) { // Endpoint doesn't exist (not running proprietary mode) console.log( "[SignatureStorage] Backend signature API not available (not in proprietary mode), using localStorage", diff --git a/frontend/editor/src/core/services/specialErrorToasts.ts b/frontend/editor/src/core/services/specialErrorToasts.ts index 6a38a54f53..f2ee343803 100644 --- a/frontend/editor/src/core/services/specialErrorToasts.ts +++ b/frontend/editor/src/core/services/specialErrorToasts.ts @@ -1,3 +1,4 @@ +import i18n from "i18next"; import { alert } from "@app/components/toast"; interface ErrorToastMapping { @@ -42,18 +43,13 @@ export function showSpecialErrorToast( for (const mapping of MAPPINGS) { if (mapping.regex.test(message)) { - // Best-effort translation without hard dependency on i18n config let body = mapping.defaultMessage; - try { - const anyGlobal: any = globalThis as any; - const i18next = anyGlobal?.i18next; - if (i18next && typeof i18next.t === "function") { - body = i18next.t(mapping.i18nKey, { - defaultValue: mapping.defaultMessage, - }); - } - } catch { - /* ignore translation errors */ + // The app bootstraps this shared i18next singleton at startup; guard in + // case a toast fires before that (e.g. tests) so we keep the default copy. + if (i18n.isInitialized) { + body = i18n.t(mapping.i18nKey, { + defaultValue: mapping.defaultMessage, + }); } const title = titleForStatus(options?.status); alert({ diff --git a/frontend/editor/src/core/services/usageAnalyticsService.ts b/frontend/editor/src/core/services/usageAnalyticsService.ts index 8f580c820b..f7ba3dd79e 100644 --- a/frontend/editor/src/core/services/usageAnalyticsService.ts +++ b/frontend/editor/src/core/services/usageAnalyticsService.ts @@ -25,7 +25,7 @@ const usageAnalyticsService = { limit?: number, dataType: "all" | "api" | "ui" = "all", ): Promise { - const params: Record = {}; + const params: Record = {}; if (limit !== undefined) { params.limit = limit; diff --git a/frontend/editor/src/core/types/fileContext.ts b/frontend/editor/src/core/types/fileContext.ts index 4676137abf..b4a48e3d00 100644 --- a/frontend/editor/src/core/types/fileContext.ts +++ b/frontend/editor/src/core/types/fileContext.ts @@ -9,6 +9,9 @@ import { generateId } from "@app/utils/generateId"; // Re-export FileId for convenience export type { FileId }; +/** How sure a classifier was about the labels it produced. */ +export type ClassificationConfidence = "none" | "low" | "medium" | "high"; + // Normalized state types export interface ProcessedFilePage { thumbnail?: string; @@ -61,6 +64,11 @@ export interface StirlingFileStub extends BaseFileMetadata { * unclassified files / non-SaaS builds. */ classificationLabels?: string[]; + /** + * How sure the local heuristic was about {@link classificationLabels}: a confident verdict + * stands, an unsure one escalates to the AI. Undefined when the labels came from the AI. + */ + classificationConfidence?: ClassificationConfidence; /** * This session proved the stored bytes unreadable (WebKit losing a blob's * backing store). The row renders as "data lost" instead of pretending the diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index cea05ca20a..55938d231e 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -207,6 +207,9 @@ export interface AddWatermarkRequest { */ widthSpacer?: number; } +export interface AiToolsClassifyAndLabelRequest { + reclassify?: boolean; +} export interface AutoRotatePdfRequest { /** * Minimum Tesseract OSD orientation confidence required before a correction is applied. Matches OCRmyPDF's --rotate-pages-threshold scale @@ -1485,6 +1488,7 @@ export interface UrlToPdfRequest { /** Endpoint path for a generated tool operation (the operation identity across languages). */ export type ToolEndpoint = + | "/api/v1/ai/tools/classify-and-label" | "/api/v1/convert/cbr/pdf" | "/api/v1/convert/cbz/pdf" | "/api/v1/convert/ebook/pdf" @@ -1587,6 +1591,7 @@ export type ToolEndpoint = /** Backend request-parameter model for each tool endpoint. */ export interface ToolApiParams { + "/api/v1/ai/tools/classify-and-label": AiToolsClassifyAndLabelRequest; "/api/v1/convert/cbr/pdf": ConvertCbrToPdfRequest; "/api/v1/convert/cbz/pdf": ConvertCbzToPdfRequest; "/api/v1/convert/ebook/pdf": ConvertEbookToPdfRequest; @@ -1690,6 +1695,7 @@ export interface ToolApiParams { /** Every generated tool endpoint, for iteration. */ export const TOOL_ENDPOINTS = [ + "/api/v1/ai/tools/classify-and-label", "/api/v1/convert/cbr/pdf", "/api/v1/convert/cbz/pdf", "/api/v1/convert/ebook/pdf", diff --git a/frontend/editor/src/core/types/toolIO.ts b/frontend/editor/src/core/types/toolIO.ts index 4a03a52102..9d672b2d6d 100644 --- a/frontend/editor/src/core/types/toolIO.ts +++ b/frontend/editor/src/core/types/toolIO.ts @@ -99,6 +99,11 @@ export interface ToolIOSpec { export type ToolIOTable = Partial>; export const TOOL_IO: ToolIOTable = { + "/api/v1/ai/tools/classify-and-label": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, "/api/v1/convert/cbr/pdf": { accepts: ["CBR"], produces: "PDF", diff --git a/frontend/editor/src/core/types/toolId.ts b/frontend/editor/src/core/types/toolId.ts index 13799ad869..0762c2e81d 100644 --- a/frontend/editor/src/core/types/toolId.ts +++ b/frontend/editor/src/core/types/toolId.ts @@ -50,6 +50,7 @@ export const CORE_REGULAR_TOOL_IDS = [ "removeCertSign", "unlockPDFForms", "compress", + "classify", "extractPages", "reorganizePages", "extractImages", diff --git a/frontend/editor/src/core/utils/toolSearch.ts b/frontend/editor/src/core/utils/toolSearch.ts index 41b40135a5..1a543974a3 100644 --- a/frontend/editor/src/core/utils/toolSearch.ts +++ b/frontend/editor/src/core/utils/toolSearch.ts @@ -15,7 +15,11 @@ export function filterToolRegistryByQuery( toolRegistry: Partial, query: string, ): RankedToolItem[] { - const entries = Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]; + // The single funnel into the editor's tool list, so hiding here hides it everywhere the user + // browses - while getExecutableTools still offers it to a pipeline. + const entries = ( + Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][] + ).filter(([, tool]) => !tool?.hiddenFromToolList); if (!query.trim()) { return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry], diff --git a/frontend/editor/src/global.d.ts b/frontend/editor/src/global.d.ts index cb6275c471..6b38e511b4 100644 --- a/frontend/editor/src/global.d.ts +++ b/frontend/editor/src/global.d.ts @@ -22,6 +22,7 @@ declare global { __STIRLING_PDF_BASE_URL__?: string; STIRLING_PDF_API_BASE_URL?: string; endpointAvailabilityService?: unknown; + pdfjsLib?: typeof import("pdfjs-dist"); } } diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx index 2925958efd..75da086806 100644 --- a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx +++ b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx @@ -215,22 +215,19 @@ const BIG_MEMBERS: Member[] = [ isSelf: true, portalAccess: "admin", }, - ...Array.from( - { length: 11 }, - (_, i): Member => ({ - id: `big-${i}`, - name: `Teammate ${i + 1}`, - email: `teammate${i + 1}@acme.com`, - role: i === 0 ? "team_owner" : "member", - status: "active", - lastActive: `${i + 1}h ago`, - username: `tm${i + 1}`, - teamId: 9, - teamName: "Platform", - teamLead: i === 0, - portalAccess: i === 0 ? "role" : "none", - }), - ), + ...Array.from({ length: 11 }, (_, i): Member => ({ + id: `big-${i}`, + name: `Teammate ${i + 1}`, + email: `teammate${i + 1}@acme.com`, + role: i === 0 ? "team_owner" : "member", + status: "active", + lastActive: `${i + 1}h ago`, + username: `tm${i + 1}`, + teamId: 9, + teamName: "Platform", + teamLead: i === 0, + portalAccess: i === 0 ? "role" : "none", + })), ]; const TEAMS: Team[] = [ diff --git a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts index aac4b7e3da..64fd849d87 100644 --- a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts +++ b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts @@ -1,5 +1,5 @@ -// With the AI engine off, the Classification policy runs here in the browser: -// each upload is labelled by the heuristic engine and metered for billing parity. +// The Classification policy's first pass: every upload is labelled locally before the AI is asked. +// The confidence reported here decides whether the AI is asked at all - see usePolicyAutoRun. import { useEffect, useRef, useState } from "react"; import { useAllFiles, useFileManagement } from "@app/contexts/FileContext"; @@ -7,7 +7,6 @@ import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import { fileStorage } from "@app/services/fileStorage"; import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled"; -import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled"; import { scheduleIdle } from "@app/utils/scheduleIdle"; import { usePolicies } from "@app/hooks/usePolicies"; import { classifyFileHeuristically } from "@app/services/heuristic/heuristicClassification"; @@ -15,12 +14,14 @@ import { meterClassificationRun } from "@app/services/classificationMeter"; import { isDispatched, markDispatched, + recordRunStart, + updateRun, } from "@app/components/policies/policyRunStore"; import type { FileId } from "@app/types/file"; import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; +import type { HeuristicConfidence } from "@app/services/heuristic/types"; +import { CLASSIFICATION_CATEGORY_ID } from "@app/data/classificationPolicy"; -/** The category id of the Classification policy (see policyDefinitions). */ -const CLASSIFICATION_CATEGORY = "classification"; /** Files classified per idle pass, so a large library drains over several ticks. */ const CLASSIFY_BATCH = 3; /** How long to wait for an upload's bytes to land in IndexedDB (20 × 250ms ≈ 5s). @@ -47,9 +48,8 @@ export function useClientSideClassification(): void { const { bumpRevision } = useIndexedDB(); const { policies } = usePolicies(); const classificationEnabled = useClassificationEnabled(); - const aiEnabled = useAiEngineEnabled(); - // While app-config loads, aiEnabled reads false even on AI-on tenants; classifying - // in that window would double-run (and double-bill) files the server also labels. + // Still waited on: a verdict written before app-config lands would be acted on by the + // escalation decision before it knows whether the AI engine is even available. const { loading: configLoading } = useAppConfig(); // Files claimed this session, keyed id+lastModified so a new version is retried once. A claim is // taken synchronously right before classifying, so overlapping batches never double-classify. @@ -57,7 +57,9 @@ export function useClientSideClassification(): void { // Bumped after each batch to drain the next one. const [tick, setTick] = useState(0); - const policy = policies[CLASSIFICATION_CATEGORY]; + // TODO: keyed on the Classification CATEGORY, so a pipeline that merely contains a classify + // step gets no local pass - suppressing one step of a chain is not expressible today. + const policy = policies[CLASSIFICATION_CATEGORY_ID]; // Only when the admin has an active Classification policy - the same gate the AI path uses. const active = Boolean( policy?.configured && @@ -69,7 +71,8 @@ export function useClientSideClassification(): void { ); useEffect(() => { - if (configLoading || !classificationEnabled || aiEnabled || !active) { + // Runs whether or not the AI engine is on: it is the first pass either way, not a fallback. + if (configLoading || !classificationEnabled || !active) { return; } const claimKey = (s: StirlingFileStub) => @@ -95,17 +98,23 @@ export function useClientSideClassification(): void { // Re-validate at execution time - another batch may have claimed it since. if (claimed.current.has(key)) continue; claimed.current.add(key); - const labels = await classifyStub(stub.id as FileId, stub.name); + const verdict = await classifyStub( + stub.id as FileId, + stub.name, + stub.size ?? 0, + ); // Bytes never landed (file removed mid-wait): leave undelivered so a // reload (or new version) retries; the claim stops churn this session. - if (labels == null) continue; + if (verdict == null) continue; // Deliver unconditionally - a re-render must never discard a computed // (and already metered) result. Writes are idempotent. updateStirlingFileStub(stub.id as FileId, { - classificationLabels: labels, + classificationLabels: verdict.labels, + classificationConfidence: verdict.confidence, }); const ok = await fileStorage.updateFileMetadata(stub.id as FileId, { - classificationLabels: labels, + classificationLabels: verdict.labels, + classificationConfidence: verdict.confidence, }); if (ok) wrote = true; } @@ -122,7 +131,6 @@ export function useClientSideClassification(): void { fileStubs, active, classificationEnabled, - aiEnabled, configLoading, updateStirlingFileStub, bumpRevision, @@ -134,7 +142,8 @@ export function useClientSideClassification(): void { async function classifyStub( fileId: FileId, fileName: string, -): Promise { + fileSize: number, +): Promise<{ labels: string[]; confidence: HeuristicConfidence } | null> { let file: StirlingFile | null = null; for (let i = 0; i < FILE_WAIT_TRIES; i++) { file = await fileStorage.getStirlingFile(fileId).catch(() => null); @@ -149,10 +158,28 @@ async function classifyStub( } const debug = isClassificationDebug(); const startedAt = performance.now(); + // A local run is still a billable policy run, so it belongs in the activity feed; recorded only + // once the bytes are in hand, so a file whose bytes never land leaves no phantom row. + + // Read before recordRunStart, which takes the dispatch key itself and would otherwise always + // answer "already dispatched", silently stopping metering. + const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY_ID, fileId); + const runId = `local-${CLASSIFICATION_CATEGORY_ID}-${fileId}-${Date.now()}`; + recordRunStart({ + runId, + categoryId: CLASSIFICATION_CATEGORY_ID, + fileId: fileId as string, + fileName, + fileSize, + target: "local", + status: "RUNNING", + outputs: [], + error: null, + startedAt: Date.now(), + }); try { const result = await classifyFileHeuristically(file, { explain: debug }); const { labels } = result; - const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY, fileId); const ms = Math.round(performance.now() - startedAt); const verdict = labels.length > 0 @@ -174,12 +201,22 @@ async function classifyStub( labels, }); } - markDispatched(CLASSIFICATION_CATEGORY, fileId); - return labels; + markDispatched(CLASSIFICATION_CATEGORY_ID, fileId); + // Labels, no output file - the same settle shape the server-run classification uses. + updateRun(runId, { + status: "COMPLETED", + imported: true, + outputFileIds: [fileId as string], + }); + return { labels, confidence: result.confidence }; } catch (err) { // Never persist a verdict for an unreadable file - the failure may be // environmental, so it must stay eligible to retry (and meter) later. console.warn(`[Classify] ${fileName}: could not be read, will retry`, err); + updateRun(runId, { + status: "FAILED", + error: err instanceof Error ? err.message : String(err), + }); return null; } } diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx index bc4fa54dff..08768f1aa7 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; +import type { ClassificationConfidence } from "@app/types/fileContext"; /** * Batch integration test (61 files, two chained upload policies) driving the real @@ -12,7 +13,11 @@ const FILE_COUNT = 61; // the workbench, mirrored into useAllFiles. consumeFiles mutates it in place // (input id → output id) exactly as the real silent reducer would. const mocks = vi.hoisted(() => ({ - workspace: [] as Array<{ id: string; classificationLabels?: string[] }>, + workspace: [] as Array<{ + id: string; + classificationLabels?: string[]; + classificationConfidence?: ClassificationConfidence; + }>, consumeSilentCalls: 0, consumeNonSilentCalls: 0, persistCalls: 0, @@ -117,10 +122,20 @@ function Harness() { return null; } +/** The heuristic verdict that escalates to the AI classifier; only "high" stands alone. */ +const LOW = "low" as const; + function replaceInWorkspace(inputIds: string[], outputIds: string[]) { + // A versioned output carries its input's heuristic verdict; the escalation decision is about the + // document, not about which step produced the current bytes. + const inherited = + mocks.workspace.find((s) => inputIds.includes(s.id)) + ?.classificationConfidence ?? LOW; mocks.workspace = mocks.workspace .filter((s) => !inputIds.includes(s.id)) - .concat(outputIds.map((id) => ({ id }))); + .concat( + outputIds.map((id) => ({ id, classificationConfidence: inherited })), + ); } beforeEach(() => { @@ -138,6 +153,7 @@ beforeEach(() => { mocks.workspace = Array.from({ length: FILE_COUNT }, (_, i) => ({ id: `file-${i}`, + classificationConfidence: LOW, })); mocks.listPolicyRuns.mockResolvedValue([]); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx index b67648e7fc..84b6f58bf7 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; +import type { ClassificationConfidence } from "@app/types/fileContext"; /** * Mid-run race: classification is in flight (its labelled output is still @@ -18,6 +19,7 @@ const mocks = vi.hoisted(() => ({ sourceFileIds?: string[]; derivedFromTool?: boolean; classificationLabels?: string[]; + classificationConfidence?: ClassificationConfidence; }>, runStoredPolicy: vi.fn(), getPolicyRun: vi.fn(), @@ -114,7 +116,7 @@ beforeEach(() => { resetPolicyRuns(); vi.clearAllMocks(); - mocks.workspace = [{ id: "file-0" }]; + mocks.workspace = [{ id: "file-0", classificationConfidence: "low" }]; mocks.listPolicyRuns.mockResolvedValue([]); mocks.getStirlingFile.mockResolvedValue( diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index aeda0f0c25..936fef8119 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -1,18 +1,6 @@ /** - * Auto-run controller: every enabled policy enforces on every uploaded file. - * Watches the session's files and fires a real backend run - * (`POST /api/v1/policies/{id}/run`) per file, polling it to completion and - * recording progress in {@link policyRunStore} for the activity feed. - * - * When several policies enforce on the same trigger they run as an ordered chain: - * the first fires on the upload, and each subsequent policy fires on the previous - * one's output once it lands — so their effects accumulate in the admin-defined - * order rather than racing to fork the same version. - * - * Headless — call it from {@link PolicyAutoRunController}, which is mounted once - * wherever the editor is open so enforcement happens regardless of whether the - * policy panel is on screen. Each (policy, file) pair runs exactly once (tracked - * in the run store), so re-renders and remounts don't re-fire. + * Headless auto-run controller: one backend run per (policy, file), fired exactly once and polled. + * Policies sharing a trigger run as an ordered chain so their effects accumulate. */ import { useCallback, useEffect, useMemo, useRef } from "react"; @@ -40,7 +28,12 @@ import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge"; import type { FileId } from "@app/types/file"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { readClassificationLabelsFromFile } from "@app/services/fileClassification"; -import { isClassificationCategory } from "@app/data/policyCategories"; +import { + policyDeliversOutputFiles, + policyRequiresAiEngine, + policyRewritesDocument, + shouldDispatchToAi, +} from "@app/data/classificationPolicy"; import { acquireDispatchSlot, releaseDispatchSlot, @@ -69,10 +62,8 @@ const POLL_MS = 2000; * sitting on an indeterminate spinner for a full poll interval. */ const FIRST_POLL_MS = 500; -/** The server aborts any single tool step that runs longer than its internal-API - * read timeout, then fails the run — so a run can legitimately stay in flight - * for up to this long per step. The client must keep polling at least that long, - * or it abandons a run the server is still working on (which reads as a hang). */ +/** Server's per-step abort budget - poll at least this long per step, or we abandon + * a run the server is still working on. */ const STEP_TIMEOUT_MS = 300_000; /** Slack on top of the per-step budget: queueing before the first step starts and @@ -92,11 +83,8 @@ const POLICY_QUEUE_FULL = "POLICY_QUEUE_FULL"; const MAX_QUEUE_RETRIES = 5; const QUEUE_RETRY_BASE_MS = 4000; -/** Consecutive "run not found" responses before giving up. The run state lives - * in memory on the server, so a restart or a second instance behind the load - * balancer makes a live run's status return 404 — and it won't come back. We - * tolerate a brief blip (e.g. a poll racing a just-dispatched run, or one hop - * to an instance that hasn't seen it) then fail, rather than polling forever. */ +/** Consecutive 404s before failing. Run state is in-memory server-side, so a restart + * or a hop to another instance loses it permanently - tolerate a blip, not forever. */ const MAX_NOT_FOUND = 3; /** A 404 (run status gone, or output file gone), across the web (axios) and @@ -119,24 +107,20 @@ function failRun(runId: string, message: string): void { updateRun(runId, { status: "FAILED", error: message, errorCode: null }); } -/** How long to wait for an upload's bytes to land in IndexedDB before giving up - * (20 × 250ms ≈ 5s). The stub can surface in the file list a beat before its - * bytes are committed, so a too-eager fetch would otherwise miss the file. */ +/** Wait for an upload's bytes to land in IndexedDB (~5s): the stub surfaces in the + * file list before its bytes are committed, so an eager fetch would miss the file. */ const FILE_WAIT_TRIES = 20; const FILE_WAIT_MS = 250; -/** - * A policy that changed nothing (redaction matched no text, say) completes with no - * output: nothing to deliver, but finished. Left unimported, the file's badge and - * its blocking overlay spin forever. - */ +/** A policy that changed nothing completes with no output; left unimported its badge + * and blocking overlay spin forever. */ export function finishedWithNothingToDeliver(run: PolicyRunRecord): boolean { return ( run.status === "COMPLETED" && !run.imported && (run.outputs?.length ?? 0) === 0 && - // Classification has its own settle path: labels, no output file. - !isClassificationCategory(run.categoryId) + // An annotating policy settles on labels, not an output file. + policyDeliversOutputFiles(run.categoryId) ); } @@ -156,34 +140,23 @@ export function usePolicyAutoRun(): void { const { policies } = usePolicies(); const aiEnabled = useAiEngineEnabled(); const runs = usePolicyRuns(); - // Live view of the workspace files, read inside the import effect WITHOUT making - // it a dependency. The silent consume that delivers an output mutates fileStubs, - // so if the import effect depended on fileStubs it would re-fire on its own - // delivery — an infinite import cascade (and a bumpRevision storm that trips - // React's max-update-depth). The effect only needs to fire when `runs` changes. + // Read in the import effect via ref, not as a dependency: delivery mutates fileStubs, + // so depending on them would re-fire the effect on its own delivery (infinite cascade). const fileStubsRef = useRef(fileStubs); fileStubsRef.current = fileStubs; - // Keys (run ids / dispatch keys) currently in flight, so the effects never - // double-fire across re-renders while their first async step is pending. + // Keys in flight, so effects never double-fire across re-renders while async work pends. const polling = useRef>(new Set()); const importing = useRef>(new Set()); const dispatching = useRef>(new Set()); // Reconcile against the backend exactly once per mount. const reconciled = useRef(false); - // A policy's tool calls run server-side, so a usage-limit 402 never reaches the apiClient - // interceptor (and thus never pops the modal that direct calls get). The backend surfaces the - // limit sentinel on the run's errorCode; when a run we polled finishes blocked, broadcast a - // window event. A saas-layer listener (which can read the wallet + open the modal — this - // proprietary hook can't import the saas modal API) decides free-limit vs spend-cap. Dedupe per - // run so a folder-watch burst opens the modal once, not once per file. + // Server-side runs never hit the apiClient 402 interceptor, so we broadcast the limit + // sentinel for a saas listener to open the modal. Deduped per run. const firedLimitModal = useRef>(new Set()); - // Active upload policies in execution order. When several enforce on upload they - // run as a chain — the first fires on the upload, each subsequent one on the - // previous policy's output — so their effects accumulate in a defined order - // instead of racing to fork the same version. Mirrors the dispatch filter - // (incl. the editor-source gate) so the chain honours the same eligibility. + // Active upload policies in chain order, so effects accumulate instead of racing to fork + // the same version. Mirrors the dispatch filter so the chain honours the same eligibility. const orderedUploadCategories = useMemo( () => Object.entries(policies) @@ -196,41 +169,40 @@ export function usePolicyAutoRun(): void { s.sources.length === 0 || s.sources.includes("editor")) && (s.runOn ?? "upload") === "upload" && - // Non-AI systems classify in the browser (useClientSideClassification), so keep the - // Classification policy out of the server chain when the AI engine is off. - !(id === "classification" && !aiEnabled), + // An escalation-only policy has nothing to do with no engine to escalate to. + !(policyRequiresAiEngine(id) && !aiEnabled), ) - // Classification runs last: it's non-blocking, so an enforcement policy - // running after it would fork a new version and drop the user's edits. + // Annotating policies run last: a rewriting one after them would fork a new + // version from the pre-annotation state and drop their labels. .sort(([idA, a], [idB, b]) => { - const ca = isClassificationCategory(idA) ? 1 : 0; - const cb = isClassificationCategory(idB) ? 1 : 0; - if (ca !== cb) return ca - cb; + const ra = policyRewritesDocument(idA) ? 0 : 1; + const rb = policyRewritesDocument(idB) ? 0 : 1; + if (ra !== rb) return ra - rb; return (a.order ?? 0) - (b.order ?? 0); }) .map(([id]) => id), [policies, aiEnabled], ); - // Runs whose chain-continuation we've already handled this session, so the next - // policy is dispatched exactly once per completed run. + // Chain-continuations handled this session, so the next policy fires once per run. const chained = useRef>(new Set()); // Latest policies, read from inside the stable retry callback (which has no deps). const policiesRef = useRef(policies); policiesRef.current = policies; + // Latest stubs for the chaining effect, which keys off runs and must not depend on stubs. + const stubsRef = useRef(fileStubs); + stubsRef.current = fileStubs; // Per-file (dispatchKey) count of consecutive queue-rejection retries, so backoff escalates and // eventually gives up. Survives the run-id changing on each retry; reset on any real outcome. const queueRetries = useRef>(new Map()); - // A queue-rejected run is just backpressure — drop the rejected record and fire a fresh run in - // its place after a growing backoff (one feed row, not a new one per attempt). Once the budget is - // spent, leave the last failure standing so the activity feed offers a manual Retry. + // Queue rejection is backpressure: replace the record with a fresh run after a backoff, so the + // feed keeps one row. Budget spent, leave the failure standing for a manual Retry. const scheduleQueueRetry = useCallback((runId: string) => { const rec = getRun(runId); if (!rec) return; - // A run rediscovered from the server (reconciled) has no local input fileId, so it can't be - // re-dispatched; leave it failed rather than spinning on a file we can't resolve. + // A reconciled run has no local fileId to re-dispatch; leave it failed. if (!rec.fileId) return; const key = dispatchKey(rec.categoryId, rec.fileId); const attempts = queueRetries.current.get(key) ?? 0; @@ -281,42 +253,38 @@ export function usePolicyAutoRun(): void { [scheduleQueueRetry], ); - // Dispatch: fire only the FIRST upload policy on each not-yet-run file. The rest - // of the chain is dispatched by the chaining effect below, each on the previous - // policy's output, so the policies apply cumulatively in order. + // Fire only the FIRST upload policy per file; the chaining effect below runs the rest + // on each previous output, so policies apply cumulatively in order. useEffect(() => { const firstCategory = orderedUploadCategories[0]; if (!firstCategory) return; const backendId = policies[firstCategory]?.backendId; if (!backendId) return; for (const stub of fileStubs) { - // Input-mode policies enforce only on files that actually entered the - // system as an upload — not on files a tool/automation produced in-app - // (versioned edits or independent artifacts like convert/split/merge). - // Those are enforced only by export-mode policies, at export time. + // Input-mode policies cover uploads only; tool-produced files are left to + // export-mode policies at export time. if (stub.derivedFromTool) continue; const key = dispatchKey(firstCategory, stub.id); - // Skip if already run (persisted) or a dispatch is in flight — the - // in-memory guard prevents double-firing during the async wait. + // Skip if already run (persisted) or in flight - the in-memory guard covers the async wait. if ( isDispatched(firstCategory, stub.id) || dispatching.current.has(key) ) { continue; } + // A confident local verdict stands; only an unsure one is escalated to the engine. + if (!shouldDispatchToAi(firstCategory, stub)) continue; dispatching.current.add(key); void runPolicyOnFile(firstCategory, backendId, stub.id, stub.name) .catch(() => { - // runPolicyOnFile handles its own failures; this is just a backstop - // so an unexpected rejection never becomes an unhandled rejection. + // Backstop: runPolicyOnFile handles its own failures. }) .finally(() => dispatching.current.delete(key)); } }, [fileStubs, policies, orderedUploadCategories]); - // Chain: once a run has completed AND its output landed in the workspace, fire the - // next upload policy on that output. Only chains on success (a failed run has no - // output), and only once per run. isDispatched guards re-dispatch across reloads. + // Once a run's output lands, fire the next upload policy on it - success only, once per + // run. isDispatched guards re-dispatch across reloads. useEffect(() => { for (const run of runs) { if (run.status !== "COMPLETED" || !run.imported) continue; @@ -335,10 +303,15 @@ export function usePolicyAutoRun(): void { // Next policy not ready yet (still reconciling) — retry when policies change. if (!backendId) continue; chained.current.add(run.runId); - // Chain onto EVERY output, not just the first — a run that produced multiple files (split, - // ZIP-unpacked) must apply the next policy to all of them, or outputs 2..N silently skip it. + // Chain onto EVERY output: a run that produced several files (split, ZIP-unpacked) + // would otherwise silently skip the next policy on outputs 2..N. for (const outputId of outputIds) { if (isDispatched(nextCategory, outputId as FileId)) continue; + const outputStub = stubsRef.current.find((s) => s.id === outputId); + // Nothing to escalate: either the heuristic already answered confidently, or it has + // not reported yet and this effect re-runs when the verdict lands. + if (outputStub && !shouldDispatchToAi(nextCategory, outputStub)) + continue; void runPolicyOnFile( nextCategory, backendId, @@ -348,7 +321,7 @@ export function usePolicyAutoRun(): void { ).catch(() => {}); } } - }, [runs, policies, orderedUploadCategories]); + }, [runs, policies, orderedUploadCategories, fileStubs]); // Poll each in-flight run to a terminal state. useEffect(() => { @@ -361,11 +334,10 @@ export function usePolicyAutoRun(): void { } }, [runs, onRunFinished]); - // Import each completed run's outputs into the workspace (each output once), - // so the enforced file appears in the app rather than only on the backend. + // Import each completed run's outputs once, so the enforced file appears in the app. useEffect(() => { for (const run of runs) { - const classification = isClassificationCategory(run.categoryId); + const deliversFiles = policyDeliversOutputFiles(run.categoryId); if ( run.status !== "COMPLETED" || run.imported || @@ -378,13 +350,10 @@ export function usePolicyAutoRun(): void { continue; } importing.current.add(run.runId); - // Classification is metadata-only: stamp labels onto the current leaf of - // the file it ran on (no version fork). See importClassificationLabels. - if (classification) { - // Targets are resolved by importClassificationLabels AT WRITE TIME (not - // snapshotted here): its download/parse is an async window during which - // a manual tool run can consume the input and fork a new leaf, and a - // stale snapshot would no-op on the dead id and lose the labels. + // An annotating policy writes labels onto the current leaf; no version fork. + if (!deliversFiles) { + // Resolved at write time, not snapshotted: a tool run during the async parse can fork + // a new leaf, and a stale id would no-op and lose the labels. void importClassificationLabels( run, () => @@ -393,8 +362,7 @@ export function usePolicyAutoRun(): void { ).finally(() => importing.current.delete(run.runId)); continue; } - // Honour the policy's output mode: a new file, or a new version of the - // input file it ran on (needs that input's stub, still in the workspace). + // Output mode: a new file, or a new version of the input (needs its stub in the workspace). const outputMode = policies[run.categoryId]?.outputMode ?? "new_version"; const outputName = policies[run.categoryId]?.outputName ?? ""; const outputNamePosition = policies[run.categoryId]?.outputNamePosition; @@ -413,9 +381,8 @@ export function usePolicyAutoRun(): void { firstUploadCategory: orderedUploadCategories[0], }).finally(() => importing.current.delete(run.runId)); } - // NB: fileStubs is intentionally NOT a dependency — it's read via a ref so a - // delivery's own workspace mutation can't re-trigger this effect (see the ref - // declaration above). The effect fires on run completions, which is all it needs. + // NB: fileStubs is read via a ref, not a dependency, so a delivery's own workspace + // mutation can't re-trigger this effect. }, [ runs, addFiles, @@ -425,11 +392,8 @@ export function usePolicyAutoRun(): void { orderedUploadCategories, ]); - // Reconcile against the backend on load. The server owns runs (durable, user-scoped), - // so a run started before this client recorded it, or before a refresh/crash, is - // rediscovered here; the poll + import effects above then collect its outputs rather - // than leaving them orphaned. Waits until policies are known so server runs can be - // attributed to their category. + // The server owns runs, so rediscover any this client never recorded and let the effects + // above collect their outputs. Waits for policies so runs can be attributed to a category. useEffect(() => { if (reconciled.current) return; if (Object.keys(policies).length === 0) return; @@ -460,23 +424,18 @@ interface ImportContext { outputMode: "new_file" | "new_version"; /** Rename rule. Empty → keep the input's filename. */ outputName: string; - /** Where the rename is applied: before ("prefix") or after ("suffix") the - * base filename. Defaults to "suffix" when absent. */ + /** Rename position around the base filename; defaults to "suffix" when absent. */ outputNamePosition?: "prefix" | "suffix" | "auto-number"; /** The input file's stub — required to version it; absent if it's been removed. */ parentStub: StirlingFileStub | undefined; - /** The first upload policy in the chain — the only one the dispatch effect ever - * fires. Every policy output is marked dispatched for it so a downstream policy's - * output is never mistaken for a fresh upload and re-enforced (an endless loop). */ + /** The only policy the dispatch effect fires; every output is marked dispatched for it + * so a downstream output is never mistaken for a fresh upload and re-enforced. */ firstUploadCategory: string | undefined; } /** - * Pull the caller's server-side runs and fold them into the local store. For a run we already - * track, patch its status/outputs (preserving local import progress + attribution); for one we - * don't, adopt it for feed visibility (polled if still live, but never auto-imported — see the - * `imported` note below). Server-excluded ad-hoc runs and runs we can't map to a configured - * category are skipped. + * Fold server-side runs into the local store: patch tracked ones, adopt untracked ones for feed + * visibility only. Unmappable and ad-hoc runs are skipped. */ function applyOutputName( inputFileName: string, @@ -492,8 +451,7 @@ function applyOutputName( : `${base}_${outputName}${ext}`; } -/** The next upload policy after {@code categoryId} in the chain, or undefined if - * it's last or no longer in the ordered set (e.g. paused since it ran). */ +/** Next upload policy in the chain, or undefined if last or no longer eligible. */ function nextUploadCategory( orderedUploadCategories: string[], categoryId: string, @@ -525,13 +483,11 @@ async function reconcileServerRuns( addReconciledRun({ runId: view.runId, categoryId, - // No local input link: a run rediscovered purely from the server was never recorded by - // this client, so it can't be tied back to a workspace/storage file (and isn't retried). + // Server-only run: never recorded here, so it can't be tied to a file (and isn't retried). fileId: "", fileName: view.outputs[0]?.fileName ?? "", fileSize: 0, - // Rediscovered from the SaaS run registry (listPolicyRuns), so its outputs - // live on the cloud backend. + // From the SaaS run registry, so its outputs live on the cloud backend. target: "saas", status: view.status, outputs: view.outputs, @@ -563,10 +519,8 @@ interface ClassificationImportContext { bumpRevision: () => void; } -/** Workspace stubs to tag with a classification run's labels: the file it ran - * on plus any live descendants, so an edit made during the async run (which - * forks a new leaf) still shows the tags. Empty once the document has left the - * workspace (closed, or a reconciled run with no local input link). */ +/** The run's file plus live descendants, so an edit during the run (which forks a new leaf) + * still shows the tags. Empty once the document has left the workspace. */ export function classificationLabelTargetStubs( runFileId: string, stubs: ReadonlyArray, @@ -579,19 +533,14 @@ export function classificationLabelTargetStubs( ); } -/** Attempts to read a completed run's labels before giving up, and the backoff - * between them (delay × attempt). The import effect only re-runs when the run - * store changes, so a transient read failure has to be retried HERE: bailing - * out would leave the run unsettled and the file's "running" pill spinning - * until unrelated policy activity happened to nudge the effect. */ +/** Label-read attempts and backoff. Retried HERE because the import effect only re-runs on + * run-store changes, so bailing out would leave the file's "running" pill spinning. */ const LABEL_READ_ATTEMPTS = 3; const LABEL_READ_RETRY_MS = 2000; /** - * Read classification labels out of a completed run's output PDF. A 404 means - * that output aged out, so it's skipped; any other failure is transient and - * retried with backoff. Returns null when there are genuinely no labels to - * apply (including a run with no outputs), so the caller can settle the run. + * Read labels from a completed run's output PDF: a 404 means it aged out and is skipped, other + * failures retry with backoff. Null means no labels to apply, so the caller settles the run. */ async function readRunLabels(run: PolicyRunRecord): Promise { for (let attempt = 0; attempt < LABEL_READ_ATTEMPTS; attempt++) { @@ -612,21 +561,13 @@ async function readRunLabels(run: PolicyRunRecord): Promise { // Every output was read (or had aged out): there are no labels to apply. if (!transientFailure) return null; } - // Out of attempts. Settle the run unlabelled rather than spin forever; the - // file keeps its classification badge, just without tags. + // Out of attempts: settle unlabelled rather than spin forever - the badge stays, tags don't. return null; } /** - * Stamp `labels` onto the run's live descendants in place (workspace + storage) - * — no versioned child, no history entry, only tags. Returns the tagged ids. - * - * Runs twice, because `resolveTargets` reads a rendered snapshot of the - * workspace: a CONSUME_FILES that was dispatched but not yet rendered when the - * first pass ran leaves its target already gone by the time UPDATE_FILE_RECORD - * is processed, so that stamp no-ops and the labels would be silently lost. The - * second pass sees the forked leaf and tags it. Each id is stamped at most once - * across both passes, so the pass costs nothing when no consume raced. + * Stamp `labels` in place (workspace + storage) - tags only, no versioned child. Two passes + * because a consume racing the first would strand its target and lose the labels. */ async function stampClassificationLabels( labels: string[], @@ -637,10 +578,8 @@ async function stampClassificationLabels( const tagged = new Set(); for (let pass = 0; pass < 2; pass++) { - // Resolve and stamp the store in one synchronous block — no await between - // them, so a target can't be consumed in between. A consume AFTER the stamp - // is safe too: the CONSUME_FILES reducer carries classificationLabels onto - // the new leaf. + // Resolve and stamp synchronously so no consume lands in between; a consume after the + // stamp is safe, as the reducer carries the labels onto the new leaf. const fresh = resolveTargets().filter((s) => !tagged.has(s.id)); for (const stub of fresh) { tagged.add(stub.id); @@ -654,25 +593,20 @@ async function stampClassificationLabels( } if (mutated) ctx.bumpRevision(); - // Yield a macrotask so React processes this pass's stamps (and any consume - // that raced them) before the next pass re-resolves. + // Yield a macrotask so React processes this pass's stamps before the next re-resolves. if (pass === 0) await new Promise((resolve) => setTimeout(resolve)); } return Array.from(tagged); } -/** - * Deliver a classification run: read its labels and tag the live document with - * them. Metadata-only — nothing is versioned. - */ +/** Deliver a classification run: read its labels and tag the live document. Nothing is versioned. */ async function importClassificationLabels( run: PolicyRunRecord, resolveTargets: () => StirlingFileStub[], ctx: ClassificationImportContext, ): Promise { if (resolveTargets().length === 0) { - // The document left the workspace (closed, or a server-reconciled run with - // no local input link) — nothing to tag. + // The document left the workspace - nothing to tag. updateRun(run.runId, { imported: true }); return; } @@ -681,9 +615,8 @@ async function importClassificationLabels( labels && labels.length > 0 ? await stampClassificationLabels(labels, resolveTargets, ctx) : []; - // Settle either way so it stops re-importing. outputFileIds are the TAGGED - // workspace files (no forked version), so their policy badge persists. Safe - // to chain-key on: classification is always last, so nothing chains off it. + // Settle either way so it stops re-importing. outputFileIds are the TAGGED files, so their + // badge persists; safe to chain-key on, as classification is always last. updateRun(run.runId, { imported: true, importedFileIds: run.outputs.map((o) => o.fileId), @@ -692,16 +625,8 @@ async function importClassificationLabels( } /** - * Fetch a completed run's not-yet-imported output files and deliver them to the - * workspace. Per-output, via allSettled: each output is tracked once delivered, - * so a partial failure retries only the missing files on a later tick and the - * ones that succeeded are never added twice. `imported` flips true only once - * every output has landed. - * - * Delivery honours the policy's output mode: "new_version" replaces the input - * file with a versioned child (its history chain), "new_file" adds the output - * as a standalone file. Versioning falls back to a new file if the input is - * gone (no parent stub). + * Deliver a run's outputs per-output, so a partial failure retries only the missing files and + * successes are never added twice. Honours the output mode; versioning needs the parent stub. */ async function importOutputs( run: PolicyRunRecord, @@ -714,9 +639,8 @@ async function importOutputs( return; } - // Keep the input's original filename unless a rename rule is set — without a - // rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would - // otherwise rename every output. + // Keep the input's filename unless a rename rule is set, else the backend's auto-suffixed + // name renames every output. const targetName = ctx.outputName ? applyOutputName( run.fileName, diff --git a/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.tsx b/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.tsx index 89d648fcc5..a482b3fab1 100644 --- a/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.tsx +++ b/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.tsx @@ -38,7 +38,7 @@ function generateSecurePassword() { const uint8Array = new Uint8Array(length); window.crypto.getRandomValues(uint8Array); // To avoid modulo bias, discard values >= 256 - (256 % charsetLength) - for (let i = 0; password.length < length; ) { + for (let i = 0; password.length < length;) { const randomByte = uint8Array[i]; i++; if (randomByte >= Math.floor(256 / charsetLength) * charsetLength) { diff --git a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx index 1f04ea22ca..d5a2e7537d 100644 --- a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx +++ b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx @@ -8,7 +8,7 @@ import { usePolicyRuns, type PolicyRunRecord, } from "@app/components/policies/policyRunStore"; -import { isClassificationCategory } from "@app/data/policyCategories"; +import { isClassificationCategory } from "@app/data/classificationPolicy"; import { PolicyEnforcementOverlay } from "@app/components/viewer/PolicyEnforcementOverlay"; type SignatureOverlayPassThrough = Pick< diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.test.ts b/frontend/editor/src/proprietary/data/classificationPolicy.test.ts new file mode 100644 index 0000000000..d3cb55c043 --- /dev/null +++ b/frontend/editor/src/proprietary/data/classificationPolicy.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { + isClassificationCategory, + orderRewritesFirst, + policyDeliversOutputFiles, + policyRequiresAiEngine, + policyRewritesDocument, + shouldDispatchToAi, +} from "@app/data/classificationPolicy"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +const stub = ( + confidence?: StirlingFileStub["classificationConfidence"], +): StirlingFileStub => + ({ classificationConfidence: confidence }) as StirlingFileStub; + +describe("isClassificationCategory", () => { + it("recognises the classification category and nothing else", () => { + expect(isClassificationCategory("classification")).toBe(true); + expect(isClassificationCategory("security")).toBe(false); + expect(isClassificationCategory("")).toBe(false); + }); +}); + +describe("policy capabilities", () => { + it("treats classification as annotating, everything else as rewriting", () => { + expect(policyRewritesDocument("security")).toBe(true); + expect(policyRewritesDocument("classification")).toBe(false); + // A builder pipeline (no catalogue category) runs tools, so it rewrites. + expect(policyRewritesDocument("pipeline-abc123")).toBe(true); + }); + + it("expects output files from rewriting policies only", () => { + expect(policyDeliversOutputFiles("security")).toBe(true); + expect(policyDeliversOutputFiles("classification")).toBe(false); + }); + + it("marks classification as the AI-escalation policy", () => { + expect(policyRequiresAiEngine("classification")).toBe(true); + expect(policyRequiresAiEngine("security")).toBe(false); + }); +}); + +describe("orderRewritesFirst", () => { + it("moves annotating policies to the end, preserving other order", () => { + expect( + orderRewritesFirst(["classification", "security", "compliance"]), + ).toEqual(["security", "compliance", "classification"]); + }); + + it("leaves an order without an annotating policy untouched", () => { + expect(orderRewritesFirst(["security", "compliance"])).toEqual([ + "security", + "compliance", + ]); + }); + + it("is a no-op when the annotating policy is already last", () => { + expect(orderRewritesFirst(["security", "classification"])).toEqual([ + "security", + "classification", + ]); + }); + + it("handles the annotating policy as the only one", () => { + expect(orderRewritesFirst(["classification"])).toEqual(["classification"]); + }); +}); + +describe("shouldDispatchToAi", () => { + it("always dispatches a policy that is not classification", () => { + expect(shouldDispatchToAi("security", stub())).toBe(true); + expect(shouldDispatchToAi("security", stub("high"))).toBe(true); + }); + + it("holds back until the local heuristic has reported", () => { + // Not a skip: dispatching now races the local pass and pays for a free answer; + // the caller re-evaluates once the verdict lands. + expect(shouldDispatchToAi("classification", stub())).toBe(false); + }); + + it("lets a confident local verdict stand", () => { + expect(shouldDispatchToAi("classification", stub("high"))).toBe(false); + }); + + it("escalates anything less than confident", () => { + expect(shouldDispatchToAi("classification", stub("medium"))).toBe(true); + expect(shouldDispatchToAi("classification", stub("low"))).toBe(true); + expect(shouldDispatchToAi("classification", stub("none"))).toBe(true); + }); +}); diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.ts b/frontend/editor/src/proprietary/data/classificationPolicy.ts new file mode 100644 index 0000000000..4327b40d39 --- /dev/null +++ b/frontend/editor/src/proprietary/data/classificationPolicy.ts @@ -0,0 +1,69 @@ +/** + * Everything specific to the built-in Classification policy, in one module. The generic policy + * runner asks the capability questions below instead of naming classification itself, so a second + * annotating policy needs a change here rather than in the runner. + * + * These are still keyed on the category id rather than a property each policy declares. That is + * deliberate for now: policies are becoming pipelines with labels behind a separate enforcement + * layer, which removes the category concept these would be declared against. Classification also + * stays genuinely privileged - it is the only policy with a browser-side implementation, so it can + * answer without the server. Ordering and output shape belong in that rework (an in-place output + * mode, and a run result that can carry findings as well as files), not in a flag added here first. + */ + +import type { + ClassificationConfidence, + StirlingFileStub, +} from "@app/types/fileContext"; + +/** Catalogue category id of the built-in Classification policy. */ +export const CLASSIFICATION_CATEGORY_ID = "classification"; + +export function isClassificationCategory(categoryId: string): boolean { + return categoryId === CLASSIFICATION_CATEGORY_ID; +} + +/** + * Whether the policy rewrites the document rather than only annotating it. Annotating policies are + * ordered last: a rewriting one after them would fork from the pre-annotation version. + */ +export function policyRewritesDocument(categoryId: string): boolean { + return !isClassificationCategory(categoryId); +} + +/** Whether a completed run is expected to deliver output files (annotators deliver labels). */ +export function policyDeliversOutputFiles(categoryId: string): boolean { + return policyRewritesDocument(categoryId); +} + +/** Whether the policy's server-side run exists only to escalate to the AI engine. */ +export function policyRequiresAiEngine(categoryId: string): boolean { + return isClassificationCategory(categoryId); +} + +/** Order annotating policies last; everything else keeps the order it was given. */ +export function orderRewritesFirst(categoryIds: string[]): string[] { + return [ + ...categoryIds.filter(policyRewritesDocument), + ...categoryIds.filter((id) => !policyRewritesDocument(id)), + ]; +} + +/** + * The one heuristic verdict trusted to stand on its own; anything less escalates to the AI, which + * overwrites it. Deliberately strict - a wrong label costs more than an engine call. + */ +const TRUSTED_CONFIDENCE: ClassificationConfidence = "high"; + +/** + * Whether the AI classifier should be asked about this file. Only once the heuristic has reported: + * dispatching before then races the first pass and bills for an answer it was about to produce. + */ +export function shouldDispatchToAi( + categoryId: string, + stub: StirlingFileStub, +): boolean { + if (!isClassificationCategory(categoryId)) return true; + const confidence = stub.classificationConfidence; + return confidence != null && confidence !== TRUSTED_CONFIDENCE; +} diff --git a/frontend/editor/src/proprietary/data/policyCategories.test.ts b/frontend/editor/src/proprietary/data/policyCategories.test.ts deleted file mode 100644 index 4b4fca2b7e..0000000000 --- a/frontend/editor/src/proprietary/data/policyCategories.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - isClassificationCategory, - pinClassificationLast, -} from "@app/data/policyCategories"; - -describe("isClassificationCategory", () => { - it("recognises the classification category and nothing else", () => { - expect(isClassificationCategory("classification")).toBe(true); - expect(isClassificationCategory("security")).toBe(false); - expect(isClassificationCategory("")).toBe(false); - }); -}); - -describe("pinClassificationLast", () => { - it("moves classification to the end, preserving other order", () => { - expect( - pinClassificationLast(["classification", "security", "compliance"]), - ).toEqual(["security", "compliance", "classification"]); - }); - - it("leaves an order without classification untouched", () => { - expect(pinClassificationLast(["security", "compliance"])).toEqual([ - "security", - "compliance", - ]); - }); - - it("is a no-op when classification is already last", () => { - expect(pinClassificationLast(["security", "classification"])).toEqual([ - "security", - "classification", - ]); - }); - - it("handles classification as the only policy", () => { - expect(pinClassificationLast(["classification"])).toEqual([ - "classification", - ]); - }); -}); diff --git a/frontend/editor/src/proprietary/data/policyCategories.ts b/frontend/editor/src/proprietary/data/policyCategories.ts deleted file mode 100644 index 820a93366b..0000000000 --- a/frontend/editor/src/proprietary/data/policyCategories.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** The classification policy's catalog category id. */ -export const CLASSIFICATION_CATEGORY_ID = "classification"; - -/** - * Classification is metadata-only: it runs async (never blocks), never forks a - * version, and always runs last. This predicate gates that special handling. - */ -export function isClassificationCategory(categoryId: string): boolean { - return categoryId === CLASSIFICATION_CATEGORY_ID; -} - -/** - * Move classification to the end of an execution order (others keep their order), - * so a persisted/displayed order can't place it anywhere but last. - */ -export function pinClassificationLast(orderedCategoryIds: string[]): string[] { - return [ - ...orderedCategoryIds.filter((id) => !isClassificationCategory(id)), - ...orderedCategoryIds.filter((id) => isClassificationCategory(id)), - ]; -} diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index 389f98cd65..382a7f9222 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -35,7 +35,7 @@ import { removePolicy, } from "@app/services/policyBackend"; import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi"; -import { pinClassificationLast } from "@app/data/policyCategories"; +import { orderRewritesFirst } from "@app/data/classificationPolicy"; import type { PolicyToStore } from "@app/services/policyPipeline"; import type { PoliciesByCategory, @@ -327,9 +327,9 @@ export function usePolicies() { * first for an instant re-render; the next reconcile re-reads the server order. */ const reorderPolicies = useCallback((orderedCategoryIds: string[]) => { - // Pin classification last so the persisted/server order matches execution - // (it always runs last — see usePolicyAutoRun). - const ordered = pinClassificationLast(orderedCategoryIds); + // Annotating policies last, so the persisted order matches execution order + // (see usePolicyAutoRun). + const ordered = orderRewritesFirst(orderedCategoryIds); persistPolicyOrder(ordered); const current = loadPolicies(); const backendIds = ordered diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts index 52779b9cf8..85b2249cbb 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -4,7 +4,7 @@ import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; import { useAllFiles } from "@app/contexts/FileContext"; import { loadPolicyCatalog } from "@app/services/policyCatalog"; import { policyAccentVar } from "@app/components/policies/policyStatus"; -import { isClassificationCategory } from "@app/data/policyCategories"; +import { isClassificationCategory } from "@app/data/classificationPolicy"; import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; /** Minimal provenance shape needed to resolve a file's inherited badges. */ diff --git a/frontend/editor/src/proprietary/services/heuristic/types.ts b/frontend/editor/src/proprietary/services/heuristic/types.ts index fbd8128ba4..5170feccd5 100644 --- a/frontend/editor/src/proprietary/services/heuristic/types.ts +++ b/frontend/editor/src/proprietary/services/heuristic/types.ts @@ -1,5 +1,7 @@ // Shared types for the client-side heuristic (non-AI) document classifier. +import type { ClassificationConfidence } from "@app/types/fileContext"; + /** Input document for the heuristic engine. */ export interface HeuristicDoc { fileName: string; @@ -11,7 +13,7 @@ export interface HeuristicDoc { } // "none" = no match or non-English; a real runtime value, not just a type state. -export type HeuristicConfidence = "none" | "low" | "medium" | "high"; +export type HeuristicConfidence = ClassificationConfidence; /** One scored candidate label with the rule hits that produced its score (debug only). */ export interface LabelScoreExplanation { diff --git a/frontend/oxlint.config.ts b/frontend/oxlint.config.ts index ac29c9b2bb..314bc3178e 100644 --- a/frontend/oxlint.config.ts +++ b/frontend/oxlint.config.ts @@ -74,15 +74,14 @@ const modernGlobals: OxlintGlobals = { // Folders not yet conformant to the stricter no-explicit-any rule const noExplicitAnyExcludes = [ - "editor/src/core/components/shared/*.{js,mjs,jsx,ts,tsx}", + "editor/src/core/components/shared/FilePickerModal.tsx", "editor/src/core/components/shared/config/configSections/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/addStamp/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/tools/automate/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/viewer/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/contexts/viewer/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/hooks/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/services/*.{js,mjs,jsx,ts,tsx}", + "editor/src/core/services/pdfProcessingService.ts", + "editor/src/core/services/zipFileService.ts", "editor/src/core/tools/annotate/useAnnotationSelection.ts", "editor/src/core/types/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/utils/*.{js,mjs,jsx,ts,tsx}", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9de0a5bf6a..6ade3d2a28 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -121,12 +121,12 @@ "license-checker": "^25.0.1", "msw": "^2.14.6", "msw-storybook-addon": "^2.0.7", + "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "postcss": "^8.5.12", "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", - "prettier": "^3.8.1", "puppeteer": "^24.25.0", "rollup-plugin-visualizer": "^7.0.1", "storybook": "^9.1.20", @@ -2905,6 +2905,329 @@ "dev": true, "license": "MIT" }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.62.0.tgz", + "integrity": "sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.62.0.tgz", + "integrity": "sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.62.0.tgz", + "integrity": "sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.62.0.tgz", + "integrity": "sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.62.0.tgz", + "integrity": "sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.62.0.tgz", + "integrity": "sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.62.0.tgz", + "integrity": "sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.62.0.tgz", + "integrity": "sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.62.0.tgz", + "integrity": "sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.62.0.tgz", + "integrity": "sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.62.0.tgz", + "integrity": "sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.62.0.tgz", + "integrity": "sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.62.0.tgz", + "integrity": "sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.62.0.tgz", + "integrity": "sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.62.0.tgz", + "integrity": "sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.62.0.tgz", + "integrity": "sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.62.0.tgz", + "integrity": "sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.62.0.tgz", + "integrity": "sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.62.0.tgz", + "integrity": "sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxlint/binding-android-arm-eabi": { "version": "1.77.0", "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.77.0.tgz", @@ -12611,6 +12934,68 @@ "dev": true, "license": "MIT" }, + "node_modules/oxfmt": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.62.0.tgz", + "integrity": "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.62.0", + "@oxfmt/binding-android-arm64": "0.62.0", + "@oxfmt/binding-darwin-arm64": "0.62.0", + "@oxfmt/binding-darwin-x64": "0.62.0", + "@oxfmt/binding-freebsd-x64": "0.62.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.62.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.62.0", + "@oxfmt/binding-linux-arm64-gnu": "0.62.0", + "@oxfmt/binding-linux-arm64-musl": "0.62.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.62.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.62.0", + "@oxfmt/binding-linux-riscv64-musl": "0.62.0", + "@oxfmt/binding-linux-s390x-gnu": "0.62.0", + "@oxfmt/binding-linux-x64-gnu": "0.62.0", + "@oxfmt/binding-linux-x64-musl": "0.62.0", + "@oxfmt/binding-openharmony-arm64": "0.62.0", + "@oxfmt/binding-win32-arm64-msvc": "0.62.0", + "@oxfmt/binding-win32-ia32-msvc": "0.62.0", + "@oxfmt/binding-win32-x64-msvc": "0.62.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxfmt/node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/oxlint": { "version": "1.77.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.77.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6bbb3e9137..71a71661e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -143,12 +143,12 @@ "license-checker": "^25.0.1", "msw": "^2.14.6", "msw-storybook-addon": "^2.0.7", + "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "postcss": "^8.5.12", "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", - "prettier": "^3.8.1", "puppeteer": "^24.25.0", "rollup-plugin-visualizer": "^7.0.1", "storybook": "^9.1.20", diff --git a/frontend/stylelint.config.mjs b/frontend/stylelint.config.mjs index 2781e78516..79f3108c9c 100644 --- a/frontend/stylelint.config.mjs +++ b/frontend/stylelint.config.mjs @@ -1,4 +1,4 @@ -// Deliberately minimal: Prettier owns formatting and `task frontend:lint:colors` +// Deliberately minimal: oxfmt owns formatting and `task frontend:lint:colors` // owns the theme tokens, so this only carries rules that catch real bugs. export default { ignoreFiles: [