From 3793a6df52d4d07330812eb4de5f74a03b50e012 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 19 Jun 2026 13:34:13 +0100 Subject: [PATCH 01/16] Fix bad frontend architecture (#6730) # Description of Changes #6727 introduced frontend code which goes against the architecture, so this PR re-implements it in the architecture properly, along with another bad Tauri check that I found in the source. I also updated the `AGENTS.md` file to use Claude's "read this file" syntax to try and force AI to actually read the file instead of just suggesting that it does it. --- AGENTS.md | 2 +- .../core/components/shared/UpdateModal.tsx | 25 ++----------------- .../components/shared/UpdateStartupPopup.tsx | 24 +----------------- .../src/core/platform/externalLinkClick.ts | 15 +++++++++++ .../components/shared/UpdateStartupPopup.tsx | 13 ++++++++++ .../src/desktop/platform/externalLinkClick.ts | 18 +++++++++++++ 6 files changed, 50 insertions(+), 47 deletions(-) create mode 100644 frontend/editor/src/core/platform/externalLinkClick.ts create mode 100644 frontend/editor/src/desktop/components/shared/UpdateStartupPopup.tsx create mode 100644 frontend/editor/src/desktop/platform/externalLinkClick.ts diff --git a/AGENTS.md b/AGENTS.md index ae8eb60316..44647b0063 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,7 +152,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie #### Import Paths - CRITICAL **ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation. -For a broader explanation of the frontend layering and override architecture, see [frontend/editor/DeveloperGuide.md](frontend/editor/DeveloperGuide.md). +For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md ```typescript // ✅ CORRECT - Use @app/* for all imports diff --git a/frontend/editor/src/core/components/shared/UpdateModal.tsx b/frontend/editor/src/core/components/shared/UpdateModal.tsx index ca052ae523..1399325b03 100644 --- a/frontend/editor/src/core/components/shared/UpdateModal.tsx +++ b/frontend/editor/src/core/components/shared/UpdateModal.tsx @@ -24,7 +24,7 @@ import { MachineInfo, } from "@app/services/updateService"; import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import { openExternal } from "@app/platform/openExternal"; +import { handleExternalLinkClick } from "@app/platform/externalLinkClick"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import DownloadIcon from "@mui/icons-material/Download"; @@ -37,19 +37,6 @@ import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; -/** - * Best-effort Tauri detection without importing `@tauri-apps/api` into the - * core bundle (which must stay runnable on plain web). Tauri v2 injects - * `__TAURI_INTERNALS__` before any user code runs. Mirrors UpdateStartupPopup. - */ -function isRunningInTauri(): boolean { - if (typeof window === "undefined") return false; - return ( - typeof (window as unknown as { __TAURI_INTERNALS__?: unknown }) - .__TAURI_INTERNALS__ !== "undefined" - ); -} - export type DesktopInstallState = | "idle" | "downloading" @@ -211,18 +198,10 @@ const UpdateModal: React.FC = ({ onClose(); }; - // External links (release notes, migration guides, download fallback) use - // real anchors so they open a new tab on web. Inside Tauri the webview traps - // target="_blank", so on desktop we intercept and hand the URL to the OS - // browser via the platform seam. stopPropagation keeps links nested in the - // clickable version-history rows from toggling the row. const handleExternalLink = (url: string) => (e: React.MouseEvent) => { e.stopPropagation(); - if (isRunningInTauri()) { - e.preventDefault(); - void openExternal(url); - } + handleExternalLinkClick(url, e); }; // Sort versions newest first, skip the latest (already shown in header) diff --git a/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx b/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx index f3e2a446b9..62f6dbb999 100644 --- a/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx +++ b/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx @@ -20,29 +20,9 @@ const STARTUP_DELAY_MS = 15_000; const SNOOZE_KEY = "stirling-pdf-updater:snoozedUntil"; const SNOOZE_DURATION_MS = 24 * 60 * 60 * 1000; -/** - * Best-effort Tauri detection without importing `@tauri-apps/api` into the - * core bundle (which must remain runnable on plain web). Tauri v2 injects - * `__TAURI_INTERNALS__` before any user code runs. - */ -function isRunningInTauri(): boolean { - if (typeof window === "undefined") return false; - return ( - typeof (window as unknown as { __TAURI_INTERNALS__?: unknown }) - .__TAURI_INTERNALS__ !== "undefined" - ); -} - /** * Web/server-side auto-popup that shows the UpdateModal on startup when a - * newer Stirling-PDF version is available. Previously this check only ran - * from the Settings → General "Check for Updates" button, so non-desktop - * users could sit on stale versions indefinitely without any prompt. - * - * On desktop (Tauri) this component is a no-op — `useDesktopUpdatePopup` - * drives the desktop flow because it also has to honour the headless - * `updateMode` provisioning flag and wire up the silent/auto installer. - * Running both would double-popup. + * newer Stirling-PDF version is available. */ export function UpdateStartupPopup() { const { config } = useAppConfig(); @@ -60,8 +40,6 @@ export function UpdateStartupPopup() { const hasChecked = useRef(false); useEffect(() => { - // Skip on desktop — the Tauri popup owns that flow end-to-end. - if (isRunningInTauri()) return; if (hasChecked.current) return; if (!currentVersion) return; // Don't even schedule the timer until we have a version to compare. diff --git a/frontend/editor/src/core/platform/externalLinkClick.ts b/frontend/editor/src/core/platform/externalLinkClick.ts new file mode 100644 index 0000000000..9be70d062f --- /dev/null +++ b/frontend/editor/src/core/platform/externalLinkClick.ts @@ -0,0 +1,15 @@ +import type { MouseEvent } from "react"; + +/** + * Click handler for external `` links rendered by shared + * components (e.g. UpdateModal release notes / migration guides). + * + * In a normal browser a `target="_blank"` anchor already opens the URL in a new + * tab, so this default does nothing and lets the native navigation proceed. + * Builds whose webview traps `target="_blank"` inside the app window shadow this + * module to intercept the click and route the URL to the OS browser instead. + */ +export function handleExternalLinkClick( + _url: string, + _event: MouseEvent, +): void {} diff --git a/frontend/editor/src/desktop/components/shared/UpdateStartupPopup.tsx b/frontend/editor/src/desktop/components/shared/UpdateStartupPopup.tsx new file mode 100644 index 0000000000..07ca5ad2b6 --- /dev/null +++ b/frontend/editor/src/desktop/components/shared/UpdateStartupPopup.tsx @@ -0,0 +1,13 @@ +/** + * Desktop (Tauri) override of @app/components/shared/UpdateStartupPopup. + * + * On desktop the update flow is owned end-to-end by `useDesktopUpdatePopup`, + * which also honours the headless `updateMode` provisioning flag and wires up + * the silent/auto installer. The web startup popup must therefore be a no-op + * here, otherwise both would run and double-popup. + */ +export function UpdateStartupPopup() { + return null; +} + +export default UpdateStartupPopup; diff --git a/frontend/editor/src/desktop/platform/externalLinkClick.ts b/frontend/editor/src/desktop/platform/externalLinkClick.ts new file mode 100644 index 0000000000..23c5722e2b --- /dev/null +++ b/frontend/editor/src/desktop/platform/externalLinkClick.ts @@ -0,0 +1,18 @@ +import type { MouseEvent } from "react"; +import { openExternal } from "@app/platform/openExternal"; + +/** + * Desktop (Tauri) override of the @app/platform/externalLinkClick seam. + * + * The app runs inside a Tauri webview, which traps a `target="_blank"` anchor + * inside our own window. Intercept the click and hand the URL to the OS browser + * via the openExternal seam (Tauri shell open) so the link lands in the user's + * real browser. + */ +export function handleExternalLinkClick( + url: string, + event: MouseEvent, +): void { + event.preventDefault(); + void openExternal(url); +} From 6a9876a067360b7dc970a81effa8219b6dd99a5d Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 19 Jun 2026 14:37:53 +0100 Subject: [PATCH 02/16] Fix more `any` typing usage in the frontend (#6664) # Description of Changes Continued effort to remove the remaining uses of the `any` type from our TS code. The vast majority of these uses that it cleans up was just catching errors as `any`, which are pretty simple to fix. I couldn't completely remove the `any` type usage from `core/tools` because there were cascading issues from a couple of the files in there (most notably Automate) but still, moving in the right direction. --- .../src/core/pages/MobileScannerPage.tsx | 68 +++++++++++++------ .../tests/convert/ConvertIntegration.test.tsx | 2 +- .../core/tests/live/watched-folders.spec.ts | 7 +- .../editor/src/core/tools/AddAttachments.tsx | 11 +-- .../editor/src/core/tools/AddPageNumbers.tsx | 11 +-- frontend/editor/src/core/tools/AddStamp.tsx | 11 +-- .../src/core/tools/EditTableOfContents.tsx | 10 +-- .../editor/src/core/tools/ReorganizePages.tsx | 6 +- .../src/core/tools/formFill/FormFill.tsx | 11 +-- .../core/tools/formFill/FormFillContext.tsx | 9 ++- .../tools/pdfTextEditor/PdfTextEditor.tsx | 59 ++++++++-------- .../editor/src/core/utils/loadJscanify.ts | 47 ++++++++++++- frontend/eslint.config.mjs | 5 +- 13 files changed, 174 insertions(+), 83 deletions(-) diff --git a/frontend/editor/src/core/pages/MobileScannerPage.tsx b/frontend/editor/src/core/pages/MobileScannerPage.tsx index 0424125aa8..0d14ce7ee8 100644 --- a/frontend/editor/src/core/pages/MobileScannerPage.tsx +++ b/frontend/editor/src/core/pages/MobileScannerPage.tsx @@ -20,12 +20,32 @@ import PhotoCameraRoundedIcon from "@mui/icons-material/PhotoCameraRounded"; import UploadRoundedIcon from "@mui/icons-material/UploadRounded"; import AddPhotoAlternateRoundedIcon from "@mui/icons-material/AddPhotoAlternateRounded"; import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded"; -import { loadJscanify } from "@app/utils/loadJscanify"; +import { + loadJscanify, + type JscanifyCornerPoints, + type JscanifyScanner, +} from "@app/utils/loadJscanify"; import apiClient from "@app/services/apiClient"; // Use the configured API base (e.g. api.stirling.com), not the page origin. const API_BASE = (apiClient.defaults.baseURL ?? "").replace(/\/+$/, ""); +// Experimental camera controls (W3C Image Capture / MediaStream extensions) that +// are not yet part of the standard DOM lib typings but are widely shipped on +// mobile browsers and required for document scanning. +declare global { + interface MediaTrackCapabilities { + focusMode?: string[]; + exposureMode?: string[]; + torch?: boolean; + } + interface MediaTrackConstraintSet { + focusMode?: ConstrainDOMString; + exposureMode?: ConstrainDOMString; + torch?: ConstrainBoolean; + } +} + /** * MobileScannerPage * @@ -63,7 +83,7 @@ export default function MobileScannerPage() { const highlightCanvasRef = useRef(null); const streamRef = useRef(null); const fileInputRef = useRef(null); - const scannerRef = useRef(null); + const scannerRef = useRef(null); const highlightIntervalRef = useRef(null); // Detection resolution - extremely low for mobile performance @@ -254,15 +274,15 @@ export default function MobileScannerPage() { // Configure camera capabilities for document scanning try { - const capabilities = videoTrack.getCapabilities() as any; // Cast to any for experimental camera APIs - const constraints: any = { advanced: [] }; + const capabilities = videoTrack.getCapabilities(); + const advanced: MediaTrackConstraintSet[] = []; // 1. Enable continuous autofocus if ( capabilities.focusMode && capabilities.focusMode.includes("continuous") ) { - constraints.advanced.push({ focusMode: "continuous" }); + advanced.push({ focusMode: "continuous" }); console.log("✓ Continuous autofocus enabled"); } @@ -271,7 +291,7 @@ export default function MobileScannerPage() { capabilities.exposureMode && capabilities.exposureMode.includes("continuous") ) { - constraints.advanced.push({ exposureMode: "continuous" }); + advanced.push({ exposureMode: "continuous" }); console.log("✓ Auto-exposure enabled"); } @@ -282,8 +302,8 @@ export default function MobileScannerPage() { } // Apply all constraints - if (constraints.advanced.length > 0) { - await videoTrack.applyConstraints(constraints); + if (advanced.length > 0) { + await videoTrack.applyConstraints({ advanced }); } } catch (err) { console.log("Could not configure camera features:", err); @@ -444,15 +464,19 @@ export default function MobileScannerPage() { // Step 2: Simple jscanify detection const detectionStart = performance.now(); - let corners = null; + let corners: JscanifyCornerPoints | null = null; // Run jscanify detection directly - convert canvas to Mat first - const mat = (window as any).cv.imread(detectionCanvas); - const contour = scannerRef.current.findPaperContour(mat); - mat.delete(); + const cv = window.cv; + const scanner = scannerRef.current; + if (cv && scanner) { + const mat = cv.imread(detectionCanvas); + const contour = scanner.findPaperContour(mat); + mat.delete(); - if (contour) { - corners = scannerRef.current.getCornerPoints(contour); + if (contour) { + corners = scanner.getCornerPoints(contour); + } } const detectionTime = performance.now() - detectionStart; @@ -660,7 +684,9 @@ export default function MobileScannerPage() { let finalDataUrl: string; // Apply jscanify processing if enabled and available - if (autoEnhance && scannerRef.current && openCvReady) { + const cv = window.cv; + const scanner = scannerRef.current; + if (autoEnhance && scanner && openCvReady && cv) { try { // Create low-res canvas for detection (faster processing) const detectionCanvas = document.createElement("canvas"); @@ -683,11 +709,11 @@ export default function MobileScannerPage() { ); // Run detection on low-res image - const mat = (window as any).cv.imread(detectionCanvas); - const contour = scannerRef.current.findPaperContour(mat); + const mat = cv.imread(detectionCanvas); + const contour = scanner.findPaperContour(mat); if (contour) { - const cornerPoints = scannerRef.current.getCornerPoints(contour); + const cornerPoints = scanner.getCornerPoints(contour); // Scale corner points back to full resolution if (cornerPoints) { @@ -746,7 +772,7 @@ export default function MobileScannerPage() { const docHeight = Math.round((leftHeight + rightHeight) / 2); // Extract paper from full-resolution canvas with scaled corner points - const resultCanvas = scannerRef.current.extractPaper( + const resultCanvas = scanner.extractPaper( canvas, docWidth, docHeight, @@ -891,8 +917,8 @@ export default function MobileScannerPage() { try { const videoTrack = streamRef.current.getVideoTracks()[0]; await videoTrack.applyConstraints({ - advanced: [{ torch: !torchEnabled } as any], // Cast to any for experimental torch API - } as any); + advanced: [{ torch: !torchEnabled }], + }); setTorchEnabled(!torchEnabled); console.log("Torch:", !torchEnabled ? "ON" : "OFF"); } catch (err) { diff --git a/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx index e0555d25f8..06f9198f05 100644 --- a/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx @@ -123,7 +123,7 @@ describe("Convert Tool Integration Tests", () => { beforeEach(() => { vi.clearAllMocks(); // Setup default apiClient mock - mockedApiClient.post = vi.fn() as any; + mockedApiClient.post = vi.fn() as typeof mockedApiClient.post; }); afterEach(() => { diff --git a/frontend/editor/src/core/tests/live/watched-folders.spec.ts b/frontend/editor/src/core/tests/live/watched-folders.spec.ts index 8f6e09d4b6..6ee7dddd7c 100644 --- a/frontend/editor/src/core/tests/live/watched-folders.spec.ts +++ b/frontend/editor/src/core/tests/live/watched-folders.spec.ts @@ -83,7 +83,10 @@ async function getIDBFolders( const all = tx.objectStore(storeName).getAll(); all.onsuccess = () => resolve( - (all.result || []).map((f: any) => ({ id: f.id, name: f.name })), + (all.result || []).map((f: { id: string; name: string }) => ({ + id: f.id, + name: f.name, + })), ); all.onerror = () => resolve([]); }; @@ -275,7 +278,7 @@ test.describe("Watched Folders — Create / Edit / Delete", () => { dbName: string, storeName: string, key: string, - value: any, + value: unknown, ) => new Promise((resolve) => { const req = indexedDB.open(dbName); diff --git a/frontend/editor/src/core/tools/AddAttachments.tsx b/frontend/editor/src/core/tools/AddAttachments.tsx index 22c9713b68..17e5d7b0b1 100644 --- a/frontend/editor/src/core/tools/AddAttachments.tsx +++ b/frontend/editor/src/core/tools/AddAttachments.tsx @@ -1,7 +1,10 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; -import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; +import { + createToolFlow, + type MiddleStepConfig, +} from "@app/components/tools/shared/createToolFlow"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; import { useAddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters"; @@ -36,9 +39,9 @@ const AddAttachments = ({ if (operation.files && onComplete) { onComplete(operation.files); } - } catch (error: any) { + } catch (error) { onError?.( - error?.message || + (error instanceof Error ? error.message : undefined) || t( "AddAttachmentsRequest.error.failed", "Add attachments operation failed", @@ -70,7 +73,7 @@ const AddAttachments = ({ }); const getSteps = () => { - const steps: any[] = []; + const steps: MiddleStepConfig[] = []; // Step 1: Attachments Selection steps.push({ diff --git a/frontend/editor/src/core/tools/AddPageNumbers.tsx b/frontend/editor/src/core/tools/AddPageNumbers.tsx index db6e5404e5..7001dc2894 100644 --- a/frontend/editor/src/core/tools/AddPageNumbers.tsx +++ b/frontend/editor/src/core/tools/AddPageNumbers.tsx @@ -1,7 +1,10 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; -import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; +import { + createToolFlow, + type MiddleStepConfig, +} from "@app/components/tools/shared/createToolFlow"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; import { useAddPageNumbersParameters } from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters"; @@ -35,9 +38,9 @@ const AddPageNumbers = ({ if (operation.files && onComplete) { onComplete(operation.files); } - } catch (error: any) { + } catch (error) { onError?.( - error?.message || + (error instanceof Error ? error.message : undefined) || t("addPageNumbers.error.failed", "Add page numbers operation failed"), ); } @@ -67,7 +70,7 @@ const AddPageNumbers = ({ }); const getSteps = () => { - const steps: any[] = []; + const steps: MiddleStepConfig[] = []; // Step 1: Position Selection & Pages/Starting Number steps.push({ diff --git a/frontend/editor/src/core/tools/AddStamp.tsx b/frontend/editor/src/core/tools/AddStamp.tsx index b5c5d913fc..9a7422188e 100644 --- a/frontend/editor/src/core/tools/AddStamp.tsx +++ b/frontend/editor/src/core/tools/AddStamp.tsx @@ -1,6 +1,9 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; +import { + createToolFlow, + type MiddleStepConfig, +} from "@app/components/tools/shared/createToolFlow"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; @@ -41,9 +44,9 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { if (operation.files && onComplete) { onComplete(operation.files); } - } catch (error: any) { + } catch (error) { onError?.( - error?.message || + (error instanceof Error ? error.message : undefined) || t("AddStampRequest.error.failed", "Add stamp operation failed"), ); } @@ -73,7 +76,7 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }); const getSteps = () => { - const steps: any[] = []; + const steps: MiddleStepConfig[] = []; // Step 1: Stamp Setup steps.push({ diff --git a/frontend/editor/src/core/tools/EditTableOfContents.tsx b/frontend/editor/src/core/tools/EditTableOfContents.tsx index 725c64124c..85769e0649 100644 --- a/frontend/editor/src/core/tools/EditTableOfContents.tsx +++ b/frontend/editor/src/core/tools/EditTableOfContents.tsx @@ -26,6 +26,7 @@ import { useNavigationState, } from "@app/contexts/NavigationContext"; import { useFileSelection } from "@app/contexts/FileContext"; +import { isStirlingFile } from "@app/types/fileContext"; const extractBookmarks = async (file: File): Promise => { const formData = new FormData(); @@ -39,7 +40,7 @@ const extractBookmarks = async (file: File): Promise => { return response.data as BookmarkPayload[]; }; -const useStableCallback = any>( +const useStableCallback = unknown>( callback: T, ): T => { const callbackRef = useRef(callback); @@ -112,7 +113,7 @@ const EditTableOfContents = (props: BaseToolProps) => { const payload = await extractBookmarks(file); const bookmarks = hydrateBookmarkPayload(payload); setBookmarks(bookmarks); - setLastLoadedFileId((file as any)?.fileId ?? file.name); + setLastLoadedFileId(isStirlingFile(file) ? file.fileId : file.name); if (showToast) { alert({ @@ -164,7 +165,7 @@ const EditTableOfContents = (props: BaseToolProps) => { return; } - const fileId = (selectedFile as any)?.fileId ?? selectedFile.name; + const fileId = selectedFile.fileId; if (fileId === lastLoadedFileId) { return; } @@ -466,6 +467,7 @@ const EditTableOfContents = (props: BaseToolProps) => { }); }; -(EditTableOfContents as any).tool = () => useEditTableOfContentsOperation; +(EditTableOfContents as ToolComponent).tool = () => + useEditTableOfContentsOperation; export default EditTableOfContents as ToolComponent; diff --git a/frontend/editor/src/core/tools/ReorganizePages.tsx b/frontend/editor/src/core/tools/ReorganizePages.tsx index dcf5c1ae9c..024469bee4 100644 --- a/frontend/editor/src/core/tools/ReorganizePages.tsx +++ b/frontend/editor/src/core/tools/ReorganizePages.tsx @@ -34,9 +34,9 @@ const ReorganizePages = ({ if (operation.files && onComplete) { onComplete(operation.files); } - } catch (error: any) { + } catch (error) { onError?.( - error?.message || + (error instanceof Error ? error.message : undefined) || t("reorganizePages.error.failed", "Failed to reorganize pages"), ); } @@ -107,6 +107,6 @@ const ReorganizePages = ({ }); }; -(ReorganizePages as any).tool = () => useReorganizePagesOperation; +(ReorganizePages as ToolComponent).tool = () => useReorganizePagesOperation; export default ReorganizePages as ToolComponent; diff --git a/frontend/editor/src/core/tools/formFill/FormFill.tsx b/frontend/editor/src/core/tools/formFill/FormFill.tsx index 846c1dcc83..0e4829df69 100644 --- a/frontend/editor/src/core/tools/formFill/FormFill.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFill.tsx @@ -28,6 +28,7 @@ import { ActionIcon, } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { isAxiosError } from "axios"; import { useFormFill, useAllFormValues, @@ -267,13 +268,15 @@ const FormFill = (_props: BaseToolProps) => { detail: { blob: filledBlob }, }); window.dispatchEvent(event); - } catch (err: any) { + } catch (err) { + const status = isAxiosError(err) ? err.response?.status : undefined; const message = - err?.response?.status === 413 + status === 413 ? "File too large. Try reducing the PDF size first." - : err?.response?.status === 400 + : status === 400 ? "Invalid form data. Please check all fields." - : err?.message || "Failed to save filled form"; + : (err instanceof Error ? err.message : undefined) || + "Failed to save filled form"; setSaveError(message); console.error("[FormFill] Save failed:", err); } finally { diff --git a/frontend/editor/src/core/tools/formFill/FormFillContext.tsx b/frontend/editor/src/core/tools/formFill/FormFillContext.tsx index 217b568bbc..4377984111 100644 --- a/frontend/editor/src/core/tools/formFill/FormFillContext.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFillContext.tsx @@ -30,6 +30,7 @@ import React, { useSyncExternalStore, } from "react"; import { useDebouncedCallback } from "@mantine/hooks"; +import { isAxiosError } from "axios"; import type { FormField, FormFillState, @@ -361,11 +362,13 @@ export function FormFillProvider({ forFileIdRef.current = fileId ?? null; setForFileId(fileId ?? null); dispatch({ type: "FETCH_SUCCESS", fields }); - } catch (err: any) { + } catch (err) { if (fetchVersionRef.current !== version) return; // stale const msg = - err?.response?.data?.message || - err?.message || + (isAxiosError<{ message?: string }>(err) + ? err.response?.data?.message + : undefined) || + (err instanceof Error ? err.message : undefined) || "Failed to fetch form fields"; dispatch({ type: "FETCH_ERROR", error: msg }); } diff --git a/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx b/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx index bf9628f5b3..fee07ac6ea 100644 --- a/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx +++ b/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useTranslation } from "react-i18next"; +import { isAxiosError } from "axios"; import DescriptionIcon from "@mui/icons-material/DescriptionOutlined"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; @@ -16,6 +17,7 @@ import { import { useViewer } from "@app/contexts/ViewerContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; +import type { FileId } from "@app/types/file"; import { getDefaultWorkbench } from "@app/types/workbench"; import { CONVERSION_ENDPOINTS } from "@app/constants/convertConstants"; import apiClient from "@app/services/apiClient"; @@ -294,7 +296,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { const imagesByPageRef = useRef([]); const lastLoadedFileRef = useRef(null); const autoLoadKeyRef = useRef(null); - const sourceFileIdRef = useRef(null); + const sourceFileIdRef = useRef(null); const loadRequestIdRef = useRef(0); const latestPdfRequestIdRef = useRef(null); const loadedDocumentRef = useRef(null); @@ -339,8 +341,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }; }, []); - const isCacheUnavailableError = useCallback((error: any): boolean => { - const status = error?.response?.status; + const isCacheUnavailableError = useCallback((error: unknown): boolean => { + const status = isAxiosError(error) ? error.response?.status : undefined; // Treat any 410 as cache unavailable, since responseType: 'blob' makes // it impossible to reliably check the JSON body return status === 410; @@ -804,14 +806,20 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { } else { console.log("Job not complete yet, continuing to poll..."); } - } catch (pollError: any) { + } catch (pollError) { console.error("Error polling job status:", pollError); + const status = isAxiosError(pollError) + ? pollError.response?.status + : undefined; console.error("Poll error details:", { - status: pollError?.response?.status, - data: pollError?.response?.data, - message: pollError?.message, + status, + data: isAxiosError(pollError) + ? pollError.response?.data + : undefined, + message: + pollError instanceof Error ? pollError.message : undefined, }); - if (pollError?.response?.status === 404) { + if (status === 404) { throw new Error("Job not found on server", { cause: pollError, }); @@ -864,12 +872,12 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { cachedJobIdRef.current = newJobId; setFileName(file.name); setErrorMessage(null); - } catch (error: any) { + } catch (error) { console.error("Failed to load file", error); console.error("Error details:", { - message: error?.message, - response: error?.response?.data, - stack: error?.stack, + message: error instanceof Error ? error.message : undefined, + response: isAxiosError(error) ? error.response?.data : undefined, + stack: error instanceof Error ? error.stack : undefined, }); if (loadRequestIdRef.current !== requestId) { @@ -885,7 +893,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { if (isPdf) { const errorMsg = - error?.message || + (error instanceof Error ? error.message : undefined) || t( "pdfTextEditor.conversionFailed", "Failed to convert PDF. Please try again.", @@ -1406,11 +1414,11 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { onComplete([pdfFile]); } setErrorMessage(null); - } catch (error: any) { + } catch (error) { console.error("Failed to convert JSON back to PDF", error); const message = - error?.response?.data || - error?.message || + (isAxiosError(error) ? error.response?.data : undefined) || + (error instanceof Error ? error.message : undefined) || t( "pdfTextEditor.errors.pdfConversion", "Unable to convert the edited JSON back into a PDF.", @@ -1451,9 +1459,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { return; } - const parentStub = selectors.getStirlingFileStub( - sourceFileIdRef.current as any, - ); + const sourceFileId = sourceFileIdRef.current; + const parentStub = selectors.getStirlingFileStub(sourceFileId); if (!parentStub) { console.warn( "[PdfTextEditor] Could not find parent stub for save to workbench", @@ -1660,11 +1667,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { ); // Replace the original file with the edited version - await consumeFiles( - [sourceFileIdRef.current as any], - stirlingFiles, - stubs, - ); + await consumeFiles([sourceFileId], stirlingFiles, stubs); // Update the source file ID to point to the new file sourceFileIdRef.current = stubs[0].id; @@ -1676,11 +1679,11 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { // Set flag to trigger navigation after state update is processed setShouldNavigateAfterSave(true); - } catch (error: any) { + } catch (error) { console.error("Failed to save to workbench", error); const message = - error?.response?.data || - error?.message || + (isAxiosError(error) ? error.response?.data : undefined) || + (error instanceof Error ? error.message : undefined) || t( "pdfTextEditor.errors.pdfConversion", "Unable to save changes to workbench.", @@ -1955,7 +1958,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { autoLoadKeyRef.current = fileKey; // Capture the source file ID for save-to-workbench functionality - sourceFileIdRef.current = (autoLoadFile as any).fileId ?? null; + sourceFileIdRef.current = autoLoadFile.fileId ?? null; void handleLoadFile(autoLoadFile); }, [autoLoadFile, navigationState.selectedTool, handleLoadFile]); diff --git a/frontend/editor/src/core/utils/loadJscanify.ts b/frontend/editor/src/core/utils/loadJscanify.ts index 93dec00394..94b141bba2 100644 --- a/frontend/editor/src/core/utils/loadJscanify.ts +++ b/frontend/editor/src/core/utils/loadJscanify.ts @@ -1,9 +1,52 @@ import { withBasePath } from "@app/constants/app"; +/** A single point in image space, as returned by jscanify corner detection. */ +export interface JscanifyPoint { + x: number; + y: number; +} + +/** The four detected document corners returned by {@link JscanifyScanner.getCornerPoints}. */ +export interface JscanifyCornerPoints { + topLeftCorner: JscanifyPoint; + topRightCorner: JscanifyPoint; + bottomLeftCorner: JscanifyPoint; + bottomRightCorner: JscanifyPoint; +} + +/** Minimal subset of an OpenCV.js `Mat` that this app interacts with directly. */ +export interface OpenCVMat { + delete(): void; +} + +/** Minimal subset of the OpenCV.js runtime exposed on `window.cv`. */ +export interface OpenCV { + /** Defined only once the WASM runtime has finished initializing. */ + readonly Mat: unknown; + imread(source: HTMLImageElement | HTMLCanvasElement | string): OpenCVMat; +} + +/** The jscanify scanner instance API used by the mobile scanner. */ +export interface JscanifyScanner { + findPaperContour(image: OpenCVMat): OpenCVMat | undefined; + getCornerPoints(contour: OpenCVMat): JscanifyCornerPoints; + extractPaper( + image: HTMLCanvasElement, + resultWidth: number, + resultHeight: number, + cornerPoints?: JscanifyCornerPoints, + ): HTMLCanvasElement; +} + +/** Constructor for jscanify, exposed on `window.jscanify`. */ +export interface JscanifyConstructor { + new (): JscanifyScanner; +} + declare global { interface Window { - cv?: any; - jscanify?: any; + cv?: OpenCV; + jscanify?: JscanifyConstructor; } } diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 55950ad1b7..a176ef8541 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -215,10 +215,9 @@ export default defineConfig( "editor/src/core/contexts/**/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/data/**/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/hooks/**/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/pages/**/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/services/**/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/tests/**/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/tools/**/*.{js,mjs,jsx,ts,tsx}", + "editor/src/core/tools/Automate.tsx", + "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}", ], From fe7a2a5ac7c2aa7560b70fd52daa5acbe2ebd58b Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:19:51 +0100 Subject: [PATCH 03/16] Fix Multi Tool page rotation lost on save (#6733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Rotating a page in the Multi Tool and saving could leave the page at its original rotation (the change appeared lost), with inconsistent results across pages. - Page rotation is now always written on export, including 0°, so rotating a page that already had a non-zero rotation in the source PDF (e.g. a 270° page rotated back to upright) is no longer dropped. - Per-page rotation is always read when building the Multi Tool document, so pages keep their true orientation regardless of file size. - Rotation is only applied after a page imports successfully, avoiding a misaligned or failed export when an import fails. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../components/shared/HoverActionMenu.tsx | 1 + .../src/core/services/pdfExportService.ts | 50 +++++---- .../stubbed/page-editor-rotation.spec.ts | 97 ++++++++++++++++++ .../tests/test-fixtures/rotated-pages.pdf | Bin 0 -> 2829 bytes .../editor/src/core/utils/thumbnailUtils.ts | 5 +- 5 files changed, 129 insertions(+), 24 deletions(-) create mode 100644 frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts create mode 100644 frontend/editor/src/core/tests/test-fixtures/rotated-pages.pdf diff --git a/frontend/editor/src/core/components/shared/HoverActionMenu.tsx b/frontend/editor/src/core/components/shared/HoverActionMenu.tsx index f4cfb247a1..fafebdf8f2 100644 --- a/frontend/editor/src/core/components/shared/HoverActionMenu.tsx +++ b/frontend/editor/src/core/components/shared/HoverActionMenu.tsx @@ -64,6 +64,7 @@ const HoverActionMenu: React.FC = ({ disabled={action.disabled} onClick={action.onClick} c={action.color} + aria-label={action.label} style={{ color: action.color || "var(--text-secondary)" }} data-tour={action.dataTour} > diff --git a/frontend/editor/src/core/services/pdfExportService.ts b/frontend/editor/src/core/services/pdfExportService.ts index da7bbc9a0b..1883d9efc9 100644 --- a/frontend/editor/src/core/services/pdfExportService.ts +++ b/frontend/editor/src/core/services/pdfExportService.ts @@ -135,11 +135,12 @@ export class PDFExportService { if (page.isBlankPage || page.originalPageNumber === -1) { // Insert a blank A4 page await addNewPage(destDocPtr, insertIdx, A4_WIDTH, A4_HEIGHT); - // Apply rotation - const pdfiumRotation = degreesToPdfiumRotation(page.rotation); - if (pdfiumRotation !== 0) { - await setPageRotation(destDocPtr, insertIdx, pdfiumRotation); - } + // Set absolute rotation (incl. 0) so editor rotation wins over source. + await setPageRotation( + destDocPtr, + insertIdx, + degreesToPdfiumRotation(page.rotation), + ); insertIdx++; } else if (page.originalFileId && loadedDocs.has(page.originalFileId)) { const srcDocPtr = loadedDocs.get(page.originalFileId)!; @@ -155,17 +156,18 @@ export class PDFExportService { pageRange, insertIdx, ); - if (!imported) { + if (imported) { + // Set absolute rotation (incl. 0) so editor rotation wins over source. + await setPageRotation( + destDocPtr, + insertIdx, + degreesToPdfiumRotation(page.rotation), + ); + } else { console.warn( `[PDFExport] importPages failed for fileId=${page.originalFileId} pageRange=${pageRange} — page will be missing from output.`, ); } - - // Apply rotation - const pdfiumRotation = degreesToPdfiumRotation(page.rotation); - if (pdfiumRotation !== 0) { - await setPageRotation(destDocPtr, insertIdx, pdfiumRotation); - } insertIdx++; } } else { @@ -211,10 +213,12 @@ export class PDFExportService { for (const page of pages) { if (page.isBlankPage || page.originalPageNumber === -1) { await addNewPage(destDocPtr, insertIdx, A4_WIDTH, A4_HEIGHT); - const pdfiumRotation = degreesToPdfiumRotation(page.rotation); - if (pdfiumRotation !== 0) { - await setPageRotation(destDocPtr, insertIdx, pdfiumRotation); - } + // Set absolute rotation (incl. 0) so editor rotation wins over source. + await setPageRotation( + destDocPtr, + insertIdx, + degreesToPdfiumRotation(page.rotation), + ); insertIdx++; } else { const sourcePageIndex = page.originalPageNumber - 1; @@ -227,16 +231,18 @@ export class PDFExportService { pageRange, insertIdx, ); - if (!imported) { + if (imported) { + // Set absolute rotation (incl. 0) so editor rotation wins over source. + await setPageRotation( + destDocPtr, + insertIdx, + degreesToPdfiumRotation(page.rotation), + ); + } else { console.warn( `[PDFExport] importPages failed for page ${page.originalPageNumber} pageRange=${pageRange} — page will be missing from output.`, ); } - - const pdfiumRotation = degreesToPdfiumRotation(page.rotation); - if (pdfiumRotation !== 0) { - await setPageRotation(destDocPtr, insertIdx, pdfiumRotation); - } insertIdx++; } } diff --git a/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts b/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts new file mode 100644 index 0000000000..5912c89211 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { PDFDocument } from "@cantoo/pdf-lib"; + +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles, dismissTourTooltip } from "@app/tests/helpers/ui-helpers"; + +// Fixture: 4 portrait pages whose intrinsic /Rotate is 0, 90, 270, 180. +// Page 3 (index 2) is 270 so a single rotate-right lands on a net-0 target - +// the exact case the export used to drop, leaving the source rotation behind. +const ROTATED_PDF = path.join(__dirname, "../test-fixtures/rotated-pages.pdf"); +const SOURCE_ROTATIONS = [0, 90, 270, 180]; + +/** Read the rotation each thumbnail is currently displaying (= page.rotation). */ +async function readEditorRotations(page: import("@playwright/test").Page) { + const imgs = page.locator("[data-page-id] img[data-original-rotation]"); + await expect(imgs).toHaveCount(SOURCE_ROTATIONS.length, { timeout: 30_000 }); + const count = await imgs.count(); + const rots: number[] = []; + for (let i = 0; i < count; i++) { + rots.push( + parseInt( + (await imgs.nth(i).getAttribute("data-original-rotation")) || "NaN", + 10, + ), + ); + } + return rots; +} + +// Skip the fixture's 30s auto-goto; vite's cold on-demand compile can exceed it. +test.use({ autoGoto: false }); + +test.describe("PageEditor (multitool) rotation save", () => { + test("rotating a page persists the correct absolute rotation on export", async ({ + page, + }) => { + await page.goto("/", { waitUntil: "domcontentloaded", timeout: 120_000 }); + await uploadFiles(page, ROTATED_PDF); + // Enter the multitool via in-app navigation, NOT page.goto: a full reload + // wipes the in-memory workbench before PageEditorContext's "entering page + // editor" effect can auto-select the file, leaving the editor empty. + await dismissTourTooltip(page); + await page.getByText("PDF Multi Tool", { exact: true }).first().click(); + + // 1. Baseline: the multitool must seed page.rotation from the source /Rotate, + // otherwise rotated pages render upright and every rotate is off-baseline. + const baseline = await readEditorRotations(page); + expect( + baseline, + "editor must show pages at their true source rotation", + ).toEqual(SOURCE_ROTATIONS); + + // 2. Rotate page 3 (index 2, source /Rotate 270) right once via its + // per-page hover menu. Target rotation is (270 + 90) % 360 = 0. + const page3 = page.locator("[data-page-id]").nth(2); + await page3.scrollIntoViewIfNeeded(); + await page3.hover(); + const rotateRight = page3.getByRole("button", { name: "Rotate Right" }); + await expect(rotateRight).toBeVisible({ timeout: 5_000 }); + await rotateRight.click(); + + // Only page 3 changes (270 -> 0); the others keep their source rotation. + await expect(page3.locator("img[data-original-rotation]")).toHaveAttribute( + "data-original-rotation", + "0", + { timeout: 10_000 }, + ); + expect(await readEditorRotations(page)).toEqual([0, 90, 0, 180]); + + // 3. Ensure all pages are selected, then export, capturing the PDF. + // Pages load all-selected, so "Select All" is disabled - only click it + // if some pages got deselected. + const selectAll = page.getByRole("button", { + name: "Select All", + exact: true, + }); + if (await selectAll.isEnabled()) { + await selectAll.click(); + } + const tmpOut = path.join(os.tmpdir(), `rot-export-${process.pid}.pdf`); + const [download] = await Promise.all([ + page.waitForEvent("download", { timeout: 30_000 }), + page.getByRole("button", { name: "Export Selected Pages" }).click(), + ]); + await download.saveAs(tmpOut); + + // 4. The exported /Rotate must match what the editor showed: page 3 upright + // (0), the untouched pages keeping their source rotation. + const outDoc = await PDFDocument.load(fs.readFileSync(tmpOut)); + const outRotations = outDoc.getPages().map((p) => p.getRotation().angle); + fs.rmSync(tmpOut, { force: true }); + expect(outRotations).toEqual([0, 90, 0, 180]); + }); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/rotated-pages.pdf b/frontend/editor/src/core/tests/test-fixtures/rotated-pages.pdf new file mode 100644 index 0000000000000000000000000000000000000000..53f7a3ac5ac0f2c25264d2b71fc8a1700dc18b87 GIT binary patch literal 2829 zcmY!laBW|#WD1uNhH=h`C5but>0A(V6~LO2 zb$~1YX+?DaNO@6eUI|paeo$(0erZv1YOw-Hom+lh37FxLnp2iql9`;S>y)39qHAto zX<=evU~XZlU;=V0mp+;TOLG$=GYcbAGYcFFj0_A6EDTI7EzF<_?CiMoBm9%HQj@_p zg1D{~CGNo`i6yBZC)nA6JfrWMnv$95lwY9`tze*FpkQKPpkQWT0170>yuADpP)LFU zEUzRr56m+Ixep{Cln*w_0Avg-6hV@(FmcPwDM>9-(09v8EJ<}qP0mkA<_%}X!I zP%t#N1ZgWSDN0Su<*Jxddj5PClOqq?hw6Ds=?8!LnoU0G^l`%$Z~nNJO2&`!(?kQ0 zJb!=Y(b648ENT;;94}w0C%j|d?TKRczaMVgKY!co%5urF&n?%kPTZ4PdX8P_>7GRf z=h)2(SS;qhGuplgvHTx2?_&8f(^&ThhwlsK1{qGzIQR93NTR>f)_#GEpCMUI zE4^E1C9pIoZ_CrENENwbGkM9eUm}wul2v=Z1bVF(^SW9Rzw|@@Kjy0kJ;Ffg2I_j4 z-$6+Y=66JbBriQ!nwuJ!m{}Sbm_uU|Jw2G4nj4uK8=JwS8(o2giK&61g@Ga51W*E? zMS8G6OAnSP2?8Vw3l#hb!W=C@9OOD=z{BDrNGd_#KP3b#L(OnnxJTv91KCF2)K}imJdee zCxS*~m8O&yVi`>IypV zD;tk)dwcKg>nfX&uZyii?OFtS71dWTUY3k7e0zZBGn?!})k7uiOI1x@T{Y@lXK+{H zY{KLO{=W=o4%;jJ(pWW7dD`vZCTZ5I$Al(7^Pi|TWz&?UoUOL3!pU3sX4XlnPBH8d zzh3eBX6Na|9UJFVm#T$xZaf7^&%j)$fpW;-8ciT$0PB@26nI zrSFzmR9vE9XaKKKjf~7t0_JLKJ)Re6 ztWtdK$*lW5Ua<#uu2Rw3zGfy*?9*9G^zU6=y6^dkxVbj5-&U1A+rBe1Y<~5L{ohtb zH~J^OYRh=I?zYFIStWtuL7N``jeAhH&6(%__Z_MA;(ykQZ(HlJ|HrStnT)<40w0|I zsjO$B%H6p8VajBt9~u4eX1fY2ZnQpD+3-Di#vFTweU|&bs+apN*=GOvW`hB?fCM$2 zV2LO=vno}=(3DF*C_leM0hGLgxb!{q(()BR!5st&K`hM^NJvKnrKZ6H*35!SKU^VN z!BD|S!3fmu@yts}g*2B8O%-BM0zEk);m7~^6CE2G8z(BxYi#Un6n8Q(GV)>RGSp)g z^5Je|`D~_ea1*1)203=2oZ}5Fzhe~+77BY5Xc`(YFeG+zW@7PHaYn0LCcXod5s; literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/utils/thumbnailUtils.ts b/frontend/editor/src/core/utils/thumbnailUtils.ts index a4efafbff5..838992e38b 100644 --- a/frontend/editor/src/core/utils/thumbnailUtils.ts +++ b/frontend/editor/src/core/utils/thumbnailUtils.ts @@ -192,15 +192,16 @@ export async function generateThumbnailWithMetadata( } const scale = calculateScaleFromFileSize(file.size); - const isVeryLarge = file.size >= 100 * 1024 * 1024; // 100MB threshold try { const arrayBuffer = await file.arrayBuffer(); + // Always read per-page rotation: PageEditor renders thumbnails upright and + // uses this as the rotation baseline, so skipping it corrupts saves. const result = await renderPdfThumbnailPdfium( arrayBuffer, scale, applyRotation, - !isVeryLarge, + true, ); if (result.isEncrypted) { From b9ea9064c73291edf9bdb812b52f5ccc3ebf13ac Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 19 Jun 2026 16:27:14 +0100 Subject: [PATCH 04/16] Cache Rust build to improve Tauri build job times (#6732) # Description of Changes The Tauri jobs are very slow, especially the Linux ones, which can take >1hr to build all the necessary code. A lot of that is because of the actual Rust compilation, which isn't cached at all as far as I can tell. This introduces a cache step for the Rust dependencies, so PRs will just reuse the compiled Rust from the last build of main (if it's safe to do so). --- .github/workflows/tauri-build.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index d8bece6803..8968df4250 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -115,6 +115,15 @@ jobs: toolchain: stable targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + # Cache the Cargo registry and compiled dependency crates so the build + # only recompiles the app crate. Written on main; PRs and the merge queue + # restore from it. + - name: Cache Rust build + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: frontend/editor/src-tauri + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Set up x86_64 JDK 25 (macOS universal JRE) if: matrix.platform == 'macos-15' uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 From 3870ac3d7dd41203f38951ba09ac73f7d60ab53b Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:33:49 +0100 Subject: [PATCH 05/16] Add desktop mobile-upload page and fix LAN QR URL (#6736) # Description of Changes Desktop can not use QR code upload due to API backend not having UI for it... Because of this we add UI, has to be custom because can not support OpenCV and in app camera due to its https requirement image image image image --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .gitignore | 1 + .../software/common/util/GeneralUtils.java | 184 +++++- .../common/util/GeneralUtilsLocalIpTest.java | 114 ++++ app/core/build.gradle | 4 +- .../web/ReactRoutingController.java | 41 ++ .../main/resources/static/mobile-upload.html | 572 ++++++++++++++++++ .../web/ReactRoutingControllerTest.java | 32 + 7 files changed, 933 insertions(+), 15 deletions(-) create mode 100644 app/common/src/test/java/stirling/software/common/util/GeneralUtilsLocalIpTest.java create mode 100644 app/core/src/main/resources/static/mobile-upload.html diff --git a/.gitignore b/.gitignore index a379cf1db0..f47b020013 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ app/core/src/main/resources/static/index.html # Prerendered per-route SPA pages (OG/social-preview), e.g. compress.html. api-landing.html is source. app/core/src/main/resources/static/*.html !app/core/src/main/resources/static/api-landing.html +!app/core/src/main/resources/static/mobile-upload.html # Prerendered nested-route pages (e.g. settings/people.html) app/core/src/main/resources/static/settings/ app/core/src/main/resources/static/locales/ diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index fbcf30fdff..4a9ef0834b 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -1185,23 +1185,181 @@ public class GeneralUtils { } public String getLocalNetworkIp() { + String routed = detectLocalIpViaDefaultRoute(); + if (routed != null) { + return routed; + } try { - Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); - if (interfaces == null) return null; - while (interfaces.hasMoreElements()) { - NetworkInterface iface = interfaces.nextElement(); - if (!iface.isUp() || iface.isLoopback() || iface.isVirtual()) continue; - Enumeration addresses = iface.getInetAddresses(); - while (addresses.hasMoreElements()) { - InetAddress addr = addresses.nextElement(); - if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) { - return addr.getHostAddress(); - } - } - } + return selectBestSiteLocalIp(collectInterfaceInfo()); } catch (Exception e) { log.warn("Failed to detect local network IP", e); + return null; + } + } + + private String detectLocalIpViaDefaultRoute() { + try (DatagramSocket socket = new DatagramSocket()) { + socket.connect(InetAddress.getByName("8.8.8.8"), 53); + InetAddress local = socket.getLocalAddress(); + if (local instanceof Inet4Address + && !local.isAnyLocalAddress() + && !local.isLoopbackAddress() + && !local.isLinkLocalAddress()) { + return local.getHostAddress(); + } + } catch (Exception e) { + log.debug("Default-route IP detection failed; will scan interfaces", e); } return null; } + + private List collectInterfaceInfo() throws SocketException { + List infos = new ArrayList<>(); + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + if (interfaces == null) { + return infos; + } + while (interfaces.hasMoreElements()) { + NetworkInterface iface = interfaces.nextElement(); + + List siteLocalIpv4s = new ArrayList<>(); + Enumeration addresses = iface.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress addr = addresses.nextElement(); + if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) { + siteLocalIpv4s.add(addr.getHostAddress()); + } + } + if (siteLocalIpv4s.isEmpty()) { + continue; + } + + try { + byte[] mac = iface.getHardwareAddress(); + infos.add( + new NetworkInterfaceInfo( + iface.getName(), + iface.getDisplayName(), + iface.getIndex(), + iface.isUp(), + iface.isLoopback(), + iface.isPointToPoint(), + iface.isVirtual(), + mac != null && mac.length > 0, + siteLocalIpv4s)); + } catch (SocketException e) { + log.debug("Skipping interface {} while scanning for local IP", iface.getName(), e); + } + } + return infos; + } + + static String selectBestSiteLocalIp(List interfaces) { + return interfaces.stream() + .filter(i -> i.up() && !i.loopback() && !i.pointToPoint() && !i.virtual()) + .filter(i -> !isLikelyVirtualInterface(i.name(), i.displayName())) + .flatMap( + i -> + i.siteLocalIpv4s().stream() + .map( + ip -> + new ScoredAddress( + ip, + scoreInterface(i, ip), + i.index()))) + .max( + Comparator.comparingInt(ScoredAddress::score) + .thenComparing( + Comparator.comparingInt(ScoredAddress::interfaceIndex) + .reversed())) + .map(ScoredAddress::ip) + .orElse(null); + } + + private static int scoreInterface(NetworkInterfaceInfo iface, String ip) { + int score = 0; + if (isLikelyPhysicalInterface(iface.name(), iface.displayName())) { + score += 100; + } + if (iface.hasHardwareAddress()) { + score += 20; + } + if (ip.startsWith("192.168.")) { + score += 30; + } else if (ip.startsWith("10.")) { + score += 20; + } else { + score += 5; + } + return score; + } + + static boolean isLikelyVirtualInterface(String name, String displayName) { + String n = name == null ? "" : name.toLowerCase(Locale.ROOT); + String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT); + String[] namePrefixes = { + "tun", "tap", "utun", "veth", "virbr", "vmnet", "docker", "br-", "wg", "ppp", "awdl", + "llw" + }; + for (String prefix : namePrefixes) { + if (n.startsWith(prefix)) { + return true; + } + } + String[] displayMarkers = { + "vmware", + "virtualbox", + "virtual box", + "vbox", + "hyper-v", + "hyperv", + "vethernet", + "windows subsystem for linux", + "wsl", + "docker", + "tap-windows", + "tunnel", + "vpn", + "zerotier", + "tailscale", + "bluetooth", + "teredo", + "isatap", + "loopback", + "pseudo", + "virtual" + }; + for (String marker : displayMarkers) { + if (d.contains(marker)) { + return true; + } + } + return false; + } + + private static boolean isLikelyPhysicalInterface(String name, String displayName) { + String n = name == null ? "" : name.toLowerCase(Locale.ROOT); + String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT); + return n.startsWith("eth") + || n.startsWith("en") + || n.startsWith("wl") + || n.startsWith("em") + || d.contains("ethernet") + || d.contains("wi-fi") + || d.contains("wifi") + || d.contains("wireless"); + } + + record NetworkInterfaceInfo( + String name, + String displayName, + int index, + boolean up, + boolean loopback, + boolean pointToPoint, + boolean virtual, + boolean hasHardwareAddress, + List siteLocalIpv4s) {} + + private record ScoredAddress(String ip, int score, int interfaceIndex) {} } diff --git a/app/common/src/test/java/stirling/software/common/util/GeneralUtilsLocalIpTest.java b/app/common/src/test/java/stirling/software/common/util/GeneralUtilsLocalIpTest.java new file mode 100644 index 0000000000..5e106f096c --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/GeneralUtilsLocalIpTest.java @@ -0,0 +1,114 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.util.GeneralUtils.NetworkInterfaceInfo; + +class GeneralUtilsLocalIpTest { + + private static NetworkInterfaceInfo iface( + String name, String displayName, int index, boolean virtual, String... ips) { + return new NetworkInterfaceInfo( + name, displayName, index, true, false, false, virtual, true, List.of(ips)); + } + + @Test + void prefersPhysicalWifiOverVmwareNatAdapter() { + NetworkInterfaceInfo vmware = + iface("eth5", "VMware Virtual Ethernet Adapter for VMnet8", 5, false, "172.16.1.1"); + NetworkInterfaceInfo wifi = + iface("wlan0", "Intel(R) Wi-Fi 6 AX201", 12, false, "192.168.1.50"); + + assertEquals("192.168.1.50", GeneralUtils.selectBestSiteLocalIp(List.of(vmware, wifi))); + } + + @Test + void excludesHyperVVethernetAdapter() { + NetworkInterfaceInfo hyperv = + iface("ethernet_32770", "Hyper-V Virtual Ethernet Adapter", 3, false, "172.28.0.1"); + NetworkInterfaceInfo ethernet = + iface("eth0", "Realtek PCIe GbE Family Controller", 8, false, "192.168.0.20"); + + assertEquals("192.168.0.20", GeneralUtils.selectBestSiteLocalIp(List.of(hyperv, ethernet))); + } + + @Test + void excludesWslAndDockerBridges() { + NetworkInterfaceInfo wsl = + iface("eth1", "Hyper-V Virtual Ethernet Adapter (WSL)", 70, false, "172.20.0.1"); + NetworkInterfaceInfo docker = iface("docker0", "docker0", 4, false, "172.17.0.1"); + NetworkInterfaceInfo lan = + iface("eth0", "Intel(R) Ethernet Connection", 2, false, "10.0.0.5"); + + assertEquals("10.0.0.5", GeneralUtils.selectBestSiteLocalIp(List.of(wsl, docker, lan))); + } + + @Test + void prefers192Over10WhenBothPhysical() { + NetworkInterfaceInfo ten = iface("eth0", "Ethernet", 2, false, "10.1.2.3"); + NetworkInterfaceInfo home = iface("wlan0", "Wi-Fi", 6, false, "192.168.1.10"); + + assertEquals("192.168.1.10", GeneralUtils.selectBestSiteLocalIp(List.of(ten, home))); + } + + @Test + void breaksTiesByLowestInterfaceIndex() { + NetworkInterfaceInfo first = iface("eth0", "Ethernet", 2, false, "192.168.1.2"); + NetworkInterfaceInfo second = iface("eth1", "Ethernet", 9, false, "192.168.1.3"); + + assertEquals("192.168.1.2", GeneralUtils.selectBestSiteLocalIp(List.of(second, first))); + } + + @Test + void returnsNullWhenOnlyVirtualOrDownInterfaces() { + NetworkInterfaceInfo vbox = + iface("vboxnet0", "VirtualBox Host-Only Network", 1, false, "192.168.56.1"); + NetworkInterfaceInfo flaggedVirtual = + new NetworkInterfaceInfo( + "eth9", + "Ethernet", + 9, + true, + false, + false, + true, + true, + List.of("192.168.1.9")); + NetworkInterfaceInfo down = + new NetworkInterfaceInfo( + "eth0", + "Ethernet", + 2, + false, + false, + false, + false, + true, + List.of("192.168.1.2")); + + assertNull(GeneralUtils.selectBestSiteLocalIp(List.of(vbox, flaggedVirtual, down))); + } + + @Test + void isLikelyVirtualInterfaceFlagsKnownAdaptersButNotRealNics() { + assertTrue( + GeneralUtils.isLikelyVirtualInterface( + "vEthernet", "Hyper-V Virtual Ethernet Adapter")); + assertTrue(GeneralUtils.isLikelyVirtualInterface("docker0", "docker0")); + assertTrue( + GeneralUtils.isLikelyVirtualInterface("eth0", "VMware Virtual Ethernet Adapter")); + assertTrue(GeneralUtils.isLikelyVirtualInterface("tun0", "WireGuard tunnel")); + + assertFalse(GeneralUtils.isLikelyVirtualInterface("wlan0", "Intel(R) Wi-Fi 6 AX201")); + assertFalse( + GeneralUtils.isLikelyVirtualInterface( + "eth0", "Realtek PCIe GbE Family Controller")); + } +} diff --git a/app/core/build.gradle b/app/core/build.gradle index e505ec9838..21acdb36e0 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -332,8 +332,8 @@ tasks.register('cleanFrontendAssets', Delete) { delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) } // Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are // copied from the frontend build. Remove stale ones so renamed/removed tools don't linger. - // api-landing.html is a real backend source file, not a generated artifact. - delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html']) + // api-landing.html and mobile-upload.html are real backend source files, not generated artifacts. + delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html']) // Nested prerendered route pages (e.g. settings/people.html) delete new File(resourcesStaticDir, 'settings') } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 62ce9dac01..1e05ec17b2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -42,6 +42,8 @@ public class ReactRoutingController { private boolean loggedMissingIndex = false; private String cachedSaasLandingHtml; private boolean saasLandingExists = false; + private String cachedMobileUploadHtml; + private boolean mobileUploadHtmlExists = false; @PostConstruct public void init() { @@ -64,6 +66,12 @@ public class ReactRoutingController { } } + // Desktop (Tauri) serves the SPA from its bundled webview, so a phone scanning the QR can't + // load the React /mobile-scanner route from the local backend. Cache the self-contained + // static upload page to serve at that route in desktop mode instead. + this.cachedMobileUploadHtml = readStaticHtml("mobile-upload.html"); + this.mobileUploadHtmlExists = this.cachedMobileUploadHtml != null; + // Check for external index.html first (customFiles/static/) Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html"); log.debug("Checking for custom index.html at: {}", externalIndexPath); @@ -144,6 +152,28 @@ public class ReactRoutingController { return new ClassPathResource("static/index.html"); } + private String readStaticHtml(String filename) { + try { + Path external = Path.of(InstallationPathConfig.getStaticPath(), filename); + if (Files.exists(external) && Files.isReadable(external)) { + return Files.readString(external, StandardCharsets.UTF_8); + } + ClassPathResource resource = new ClassPathResource("static/" + filename); + if (resource.exists()) { + try (InputStream in = resource.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + } catch (Exception ex) { + log.warn("Failed to read static HTML {}", filename, ex); + } + return null; + } + + private static boolean isDesktopMode() { + return Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false")); + } + @GetMapping( value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE) @@ -191,6 +221,17 @@ public class ReactRoutingController { return serveIndexHtml(request); } + @GetMapping(value = "/mobile-scanner", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity serveMobileScanner(HttpServletRequest request) { + if (isDesktopMode() && mobileUploadHtmlExists) { + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().mustRevalidate()) + .contentType(MediaType.TEXT_HTML) + .body(cachedMobileUploadHtml); + } + return serveIndexHtml(request); + } + @GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE) public ResponseEntity serveTauriAuthCallback(HttpServletRequest request) { // cachedCallbackHtml is always initialized in @PostConstruct diff --git a/app/core/src/main/resources/static/mobile-upload.html b/app/core/src/main/resources/static/mobile-upload.html new file mode 100644 index 0000000000..f96f9f4f53 --- /dev/null +++ b/app/core/src/main/resources/static/mobile-upload.html @@ -0,0 +1,572 @@ + + + + + + + + Stirling PDF - Mobile Upload + + + + + + + + +
+
+ +
+ + + + +
Mobile Upload
+
+
+ +
+
Connecting…
+ +
+ + +
+ + + + +
+ + + + + +

Add photos or files, then upload. They appear on your computer automatically.

+
+ + + +
Stirling PDF · files transfer directly to your desktop
+
+ + + + diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java index d1305ff763..2df3fb5587 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java @@ -95,6 +95,38 @@ class ReactRoutingControllerTest { assertTrue(body.contains("Stirling PDF")); } + // --- mobile scanner route --- + + @Test + void serveMobileScanner_webMode_servesSpaNotUploadPage() { + controller.init(); + + ResponseEntity response = controller.serveMobileScanner(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + String body = response.getBody(); + assertNotNull(body); + assertFalse(body.contains("Take Photo")); + } + + @Test + void serveMobileScanner_desktopMode_servesStaticUploadPage() { + controller.init(); + System.setProperty("STIRLING_PDF_TAURI_MODE", "true"); + try { + ResponseEntity response = controller.serveMobileScanner(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(MediaType.TEXT_HTML, response.getHeaders().getContentType()); + String body = response.getBody(); + assertNotNull(body); + assertTrue(body.contains("Mobile Upload")); + assertTrue(body.contains("Take Photo")); + } finally { + System.clearProperty("STIRLING_PDF_TAURI_MODE"); + } + } + // --- tauri auth callback --- @Test From 1a770af47c53e61ba9cd600b9ed8eb8edd09d421 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:06:28 +0100 Subject: [PATCH 06/16] fix create tool in the AI chat (#6673) AI PDF creation ("create a PDF for me") has been broken since the Policies backend (#6527) introduced PolicyExecutor as the tool execution pipeline. PolicyExecutor runs normal single-input tools with a per-file loop, but generator tools like `create-pdf-from-html-agent` take no input file and build their output purely from parameters. With zero input files the loop ran zero times, so the endpoint was never called and the step silently produced nothing. The chat reported success ("Created Purchase Order") while no document ever appeared. This adds an `else if (inputFiles.isEmpty())` branch so a generator tool is called once with an empty file list, matching what the multi-input branch already does for an empty input. Two files changed: the one-line-ish fix in `PolicyExecutor`, and a regression test covering the no-input case. --------- Co-authored-by: James Brunton --- .../policy/engine/PolicyExecutor.java | 4 +++ .../policy/engine/PolicyExecutorTest.java | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java index af9f8c7283..fe88b40391 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java @@ -116,6 +116,10 @@ public class PolicyExecutor { ToolResult r = callEndpoint(step, inputFiles, supportingFiles); files.addAll(r.files()); report = r.report(); + } else if (inputFiles.isEmpty()) { + ToolResult r = callEndpoint(step, List.of(), supportingFiles); + files.addAll(r.files()); + report = r.report(); } else { for (Resource file : inputFiles) { ToolResult r = callEndpoint(step, List.of(file), supportingFiles); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java index d682c35cd7..9b10d9090e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.engine; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -149,6 +150,40 @@ class PolicyExecutorTest { verify(internalApiClient, times(2)).post(eq(ROTATE), any()); } + @Test + void noInputGeneratorEndpointIsCalledOnceWithNoFile() throws IOException { + // A "create" workflow has no source documents: a generator tool (e.g. + // create-pdf-from-html-agent) produces its output purely from parameters. Per-file + // dispatch would skip it entirely (zero files = zero calls), so it must still run once. + String createPdf = "/api/v1/ai/tools/create-pdf-from-html-agent"; + when(toolMetadataService.isMultiInput(createPdf)).thenReturn(false); + when(toolMetadataService.shouldUnpackZipResponse(createPdf)).thenReturn(false); + stubEndpoint(createPdf, pdf("generated", "purchase-order.pdf")); + + PolicyExecutionResult result = + executor.execute( + definition( + new PipelineStep( + createPdf, + Map.of( + "htmlContent", + "

hi

", + "filename", + "purchase-order.pdf"))), + PolicyInputs.of(List.of()), + PolicyProgressListener.NOOP); + + assertEquals(1, result.files().size()); + assertEquals("purchase-order.pdf", result.files().get(0).getFilename()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> bodyCaptor = + ArgumentCaptor.forClass(MultiValueMap.class); + verify(internalApiClient, times(1)).post(eq(createPdf), bodyCaptor.capture()); + // No document stream: the body carries only the generator's parameters, no fileInput. + assertNull(bodyCaptor.getValue().get("fileInput")); + } + @Test void zipResponseIsUnpackedIntoIndividualFiles() throws IOException { when(toolMetadataService.isMultiInput(SPLIT)).thenReturn(false); From a3fe15bfd03f721d6e57f11d9c28fe5ae0fc7e61 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:57:03 +0100 Subject: [PATCH 07/16] Add metrics for numerical count of total PDFs (#6737) --- .../SPDF/config/PdfMetricsInterceptor.java | 80 +++++++++++++ .../software/SPDF/config/WebMvcConfig.java | 2 + .../SPDF/service/PdfMetricsService.java | 66 +++++++++++ .../config/PdfMetricsInterceptorTest.java | 107 ++++++++++++++++++ .../SPDF/service/PdfMetricsServiceTest.java | 79 +++++++++++++ .../configuration/SecurityConfiguration.java | 3 +- .../saas/security/SupabaseSecurityConfig.java | 3 +- .../pageEditor/hooks/usePageEditorExport.ts | 1 + .../components/shared/signing/SignPopout.tsx | 4 +- .../certSign/SignRequestWorkbenchView.tsx | 2 +- .../editor/src/core/contexts/FileContext.tsx | 2 + .../src/core/contexts/file/fileActions.ts | 6 + .../hooks/tools/shared/useToolOperation.ts | 6 + .../src/core/services/analytics.test.ts | 60 ++++++++++ .../editor/src/core/services/analytics.ts | 40 +++++++ .../src/core/services/fileSyncService.ts | 2 + frontend/editor/src/core/types/fileContext.ts | 7 +- .../components/policies/usePolicyAutoRun.ts | 7 +- .../proprietary/services/apiClientSetup.ts | 3 + .../editor/src/saas/services/apiClient.ts | 3 + 20 files changed, 475 insertions(+), 8 deletions(-) create mode 100644 app/core/src/main/java/stirling/software/SPDF/config/PdfMetricsInterceptor.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/service/PdfMetricsService.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/config/PdfMetricsInterceptorTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/service/PdfMetricsServiceTest.java create mode 100644 frontend/editor/src/core/services/analytics.test.ts create mode 100644 frontend/editor/src/core/services/analytics.ts diff --git a/app/core/src/main/java/stirling/software/SPDF/config/PdfMetricsInterceptor.java b/app/core/src/main/java/stirling/software/SPDF/config/PdfMetricsInterceptor.java new file mode 100644 index 0000000000..a99e31a2df --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/PdfMetricsInterceptor.java @@ -0,0 +1,80 @@ +package stirling.software.SPDF.config; + +import java.util.List; + +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.service.PdfMetricsService; + +@Component +@Slf4j +@RequiredArgsConstructor +public class PdfMetricsInterceptor implements HandlerInterceptor { + + private final PdfMetricsService pdfMetricsService; + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, + Exception ex) { + try { + if (!pdfMetricsService.isEnabled()) { + return; + } + if (!"POST".equalsIgnoreCase(request.getMethod()) || response.getStatus() >= 400) { + return; + } + String path = request.getServletPath(); + if (path == null || path.isBlank()) { + path = request.getRequestURI(); + } + if (path == null || !path.contains("/api/v1/")) { + return; + } + if (!(request instanceof MultipartHttpServletRequest multipart)) { + return; + } + if (isFromEditor(request)) { + return; + } + + int fileCount = 0; + for (List bucket : multipart.getMultiFileMap().values()) { + fileCount += bucket.size(); + } + if (fileCount == 0) { + return; + } + + pdfMetricsService.recordOperation(fileCount); + } catch (Exception e) { + log.debug("Failed to record PDF metrics", e); + } + } + + // Editor traffic carries X-Browser-Id, or (if a proxy strips it) a logged-in user's JWT. + // JWTs start "eyJ" and have two dots; API keys do not, so they still count as API. + private boolean isFromEditor(HttpServletRequest request) { + String browserId = request.getHeader("X-Browser-Id"); + if (browserId != null && !browserId.isBlank()) { + return true; + } + String auth = request.getHeader("Authorization"); + if (auth == null || !auth.regionMatches(true, 0, "Bearer ", 0, 7)) { + return false; + } + String token = auth.substring(7).trim(); + return token.startsWith("eyJ") && token.chars().filter(c -> c == '.').count() == 2; + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 367c875744..dac9816018 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -24,6 +24,7 @@ import stirling.software.common.model.ApplicationProperties; public class WebMvcConfig implements WebMvcConfigurer { private final EndpointInterceptor endpointInterceptor; + private final PdfMetricsInterceptor pdfMetricsInterceptor; private final ApplicationProperties applicationProperties; private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class); @@ -35,6 +36,7 @@ public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(endpointInterceptor); + registry.addInterceptor(pdfMetricsInterceptor); } @Override diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfMetricsService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfMetricsService.java new file mode 100644 index 0000000000..173ca6e551 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfMetricsService.java @@ -0,0 +1,66 @@ +package stirling.software.SPDF.service; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.PostHogService; + +@Service +public class PdfMetricsService { + + private final PostHogService postHogService; + private final ApplicationProperties applicationProperties; + + private final AtomicLong operations = new AtomicLong(); + private final AtomicLong pdfs = new AtomicLong(); + private long lastOperations; + private long lastPdfs; + + public PdfMetricsService( + PostHogService postHogService, ApplicationProperties applicationProperties) { + this.postHogService = postHogService; + this.applicationProperties = applicationProperties; + } + + public boolean isEnabled() { + return applicationProperties.getSystem().isPosthogEnabled(); + } + + public void recordOperation(int pdfCount) { + if (!isEnabled()) { + return; + } + operations.incrementAndGet(); + if (pdfCount > 0) { + pdfs.addAndGet(pdfCount); + } + } + + @Scheduled(fixedRate = 7200000) + public void flushMetrics() { + if (!isEnabled()) { + return; + } + long curOps = operations.get(); + long curPdfs = pdfs.get(); + long opsDelta = curOps - lastOperations; + long pdfsDelta = curPdfs - lastPdfs; + if (opsDelta <= 0 && pdfsDelta <= 0) { + return; + } + + Map props = new HashMap<>(); + props.put("source", "api"); + props.put("operations", opsDelta); + props.put("pdfs", pdfsDelta); + postHogService.captureEvent("pdf_operation_metrics", props); + + lastOperations = curOps; + lastPdfs = curPdfs; + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/PdfMetricsInterceptorTest.java b/app/core/src/test/java/stirling/software/SPDF/config/PdfMetricsInterceptorTest.java new file mode 100644 index 0000000000..08a8a8b762 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/PdfMetricsInterceptorTest.java @@ -0,0 +1,107 @@ +package stirling.software.SPDF.config; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; + +import jakarta.servlet.http.HttpServletResponse; + +import stirling.software.SPDF.service.PdfMetricsService; + +class PdfMetricsInterceptorTest { + + private PdfMetricsService service; + private PdfMetricsInterceptor interceptor; + + @BeforeEach + void setUp() { + service = mock(PdfMetricsService.class); + when(service.isEnabled()).thenReturn(true); + interceptor = new PdfMetricsInterceptor(service); + } + + private MultipartHttpServletRequest editRequest(int fileParts, String... headers) { + MultipartHttpServletRequest request = mock(MultipartHttpServletRequest.class); + when(request.getMethod()).thenReturn("POST"); + when(request.getServletPath()).thenReturn("/api/v1/general/rotate-pdf"); + for (int i = 0; i + 1 < headers.length; i += 2) { + when(request.getHeader(headers[i])).thenReturn(headers[i + 1]); + } + MultiValueMap files = new LinkedMultiValueMap<>(); + for (int i = 0; i < fileParts; i++) { + files.add("fileInput", mock(MultipartFile.class)); + } + when(request.getMultiFileMap()).thenReturn(files); + return request; + } + + private HttpServletResponse response(int status, String contentType) { + HttpServletResponse response = mock(HttpServletResponse.class); + when(response.getStatus()).thenReturn(status); + when(response.getContentType()).thenReturn(contentType); + return response; + } + + @Test + void apiRequestIsCounted() { + interceptor.afterCompletion(editRequest(1), response(200, "application/pdf"), null, null); + verify(service).recordOperation(1); + } + + @Test + void countsEveryFilePartUnderOneFieldName() { + interceptor.afterCompletion(editRequest(3), response(200, "application/pdf"), null, null); + verify(service).recordOperation(3); + } + + @Test + void countsRegardlessOfResponseType() { + interceptor.afterCompletion(editRequest(1), response(200, "application/json"), null, null); + verify(service).recordOperation(1); + } + + @Test + void editorRequestWithBrowserIdIsNotCounted() { + interceptor.afterCompletion( + editRequest(1, "X-Browser-Id", "abc-123"), + response(200, "application/pdf"), + null, + null); + verify(service, never()).recordOperation(anyInt()); + } + + @Test + void editorJwtWithoutBrowserIdIsNotCounted() { + interceptor.afterCompletion( + editRequest(1, "Authorization", "Bearer eyJhbG.eyJzdWI.sig"), + response(200, "application/pdf"), + null, + null); + verify(service, never()).recordOperation(anyInt()); + } + + @Test + void bearerApiKeyIsCounted() { + interceptor.afterCompletion( + editRequest(1, "Authorization", "Bearer sk-not-a-jwt-key"), + response(200, "application/pdf"), + null, + null); + verify(service).recordOperation(1); + } + + @Test + void errorResponseIsNotCounted() { + interceptor.afterCompletion(editRequest(1), response(500, "application/pdf"), null, null); + verify(service, never()).recordOperation(anyInt()); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/PdfMetricsServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/PdfMetricsServiceTest.java new file mode 100644 index 0000000000..997e5936c8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/PdfMetricsServiceTest.java @@ -0,0 +1,79 @@ +package stirling.software.SPDF.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.PostHogService; + +class PdfMetricsServiceTest { + + private PostHogService postHogService; + private ApplicationProperties applicationProperties; + private PdfMetricsService service; + + @BeforeEach + void setUp() { + postHogService = mock(PostHogService.class); + applicationProperties = new ApplicationProperties(); + applicationProperties.getSystem().setEnableAnalytics(true); + service = new PdfMetricsService(postHogService, applicationProperties); + } + + @Test + void flushesOperationAndPdfCounts() { + service.recordOperation(1); + service.recordOperation(2); + + service.flushMetrics(); + + Map event = captureEvent(); + assertEquals("api", event.get("source")); + assertEquals(2L, event.get("operations")); + assertEquals(3L, event.get("pdfs")); + } + + @Test + void sendsOnlyDeltasBetweenFlushes() { + service.recordOperation(1); + service.flushMetrics(); + reset(postHogService); + + service.flushMetrics(); + verify(postHogService, never()).captureEvent(eq("pdf_operation_metrics"), anyMap()); + + service.recordOperation(2); + service.flushMetrics(); + + Map event = captureEvent(); + assertEquals(1L, event.get("operations")); + assertEquals(2L, event.get("pdfs")); + } + + @Test + void doesNothingWhenAnalyticsDisabled() { + applicationProperties.getSystem().setEnableAnalytics(false); + + service.recordOperation(1); + service.flushMetrics(); + + verify(postHogService, never()).captureEvent(eq("pdf_operation_metrics"), anyMap()); + } + + private Map captureEvent() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(postHogService).captureEvent(eq("pdf_operation_metrics"), captor.capture()); + return captor.getValue(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 3ca3841265..20a9cb2628 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -202,7 +202,8 @@ public class SecurityConfiguration { "Origin", "X-API-KEY", "X-CSRF-TOKEN", - "X-XSRF-TOKEN")); + "X-XSRF-TOKEN", + "X-Browser-Id")); cfg.setExposedHeaders( List.of( diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index c0a78d56e0..967b725b5e 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -296,7 +296,8 @@ public class SupabaseSecurityConfig { "X-Requested-With", "Accept", "Origin", - "X-API-KEY")); + "X-API-KEY", + "X-Browser-Id")); cfg.setExposedHeaders(List.of("WWW-Authenticate")); cfg.setAllowCredentials(true); cfg.setMaxAge(3600L); diff --git a/frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts b/frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts index 2aca4a1d07..35167564f7 100644 --- a/frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts +++ b/frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts @@ -306,6 +306,7 @@ export const usePageEditorExport = ({ const newStirlingFiles = await actions.addFiles(renamedFiles, { selectFiles: true, + skipUploadTracking: true, }); if (newStirlingFiles.length > 0) { actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId)); diff --git a/frontend/editor/src/core/components/shared/signing/SignPopout.tsx b/frontend/editor/src/core/components/shared/signing/SignPopout.tsx index 3e7eb0c9bd..aece0cb2da 100644 --- a/frontend/editor/src/core/components/shared/signing/SignPopout.tsx +++ b/frontend/editor/src/core/components/shared/signing/SignPopout.tsx @@ -669,7 +669,7 @@ const SignPopout = ({ const signedFile = new File([response.data], filename, { type: "application/pdf", }); - await fileActions.addFiles([signedFile]); + await fileActions.addFiles([signedFile], { skipUploadTracking: true }); alert({ alertType: "success", title: t("success"), @@ -700,7 +700,7 @@ const SignPopout = ({ const signedFile = new File([response.data], filename, { type: "application/pdf", }); - await fileActions.addFiles([signedFile]); + await fileActions.addFiles([signedFile], { skipUploadTracking: true }); alert({ alertType: "success", title: t("success"), diff --git a/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx b/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx index 04756abdfe..fa2512fd04 100644 --- a/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx @@ -258,7 +258,7 @@ const SignRequestWorkbenchView = ({ data }: SignRequestWorkbenchViewProps) => { }; const handleAddToActiveFiles = async () => { - await fileActions.addFiles([pdfFile]); + await fileActions.addFiles([pdfFile], { skipUploadTracking: true }); alert({ alertType: "success", title: t("success"), diff --git a/frontend/editor/src/core/contexts/FileContext.tsx b/frontend/editor/src/core/contexts/FileContext.tsx index 6621bf0efd..0840d49a72 100644 --- a/frontend/editor/src/core/contexts/FileContext.tsx +++ b/frontend/editor/src/core/contexts/FileContext.tsx @@ -243,6 +243,7 @@ function FileContextInner({ skipAutoUnzip?: boolean; /** Persist to IDB without dispatching to workspace state. */ skipWorkspaceDispatch?: boolean; + skipUploadTracking?: boolean; }, ): Promise => { const stirlingFiles = await addFiles( @@ -286,6 +287,7 @@ function FileContextInner({ fileName: string, ) => Promise; allowDuplicates?: boolean; + skipUploadTracking?: boolean; }, ): Promise => { const stirlingFiles = await addFiles( diff --git a/frontend/editor/src/core/contexts/file/fileActions.ts b/frontend/editor/src/core/contexts/file/fileActions.ts index c4d6ff1f88..749e820e41 100644 --- a/frontend/editor/src/core/contexts/file/fileActions.ts +++ b/frontend/editor/src/core/contexts/file/fileActions.ts @@ -20,6 +20,7 @@ import { StirlingFile } from "@app/types/fileContext"; import { fileStorage } from "@app/services/fileStorage"; import { zipFileService } from "@app/services/zipFileService"; import { FileAnalyzer } from "@app/services/fileAnalyzer"; +import { trackPdfUploaded } from "@app/services/analytics"; const DEBUG = process.env.NODE_ENV === "development"; const HYDRATION_CONCURRENCY = 2; let activeHydrations = 0; @@ -252,6 +253,7 @@ interface AddFileOptions { fileName: string, ) => Promise; // Optional callback to confirm extraction of large ZIP files allowDuplicates?: boolean; + skipUploadTracking?: boolean; } /** @@ -538,6 +540,10 @@ export async function addFiles( ); } + if (!options.skipUploadTracking && stirlingFiles.length > 0) { + trackPdfUploaded(stirlingFiles); + } + return stirlingFiles; } finally { // Always release mutex even if error occurs diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts index 52b4ce379d..5629dfe54b 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts @@ -30,6 +30,7 @@ import { import { createNewStirlingFileStub } from "@app/types/fileContext"; import { ToolOperation } from "@app/types/file"; import { ensureBackendReady } from "@app/services/backendReadinessGuard"; +import { trackEditorOperation } from "@app/services/analytics"; import { useWillUseCloud } from "@app/hooks/useWillUseCloud"; import { useCreditCheck } from "@app/hooks/useCreditCheck"; import { notifyPdfProcessingComplete } from "@app/services/desktopNotificationService"; @@ -384,6 +385,11 @@ export const useToolOperation = ( } if (processedFiles.length > 0) { + trackEditorOperation( + config.operationType, + successSourceIds.length || validFiles.length, + ); + actions.setFiles(processedFiles); // Generate thumbnails and download URL concurrently diff --git a/frontend/editor/src/core/services/analytics.test.ts b/frontend/editor/src/core/services/analytics.test.ts new file mode 100644 index 0000000000..3334066cb9 --- /dev/null +++ b/frontend/editor/src/core/services/analytics.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const capture = vi.fn(); +let optedIn = true; + +vi.mock("posthog-js", () => ({ + default: { + __loaded: true, + has_opted_in_capturing: () => optedIn, + capture: (...args: unknown[]) => capture(...args), + }, +})); + +import { + trackPdfUploaded, + trackEditorOperation, +} from "@app/services/analytics"; + +function pdf(name: string, size = 100): File { + return new File([new Uint8Array(size)], name, { type: "application/pdf" }); +} + +describe("analytics", () => { + beforeEach(() => { + capture.mockClear(); + optedIn = true; + }); + + it("captures one event per uploaded PDF (no dedup)", () => { + trackPdfUploaded([pdf("a.pdf"), pdf("a.pdf"), pdf("b.pdf")]); + expect(capture).toHaveBeenCalledTimes(3); + expect(capture).toHaveBeenCalledWith("editor_pdf_uploaded", { + source: "editor", + }); + }); + + it("counts every uploaded file regardless of type", () => { + trackPdfUploaded([ + new File(["x"], "a.png", { type: "image/png" }), + pdf("b.pdf"), + ]); + expect(capture).toHaveBeenCalledTimes(2); + }); + + it("captures one event per editor operation run", () => { + trackEditorOperation("compress", 3); + expect(capture).toHaveBeenCalledWith("editor_operation", { + source: "editor", + tool: "compress", + file_count: 3, + }); + }); + + it("does not capture when opted out", () => { + optedIn = false; + trackPdfUploaded([pdf("a.pdf")]); + trackEditorOperation("compress", 1); + expect(capture).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/services/analytics.ts b/frontend/editor/src/core/services/analytics.ts new file mode 100644 index 0000000000..f4ea317eab --- /dev/null +++ b/frontend/editor/src/core/services/analytics.ts @@ -0,0 +1,40 @@ +import posthog from "posthog-js"; + +const DEV = process.env.NODE_ENV === "development"; + +function canCapture(): boolean { + if (typeof window === "undefined") return false; + const ph = posthog as unknown as { + __loaded?: boolean; + has_opted_in_capturing?: () => boolean; + }; + if (!ph.__loaded) return false; + return ( + typeof ph.has_opted_in_capturing !== "function" || + ph.has_opted_in_capturing() + ); +} + +export function trackPdfUploaded(files: File[]): void { + try { + if (!canCapture() || !files) return; + for (let i = 0; i < files.length; i++) { + posthog.capture("editor_pdf_uploaded", { source: "editor" }); + } + } catch (error) { + if (DEV) console.warn("[analytics] trackPdfUploaded failed", error); + } +} + +export function trackEditorOperation(toolId: string, fileCount: number): void { + try { + if (!canCapture()) return; + posthog.capture("editor_operation", { + source: "editor", + tool: toolId, + file_count: fileCount, + }); + } catch (error) { + if (DEV) console.warn("[analytics] trackEditorOperation failed", error); + } +} diff --git a/frontend/editor/src/core/services/fileSyncService.ts b/frontend/editor/src/core/services/fileSyncService.ts index 8d80563f8e..5e948f8ba7 100644 --- a/frontend/editor/src/core/services/fileSyncService.ts +++ b/frontend/editor/src/core/services/fileSyncService.ts @@ -394,6 +394,7 @@ export async function materializeServerStubs( autoUnzip: boolean; skipAutoUnzip: boolean; allowDuplicates: boolean; + skipUploadTracking?: boolean; }, ) => Promise; updateStub: (id: FileId, updates: Partial) => void; @@ -463,6 +464,7 @@ export async function materializeServerStubs( autoUnzip: false, skipAutoUnzip: true, allowDuplicates: true, + skipUploadTracking: true, }); if (ingested.length === 0) continue; const primary = ingested[ingested.length - 1]!; diff --git a/frontend/editor/src/core/types/fileContext.ts b/frontend/editor/src/core/types/fileContext.ts index 3e9ba4283d..120e39b751 100644 --- a/frontend/editor/src/core/types/fileContext.ts +++ b/frontend/editor/src/core/types/fileContext.ts @@ -306,7 +306,11 @@ export interface FileContextActions { // File management - lightweight actions only addFiles: ( files: File[], - options?: { insertAfterPageId?: string; selectFiles?: boolean }, + options?: { + insertAfterPageId?: string; + selectFiles?: boolean; + skipUploadTracking?: boolean; + }, ) => Promise; addFilesWithOptions: ( files: File[], @@ -321,6 +325,7 @@ export interface FileContextActions { fileName: string, ) => Promise; allowDuplicates?: boolean; + skipUploadTracking?: boolean; }, ) => Promise; addStirlingFileStubs: ( diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index a0b2a02bae..8440d53b5b 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -299,7 +299,10 @@ export function usePolicyAutoRun(): void { } interface ImportContext { - addFiles: (files: File[]) => Promise; + addFiles: ( + files: File[], + options?: { skipUploadTracking?: boolean }, + ) => Promise; consumeFiles: ( inputFileIds: FileId[], outputs: StirlingFile[], @@ -462,7 +465,7 @@ async function importOutputs( ctx.bumpRevision(); } } else { - const added = await ctx.addFiles(files); + const added = await ctx.addFiles(files, { skipUploadTracking: true }); // Same loop-guard for new-file output: the produced file is a new workspace // file the auto-run would otherwise re-enforce indefinitely. for (const f of added) markDispatched(run.categoryId, f.fileId); diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts index 18b13861d5..8615156e9a 100644 --- a/frontend/editor/src/proprietary/services/apiClientSetup.ts +++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts @@ -1,5 +1,6 @@ import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; import { withBasePath } from "@app/constants/app"; +import { getBrowserId } from "@app/utils/browserIdentifier"; let isRefreshing = false; let failedQueue: Array<{ @@ -125,6 +126,8 @@ export function setupApiInterceptors(client: AxiosInstance): void { } } + config.headers["X-Browser-Id"] = getBrowserId(); + return config; }, (error) => { diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index aeb6bb5275..64cd05fd01 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -6,6 +6,7 @@ import { handlePaygError, } from "@app/services/paygErrorInterceptor"; import { withBasePath } from "@app/constants/app"; +import { getBrowserId } from "@app/utils/browserIdentifier"; // Helper: decode base64url JWT payload safely function decodeJwtPayload(token: string): Record | null { @@ -82,6 +83,8 @@ apiClient.interceptors.request.use( console.error("[API Client] Error in request interceptor:", error); } + config.headers["X-Browser-Id"] = getBrowserId(); + return config; }, (error) => { From 956b8000e4e3d395f1a8643b40058b5e5e24867b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:25:27 +0100 Subject: [PATCH 08/16] build(deps): bump ws from 8.20.1 to 8.21.0 in /frontend (#6679) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Signed-off-by: dependabot[bot] --- .github/aur/stirling-pdf-desktop/PKGBUILD | 2 +- .github/aur/stirling-pdf-server-bin/PKGBUILD | 2 +- build.gradle | 2 +- frontend/editor/src-tauri/tauri.conf.json | 2 +- .../editor/src/core/testing/serverExperienceSimulations.ts | 2 +- .../src/proprietary/testing/serverExperienceSimulations.ts | 2 +- frontend/package-lock.json | 6 +++--- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD index de60325133..d35c874238 100644 --- a/.github/aur/stirling-pdf-desktop/PKGBUILD +++ b/.github/aur/stirling-pdf-desktop/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-desktop -pkgver=2.13.0 +pkgver=2.13.1 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)" arch=('x86_64') diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD index 3853bb6256..2e71087c7c 100644 --- a/.github/aur/stirling-pdf-server-bin/PKGBUILD +++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-server-bin -pkgver=2.13.0 +pkgver=2.13.1 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)" arch=('any') diff --git a/build.gradle b/build.gradle index c7413605d1..b8423fc920 100644 --- a/build.gradle +++ b/build.gradle @@ -78,7 +78,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.13.0' + version = '2.13.1' configurations.configureEach { exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index 376f1027cd..55dba6de7d 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Stirling-PDF", - "version": "2.13.0", + "version": "2.13.1", "identifier": "stirling.pdf.dev", "build": { "frontendDist": "../dist", diff --git a/frontend/editor/src/core/testing/serverExperienceSimulations.ts b/frontend/editor/src/core/testing/serverExperienceSimulations.ts index eee4f85851..8338efb9e9 100644 --- a/frontend/editor/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/core/testing/serverExperienceSimulations.ts @@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.13.0", + appVersion: "2.13.1", serverCertificateEnabled: false, enableAlphaFunctionality: false, serverPort: 8080, diff --git a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts index 9fce606968..1e19f4be0c 100644 --- a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts @@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.13.0", + appVersion: "2.13.1", serverCertificateEnabled: false, enableAlphaFunctionality: false, enableDesktopInstallSlide: true, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 63d3b6d69f..fd7a14f4fa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17898,9 +17898,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" From c95fb89c630ab4fc97472ba079d4fe61c16dc9a2 Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 21 Jun 2026 11:25:30 +0200 Subject: [PATCH 09/16] fix(frontend): correctly display the current user role in the edit dialog (#6758) # Description of Changes This PR fixes the role field in the People settings "Edit User" dialog so the currently assigned role is displayed correctly. - What was changed - The edit dialog now uses the actual role identifier from the user data when preselecting the role. - The role selection is made more robust by falling back to a valid default when the backend response does not provide a usable role value. - The role label shown in the UI is derived consistently from the role identifier. - Why the change was made - The dialog could open with an empty role field even though the user already had a role assigned. - This made role editing confusing and could lead to accidental changes. - The fix keeps the People UI aligned with the backend role data model. before: image after: image --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../public/locales/en-US/translation.toml | 1 + .../config/configSections/PeopleSection.tsx | 55 +++++++++++++++---- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 2e60ebf3d1..0bfb5749ce 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -8604,6 +8604,7 @@ disableByAdmin = "Disable MFA" [workspace.people.roleDescriptions] admin = "Can manage settings and invite users, with full administrative access." +currentRole = "Current assigned role." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index 151d0c5dff..454b9e138c 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -94,6 +94,21 @@ export default function PeopleSection() { const isCurrentUser = (user: User) => currentUser?.username === user.username; const isLockedUser = (user: User) => lockedUsers.includes(user.username); + const getUserRoleId = (user: User) => + user.rolesAsString || + (user.roleName?.startsWith("ROLE_") ? user.roleName : undefined) || + "ROLE_USER"; + + const getRoleLabel = (roleId: string) => { + switch (roleId) { + case "ROLE_ADMIN": + return t("workspace.people.admin", "Admin"); + case "ROLE_USER": + return t("workspace.people.user", "User"); + default: + return roleId; + } + }; // Form state for edit user modal const [editForm, setEditForm] = useState({ @@ -353,7 +368,7 @@ export default function PeopleSection() { const openEditModal = (user: User) => { setSelectedUser(user); setEditForm({ - role: user.roleName, + role: getUserRoleId(user), teamId: user.team?.id, }); setEditUserModalOpened(true); @@ -385,7 +400,7 @@ export default function PeopleSection() { const roleOptions = [ { value: "ROLE_ADMIN", - label: t("workspace.people.admin"), + label: getRoleLabel("ROLE_ADMIN"), description: t( "workspace.people.roleDescriptions.admin", "Can manage settings and invite members, with full administrative access.", @@ -394,7 +409,7 @@ export default function PeopleSection() { }, { value: "ROLE_USER", - label: t("workspace.people.user"), + label: getRoleLabel("ROLE_USER"), description: t( "workspace.people.roleDescriptions.user", "Can view and edit shared files, but cannot manage workspace settings or members.", @@ -474,7 +489,9 @@ export default function PeopleSection() { {" "} - {t("workspace.people.license.users", "users")} + {t("workspace.people.license.users", "users", { + count: licenseInfo.totalUsers, + })} @@ -704,18 +721,18 @@ export default function PeopleSection() { size="sm" variant="light" color={ - (user.rolesAsString || "").includes("ROLE_ADMIN") + getUserRoleId(user) === "ROLE_ADMIN" ? "blue" - : "cyan" + : getUserRoleId(user) === "ROLE_PRO_USER" + ? "grape" + : "cyan" } styles={{ root: { maxWidth: "none" }, label: { overflow: "visible" }, }} > - {(user.rolesAsString || "").includes("ROLE_ADMIN") - ? t("workspace.people.admin", "Admin") - : t("workspace.people.user", "User")} + {getRoleLabel(getUserRoleId(user))} @@ -1009,7 +1026,25 @@ export default function PeopleSection() { onName(e.target.value)} - placeholder="Your name" + placeholder={t("settings.profile.namePlaceholder")} /> onEmail(e.target.value)} - placeholder="you@company.com" + placeholder={t("settings.profile.emailPlaceholder")} /> @@ -418,19 +403,22 @@ function AppearancePanel({ theme: Theme; onTheme: (theme: Theme) => void; }) { + const { t } = useTranslation(); return (
-

Theme

+

+ {t("settings.appearance.themeTitle")} +

- Choose how the portal looks on this device. + {t("settings.appearance.themeSub")}

{THEME_OPTIONS.map((opt) => ( ))} @@ -478,13 +466,16 @@ function NotificationsPanel({ order: string[]; onToggle: (id: string, value: boolean) => void; }) { + const { t } = useTranslation(); return (
-

Email notifications

+

+ {t("settings.notifications.title")} +

- Pick which events reach your inbox. + {t("settings.notifications.sub")}

@@ -505,13 +496,14 @@ function NotificationsPanel({ {!loading && (
{order.map((id) => { - const copy = NOTIFICATION_COPY[id]; - if (!copy) return null; + if (!(NOTIFICATION_IDS as readonly string[]).includes(id)) { + return null; + } return (
- {copy.label} - {copy.description} + {t(`settings.notifications.${id}.label`)} + {t(`settings.notifications.${id}.description`)}
@@ -562,17 +555,17 @@ function WorkspacePanel({ return (
- + onWorkspaceName(e.target.value)} - placeholder="Workspace name" + placeholder={t("settings.workspace.namePlaceholder")} /> onSecurity({ sessionTimeoutMins: Number(e.target.value) }) } - options={SESSION_TIMEOUT_OPTIONS} + options={SESSION_TIMEOUT_VALUES.map((value) => ({ + value, + label: t(`settings.authentication.timeout.${value}`), + }))} />
@@ -722,6 +728,7 @@ function SessionsPanel({ loading: boolean; sessions: ActiveSession[]; }) { + const { t } = useTranslation(); if (loading) { return (
@@ -735,9 +742,11 @@ function SessionsPanel({
-

Active sessions

+

+ {t("settings.sessions.title")} +

- Devices currently signed in to this account. + {t("settings.sessions.sub")}

@@ -751,12 +760,12 @@ function SessionsPanel({
{s.current ? ( - This device + {t("settings.sessions.thisDevice")} ) : ( // TODO(backend): DELETE /v1/settings/sessions/{id} )}
@@ -784,6 +793,7 @@ function EarlyAccessPanel({ betaToggles: Record; onBeta: (id: string, value: boolean) => void; }) { + const { t } = useTranslation(); if (loading) { return (
@@ -799,9 +809,11 @@ function EarlyAccessPanel({
-

Preview features

+

+ {t("settings.earlyAccess.title")} +

- Opt into features still in preview. + {t("settings.earlyAccess.sub")}

@@ -814,7 +826,7 @@ function EarlyAccessPanel({ {f.label} {locked && ( - Enterprise + {t("settings.enterpriseBadge")} )} diff --git a/frontend/portal/src/components/Sidebar.tsx b/frontend/portal/src/components/Sidebar.tsx index a31f59d933..b1b70eae14 100644 --- a/frontend/portal/src/components/Sidebar.tsx +++ b/frontend/portal/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Dropdown, NavItem } from "@shared/components"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useTier } from "@portal/contexts/TierContext"; @@ -30,35 +31,29 @@ const EDITOR_URL = import.meta.env.DEV ? "http://localhost:5180/" : "/"; interface NavEntry { id: ViewId; - label: string; icon: React.ReactNode; } -const GROUP_PRIMARY: NavEntry[] = [ - { id: "home", label: "Home", icon: }, -]; +const GROUP_PRIMARY: NavEntry[] = [{ id: "home", icon: }]; const GROUP_OPERATIONAL: NavEntry[] = [ - { id: "users", label: "Users", icon: }, - { id: "sources", label: "Sources", icon: }, - { id: "policies", label: "Policies", icon: }, - { id: "pipelines", label: "Pipelines", icon: }, - { id: "documents", label: "Documents", icon: }, - { id: "components", label: "Components", icon: }, + { id: "users", icon: }, + { id: "sources", icon: }, + { id: "policies", icon: }, + { id: "pipelines", icon: }, + { id: "documents", icon: }, + { id: "components", icon: }, ]; const GROUP_PLATFORM: NavEntry[] = [ - { - id: "infrastructure", - label: "Infrastructure", - icon: , - }, - { id: "usage", label: "Usage & Billing", icon: }, - { id: "docs", label: "Developer Docs", icon: }, + { id: "infrastructure", icon: }, + { id: "usage", icon: }, + { id: "docs", icon: }, ]; function UsageFooter() { const { tier } = useTier(); + const { t } = useTranslation(); // Read the same endpoint Home's KPI strip uses so the doc count here can't // drift from the headline figure. The first KPI is always the doc total. const { data: kpis, loading } = useAsync( @@ -77,7 +72,9 @@ function UsageFooter() { return (
- Docs processed + + {t("shell.sidebar.docsProcessed")} + {docs ?? "—"}
@@ -105,7 +105,7 @@ function UsageFooter() { {planLabel} - {docs != null ? `${docs} docs` : "—"} + {docs != null ? t("shell.sidebar.docsCount", { docs }) : "—"}
@@ -116,13 +116,14 @@ export function Sidebar() { const { activeView, setActiveView } = useView(); const { theme } = useTheme(); const { openSettings } = useUI(); + const { t } = useTranslation(); function renderGroup(entries: NavEntry[]) { return entries.map((entry) => ( setActiveView(id as ViewId)} @@ -131,7 +132,10 @@ export function Sidebar() { } return ( -
)}
{hovered - ? `${new Date(hovered.raw.date).toLocaleDateString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - })}: ${hovered.raw.value.toLocaleString()} docs` + ? t("usageChart.srAnnounce", { + date: new Date(hovered.raw.date).toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + }), + value: hovered.raw.value.toLocaleString(), + }) : ""}
diff --git a/frontend/portal/src/components/WelcomeCarousel.tsx b/frontend/portal/src/components/WelcomeCarousel.tsx index e623200856..b485eee1a4 100644 --- a/frontend/portal/src/components/WelcomeCarousel.tsx +++ b/frontend/portal/src/components/WelcomeCarousel.tsx @@ -1,17 +1,15 @@ import { useEffect, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { Button, StatusBadge } from "@shared/components"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import "@portal/components/WelcomeCarousel.css"; type SlideAction = - | { label: string; target: ViewId } - | { label: string; action: "try-op" }; + | { labelKey: string; target: ViewId } + | { labelKey: string; action: "try-op" }; interface Slide { id: string; - eyebrow: string; - title: string; - sub: string; durationMs: number; primary: SlideAction; secondary: SlideAction; @@ -19,21 +17,22 @@ interface Slide { } function EditorOrnament() { + const { t } = useTranslation(); return (
- Critical + {t("welcome.ornament.editor.critical")}
Vulnerability Assessment Report
CVE-2026-1847 · 12 pages
- signed + {t("welcome.ornament.editor.signed")} · - OCR-clean + {t("welcome.ornament.editor.ocrClean")} · - schema match 0.97 + {t("welcome.ornament.editor.schemaMatch")}
); @@ -88,32 +87,29 @@ function AgentOrnament() { const SLIDES: Slide[] = [ { id: "editor", - eyebrow: "PDF Editor", - title: "The #1 PDF Editor on GitHub", - sub: "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane.", durationMs: 12000, - primary: { label: "Install PDF Editor", target: "editor" }, - secondary: { label: "Connect an instance", target: "editor" }, + primary: { labelKey: "welcome.slides.editor.primary", target: "editor" }, + secondary: { + labelKey: "welcome.slides.editor.secondary", + target: "editor", + }, ornament: , }, { id: "platform", - eyebrow: "Platform", - title: "PDF Infrastructure for Developers", - sub: "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls.", durationMs: 8000, - primary: { label: "Try a PDF operation", action: "try-op" }, - secondary: { label: "Get an API key", target: "infrastructure" }, + primary: { labelKey: "welcome.slides.platform.primary", action: "try-op" }, + secondary: { + labelKey: "welcome.slides.platform.secondary", + target: "infrastructure", + }, ornament: , }, { id: "agents", - eyebrow: "AI Agents", - title: "PDF Processor for AI Agents", - sub: "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship.", durationMs: 8000, - primary: { label: "Try PDF Processor", target: "sources" }, - secondary: { label: "View MCP docs", target: "docs" }, + primary: { labelKey: "welcome.slides.agents.primary", target: "sources" }, + secondary: { labelKey: "welcome.slides.agents.secondary", target: "docs" }, ornament: , }, ]; @@ -124,6 +120,7 @@ interface WelcomeCarouselProps { } export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) { + const { t } = useTranslation(); const [index, setIndex] = useState(0); const [paused, setPaused] = useState(false); const { setActiveView } = useView(); @@ -160,27 +157,33 @@ export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) { setPaused(false); } }} - aria-label="Stirling product highlights" + aria-label={t("welcome.ariaLabel")} aria-roledescription="carousel" >
-
{slide.eyebrow}
-

{slide.title}

-

{slide.sub}

+
+ {t(`welcome.slides.${slide.id}.eyebrow`)} +
+

+ {t(`welcome.slides.${slide.id}.title`)} +

+

+ {t(`welcome.slides.${slide.id}.sub`)} +

@@ -195,14 +198,17 @@ export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
{SLIDES.map((s, i) => (
} >

- Drop a sample document and we'll propose scenarios and an - extraction schema you can refine. Nothing is published until you - review it. + {t("agentBuilder.bootstrap.lead")}

diff --git a/frontend/portal/src/components/agent-builder/EvalsPanel.tsx b/frontend/portal/src/components/agent-builder/EvalsPanel.tsx index 58c601accd..651f6c1d69 100644 --- a/frontend/portal/src/components/agent-builder/EvalsPanel.tsx +++ b/frontend/portal/src/components/agent-builder/EvalsPanel.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, EmptyState, @@ -14,38 +15,50 @@ interface EvalsPanelProps { agent: Agent; } -const COLUMNS: TableColumn[] = [ - { key: "name", header: "Eval case", render: (c) => c.name }, - { - key: "result", - header: "Result", - render: (c) => - c.passing === null ? ( - not run - ) : ( - - {c.passing ? "pass" : "fail"} - - ), - }, - { - key: "latency", - header: "Latency", - align: "right", - render: (c) => ( - {c.latencyMs} ms - ), - }, -]; - /** Golden-set pass-rate, the per-case results table, and a run affordance. */ export function EvalsPanel({ agent }: EvalsPanelProps) { + const { t } = useTranslation(); + + const columns: TableColumn[] = [ + { + key: "name", + header: t("agentBuilder.evals.columnCase"), + render: (c) => c.name, + }, + { + key: "result", + header: t("agentBuilder.evals.columnResult"), + render: (c) => + c.passing === null ? ( + + {t("agentBuilder.evals.notRun")} + + ) : ( + + {c.passing + ? t("agentBuilder.evals.pass") + : t("agentBuilder.evals.fail")} + + ), + }, + { + key: "latency", + header: t("agentBuilder.evals.columnLatency"), + align: "right", + render: (c) => ( + + {t("agentBuilder.evals.latencyMs", { ms: c.latencyMs })} + + ), + }, + ]; + if (agent.evalsTotal === 0) { return (
@@ -64,34 +77,34 @@ export function EvalsPanel({ agent }: EvalsPanelProps) {
= 0.95 ? "success" : rate >= 0.8 ? "warning" : "danger"} />
- Golden-set pass rate + {t("agentBuilder.evals.goldenSetPassRate")} {Math.round(rate * 100)}%
= 0.95 ? "var(--color-green)" : "var(--color-amber)"} - label="Golden-set pass rate" + label={t("agentBuilder.evals.goldenSetPassRate")} />
- columns={COLUMNS} + columns={columns} rows={agent.evalCases} rowKey={(c) => c.id} /> diff --git a/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx b/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx index 84ef11ecb2..f355dfa4d0 100644 --- a/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx +++ b/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button, Chip, @@ -19,6 +20,7 @@ interface ScenariosPanelProps { * the submit endpoint exists. */ export function ScenariosPanel({ agent }: ScenariosPanelProps) { + const { t } = useTranslation(); // Seed from the agent and re-seed when the selection changes (key prop on the // builder forces a remount, so a plain useState initialiser is enough). const [scenarios, setScenarios] = useState(agent.scenarios); @@ -61,7 +63,9 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) { size="sm" showDot={false} > - {s.enabled ? "in eval" : "muted"} + {s.enabled + ? t("agentBuilder.scenarios.inEval") + : t("agentBuilder.scenarios.muted")}
@@ -73,7 +77,9 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) { variant="ghost" onClick={() => toggleEnabled(s.id)} > - {s.enabled ? "Mute" : "Enable"} + {s.enabled + ? t("agentBuilder.scenarios.mute") + : t("agentBuilder.scenarios.enable")} ))} @@ -81,25 +87,25 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) {
- Add scenario + {t("agentBuilder.scenarios.addScenario")}
- + setName(e.target.value)} - placeholder="e.g. Compliance escalation" + placeholder={t("agentBuilder.scenarios.namePlaceholder")} /> - + setExpectation(e.target.value)} - placeholder="What the agent should do" + placeholder={t("agentBuilder.scenarios.expectationPlaceholder")} />
diff --git a/frontend/portal/src/components/agent-builder/ToolsPanel.tsx b/frontend/portal/src/components/agent-builder/ToolsPanel.tsx index c53faa2a60..81e0f57af4 100644 --- a/frontend/portal/src/components/agent-builder/ToolsPanel.tsx +++ b/frontend/portal/src/components/agent-builder/ToolsPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Chip, ToggleSwitch } from "@shared/components"; import { type Agent, type ToolMode, TOOL_CATALOGUE } from "@portal/api/agents"; import "@portal/views/AgentBuilder.css"; @@ -14,6 +15,7 @@ interface ToolsPanelProps { * default minus an explicit deny list, picked from the known tool catalogue. */ export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) { + const { t } = useTranslation(); const [mode, setMode] = useState(agent.toolMode); const [denied, setDenied] = useState(agent.deniedTools); @@ -38,23 +40,27 @@ export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) { checked={restricted} onChange={setRestricted} disabled={!governanceUnlocked} - label="Restricted tool access" + label={t("agentBuilder.tools.restrictedAccess")} description={ governanceUnlocked - ? "Allow every tool except the ones you deny below." - : "Tool governance is available on the Enterprise plan." + ? t("agentBuilder.tools.restrictedDescription") + : t("agentBuilder.tools.governanceGate") } /> - {restricted ? "Restricted" : "Broad access"} + {restricted + ? t("agentBuilder.tools.restricted") + : t("agentBuilder.tools.broadAccess")}
{restricted && (
- Denied tools + + {t("agentBuilder.tools.deniedTools")} +

- Selected tools are blocked. Everything else stays callable. + {t("agentBuilder.tools.deniedHint")}

{TOOL_CATALOGUE.map((tool) => { diff --git a/frontend/portal/src/components/agent-builder/VersionsPanel.tsx b/frontend/portal/src/components/agent-builder/VersionsPanel.tsx index 4a9bc5377a..b05e2fa7fc 100644 --- a/frontend/portal/src/components/agent-builder/VersionsPanel.tsx +++ b/frontend/portal/src/components/agent-builder/VersionsPanel.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, StatusBadge } from "@shared/components"; import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; import "@portal/views/AgentBuilder.css"; @@ -19,6 +20,7 @@ function formatDate(iso: string): string { /** Version history with publish / rollback actions per row. */ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { + const { t } = useTranslation(); // Without governance, only the current version is meaningful to show. const versions = historyUnlocked ? agent.versions @@ -54,7 +56,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { {isCurrent && ( - current + {t("agentBuilder.versions.current")} )}
@@ -70,7 +72,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { variant="outline" onClick={() => publish(v.version)} > - Publish + {t("agentBuilder.versions.publish")} )} {v.status === "published" && !isCurrent && ( @@ -79,7 +81,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { variant="ghost" onClick={() => rollback(v.version)} > - Roll back + {t("agentBuilder.versions.rollBack")} )}
@@ -90,8 +92,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { {!historyUnlocked && publishedExists && (

- Full version history and rollback are available on the Enterprise - plan. + {t("agentBuilder.versions.historyGate")}

)}
diff --git a/frontend/portal/src/components/catalogue/ComponentCard.tsx b/frontend/portal/src/components/catalogue/ComponentCard.tsx index 6b2c46d8ab..9c662d5b9b 100644 --- a/frontend/portal/src/components/catalogue/ComponentCard.tsx +++ b/frontend/portal/src/components/catalogue/ComponentCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Card, Chip, StatusBadge } from "@shared/components"; import { type SdkComponent, @@ -19,6 +20,7 @@ export function ComponentCard({ unlocked, onOpen, }: ComponentCardProps) { + const { t } = useTranslation(); const maturity = MATURITY_META[component.maturity]; return ( @@ -28,7 +30,7 @@ export function ComponentCard({ className={"portal-components__card" + (unlocked ? "" : " is-locked")} role="button" tabIndex={0} - aria-label={`Open ${component.name} component`} + aria-label={t("catalogue.card.openAriaLabel", { name: component.name })} onClick={() => onOpen(component)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { @@ -43,7 +45,10 @@ export function ComponentCard({ {maturity.label} {!unlocked && ( - + 🔒 )} diff --git a/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx b/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx index d9f8627a38..970d2fe392 100644 --- a/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx +++ b/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Banner, Button, @@ -19,12 +20,7 @@ import "@portal/views/Components.css"; type DetailTab = "overview" | "code" | "props" | "pricing"; -const TABS: { key: DetailTab; label: string }[] = [ - { key: "overview", label: "Overview" }, - { key: "code", label: "Code" }, - { key: "props", label: "Props / API" }, - { key: "pricing", label: "Pricing" }, -]; +const TAB_KEYS: DetailTab[] = ["overview", "code", "props", "pricing"]; interface ComponentDetailModalProps { component: SdkComponent | null; @@ -43,16 +39,26 @@ export function ComponentDetailModal({ unlocked, onClose, }: ComponentDetailModalProps) { + const { t } = useTranslation(); const [tab, setTab] = useState("overview"); // Reset to the first tab whenever a new component is opened. const open = component !== null; if (!component) { return ( - + ); } + const tabs = TAB_KEYS.map((key) => ({ + key, + label: t(`catalogue.detail.tabs.${key}`), + })); + const maturity = MATURITY_META[component.maturity]; const npm = `@stirling/${component.package}`; @@ -86,7 +92,7 @@ export function ComponentDetailModal({ // publishable key scoped to this component. onClick={() => onClose()} > - Add to project + {t("catalogue.detail.addToProject")}
) : ( @@ -96,7 +102,7 @@ export function ComponentDetailModal({ // TODO(backend): route to the upgrade / contact-sales flow. onClick={() => onClose()} > - Upgrade to unlock + {t("catalogue.detail.upgradeToUnlock")} ) } @@ -104,8 +110,11 @@ export function ComponentDetailModal({ {!unlocked && ( )} @@ -113,19 +122,21 @@ export function ComponentDetailModal({
{/* TODO(backend)/host: mount the live here, booting the component against a demo document and the dev's publishable key. */} - Live preview + + {t("catalogue.detail.preview.badge")} + - Interactive sandbox renders here + {t("catalogue.detail.preview.note")}
className="portal-components__tabs" - items={TABS} + items={tabs} activeKey={tab} onChange={setTab} variant="underline" - ariaLabel="Component detail sections" + ariaLabel={t("catalogue.detail.tabsAriaLabel")} />
@@ -142,18 +153,26 @@ export function ComponentDetailModal({ ))}
- - + + 0 - ? `${component.pricing.freeQuota.toLocaleString()} / mo` - : "None" + ? t("catalogue.detail.stats.freeQuotaValue", { + amount: component.pricing.freeQuota.toLocaleString(), + }) + : t("catalogue.detail.stats.none") } />
@@ -162,11 +181,15 @@ export function ComponentDetailModal({ {tab === "code" && (
- +
)} @@ -177,23 +200,28 @@ export function ComponentDetailModal({
- + 0 - ? `${component.pricing.freeQuota.toLocaleString()} / mo` - : "None" + ? t("catalogue.detail.stats.freeQuotaValue", { + amount: component.pricing.freeQuota.toLocaleString(), + }) + : t("catalogue.detail.stats.none") } />

- Metered per {component.pricing.unit}. Usage beyond the monthly - free quota is billed to your account and itemised under Usage - & Billing. + {t("catalogue.detail.pricing.note", { + unit: component.pricing.unit, + })}

)} diff --git a/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx b/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx index 03f8e46925..0bb77cb47a 100644 --- a/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx +++ b/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Chip, Table, type TableColumn } from "@shared/components"; import type { ComponentProp } from "@portal/api/sdkComponents"; import "@portal/views/Components.css"; @@ -9,43 +10,46 @@ interface ComponentPropsTableProps { /** Small Props/API reference shown under the detail modal's Props tab. */ export function ComponentPropsTable({ props: rows }: ComponentPropsTableProps) { + const { t } = useTranslation(); const columns = useMemo[]>( () => [ { key: "name", - header: "Prop", + header: t("catalogue.props.columns.name"), render: (p) => ( {p.name} ), }, { key: "type", - header: "Type", + header: t("catalogue.props.columns.type"), render: (p) => ( {p.type} ), }, { key: "required", - header: "Required", + header: t("catalogue.props.columns.required"), render: (p) => p.required ? ( - required + {t("catalogue.props.required")} ) : ( - optional + + {t("catalogue.props.optional")} + ), }, { key: "description", - header: "Description", + header: t("catalogue.props.columns.description"), render: (p) => ( {p.description} ), }, ], - [], + [t], ); return ( diff --git a/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx b/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx index 8cad2c51ae..ec46ad855f 100644 --- a/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx +++ b/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { MetricCard, MetricStrip } from "@shared/components"; import type { ComponentsResponse } from "@portal/api/sdkComponents"; @@ -6,11 +7,11 @@ import type { ComponentsResponse } from "@portal/api/sdkComponents"; * so the strip's structure stays stable across loading / empty / ready states. * Only values flow from the API. */ -const KPI_LABELS = [ - "Components GA", - "In beta", - "Embeds this month", - "Component spend (MTD)", +const KPI_LABEL_KEYS = [ + "catalogue.summary.componentsGa", + "catalogue.summary.inBeta", + "catalogue.summary.embedsThisMonth", + "catalogue.summary.componentSpendMtd", ] as const; interface ComponentsSummaryStripProps { @@ -22,6 +23,7 @@ export function ComponentsSummaryStrip({ data, loading, }: ComponentsSummaryStripProps) { + const { t } = useTranslation(); const s = loading ? undefined : data?.summary; const values: (string | number)[] = [ s?.gaCount ?? "—", @@ -32,8 +34,8 @@ export function ComponentsSummaryStrip({ return ( - {KPI_LABELS.map((label, i) => ( - + {KPI_LABEL_KEYS.map((labelKey, i) => ( + ))} ); diff --git a/frontend/portal/src/components/docs/AuthenticationSection.tsx b/frontend/portal/src/components/docs/AuthenticationSection.tsx index b6f782305c..65b1d89599 100644 --- a/frontend/portal/src/components/docs/AuthenticationSection.tsx +++ b/frontend/portal/src/components/docs/AuthenticationSection.tsx @@ -1,17 +1,19 @@ +import { useTranslation } from "react-i18next"; import { Chip, CodeBlock } from "@shared/components"; import { DocsSection } from "@portal/components/docs/DocsSection"; export function AuthenticationSection() { + const { t } = useTranslation(); return (
@@ -19,13 +21,13 @@ export function AuthenticationSection() { sk_live_ - Production keys — billed, rate-limited per your plan. + {t("docs.authentication.liveKey")}
sk_test_ - Sandbox keys — free, return synthetic fixtures. + {t("docs.authentication.testKey")}
diff --git a/frontend/portal/src/components/docs/ComponentsSection.tsx b/frontend/portal/src/components/docs/ComponentsSection.tsx index b25f5e5164..10a2349154 100644 --- a/frontend/portal/src/components/docs/ComponentsSection.tsx +++ b/frontend/portal/src/components/docs/ComponentsSection.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Card, Chip, CodeBlock } from "@shared/components"; import type { EmbedComponent } from "@portal/api/docs"; import { DocsSection } from "@portal/components/docs/DocsSection"; @@ -7,12 +8,13 @@ export function ComponentsSection({ }: { components: EmbedComponent[]; }) { + const { t } = useTranslation(); return (
{components.map((c) => ( @@ -29,7 +31,7 @@ export function ComponentsSection({
void; }) { + const { t } = useTranslation(); return ( -