diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index f6f8d828c..20c3590e8 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -13,7 +13,19 @@ "dismiss": "Maybe later" }, "fullscreen": { - "showDetails": "Show Details" + "showDetails": "Show Details", + "comingSoon": "Coming soon:", + "favorite": "Add to favourites", + "favorites": "Favourites", + "heading": "All tools (fullscreen view)", + "noResults": "Try adjusting your search or toggle descriptions to find what you need.", + "recommended": "Recommended", + "unfavorite": "Remove from favourites" + }, + "placeholder": "Choose a tool to get started", + "toggle": { + "fullscreen": "Switch to fullscreen mode", + "sidebar": "Switch to sidebar mode" } }, "unsavedChanges": "You have unsaved changes to your PDF.", @@ -56,7 +68,7 @@ "preview": "Position Selection", "previewDisclaimer": "Preview is approximate. Final output may vary due to PDF font metrics." }, - "pageSelectionPrompt": "Specify which pages to add numbers to. Examples: \"1,3,5\" for specific pages, \"1-5\" for ranges, \"2n\" for even pages, or leave blank for all pages.", + "pageSelectionPrompt": "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1)", "startingNumberTooltip": "The first number to display. Subsequent pages will increment from this number.", "marginTooltip": "Distance between the page number and the edge of the page.", "fontSizeTooltip": "Size of the page number text in points. Larger numbers create bigger text.", @@ -72,7 +84,6 @@ "uploadLimitExceededPlural": "are too large. Maximum allowed size is", "processTimeWarning": "Warning: This process can take up to a minute depending on file-size", "pageOrderPrompt": "Custom Page Order (Enter a comma-separated list of page numbers or Functions like 2n+1) :", - "pageSelectionPrompt": "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1) :", "goToPage": "Go", "true": "True", "false": "False", @@ -104,7 +115,9 @@ "uploadFiles": "Upload Files", "addFiles": "Add files", "selectFromWorkbench": "Select files from the workbench or ", - "selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or " + "selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or ", + "created": "Created", + "size": "File Size" }, "noFavourites": "No favourites added", "downloadComplete": "Download Complete", @@ -289,7 +302,13 @@ "autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.", "autoUnzipFileLimit": "Auto-unzip file limit", "autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP", - "autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs." + "autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.", + "defaultToolPickerMode": "Default tool picker mode", + "defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default", + "mode": { + "fullscreen": "Fullscreen", + "sidebar": "Sidebar" + } }, "hotkeys": { "title": "Keyboard Shortcuts", @@ -306,7 +325,8 @@ "change": "Change shortcut", "reset": "Reset", "shortcut": "Shortcut", - "noShortcut": "No shortcut set" + "noShortcut": "No shortcut set", + "searchPlaceholder": "Search tools..." } }, "changeCreds": { @@ -665,9 +685,9 @@ "title": "Manage Certificates", "desc": "Import, export, or delete digital certificate files used for signing PDFs." }, - "read": { - "tags": "view,open,display", - "title": "Read", + "read": { + "tags": "view,open,display", + "title": "Read", "desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration." }, "reorganizePages": { @@ -724,6 +744,20 @@ "tags": "workflow,sequence,automation", "title": "Automate", "desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." + }, + "mobile": { + "brandAlt": "Stirling PDF logo", + "openFiles": "Open files", + "swipeHint": "Swipe left or right to switch views", + "tools": "Tools", + "toolsSlide": "Tool selection panel", + "viewSwitcher": "Switch workspace view", + "workbenchSlide": "Workspace panel", + "workspace": "Workspace" + }, + "overlay-pdfs": { + "desc": "Overlay one PDF on top of another", + "title": "Overlay PDFs" } }, "landing": { @@ -909,13 +943,50 @@ "bullet1": "Bookmark Level: Which level to split on (1=top level)", "bullet2": "Include Metadata: Preserve document properties", "bullet3": "Allow Duplicates: Handle repeated bookmark names" + }, + "byDocCount": { + "bullet1": "Enter the number of output files you want", + "bullet2": "Pages are distributed as evenly as possible", + "bullet3": "Useful when you need a specific number of files", + "text": "Create a specific number of output files by evenly distributing pages across them.", + "title": "Split by Document Count" + }, + "byPageCount": { + "bullet1": "Enter the number of pages per output file", + "bullet2": "Last file may have fewer pages if not evenly divisible", + "bullet3": "Useful for batch processing workflows", + "text": "Create multiple PDFs with a specific number of pages each. Perfect for creating uniform document chunks.", + "title": "Split by Page Count" + }, + "byPageDivider": { + "bullet1": "Print divider sheets from the download link", + "bullet2": "Insert divider sheets between your documents", + "bullet3": "Scan all documents together as one PDF", + "bullet4": "Upload - divider pages are automatically detected and removed", + "bullet5": "Enable Duplex Mode if scanning both sides of divider sheets", + "text": "Automatically split scanned documents using physical divider sheets with QR codes. Perfect for processing multiple documents scanned together.", + "title": "Split by Page Divider" } - } + }, + "methodSelection": { + "tooltip": { + "bullet1": "Click on a method card to select it", + "bullet2": "Hover over each card to see a quick description", + "bullet3": "The settings step will appear after you select a method", + "bullet4": "You can change methods at any time before processing", + "header": { + "text": "Choose how you want to split your PDF document. Each method is optimized for different use cases and document types.", + "title": "Split Method Selection" + }, + "title": "Choose Your Split Method" + } + }, + "selectMethod": "Select a split method" }, "rotate": { "title": "Rotate PDF", "submit": "Apply Rotation", - "selectRotation": "Select Rotation Angle (Clockwise)", + "selectRotation": "Select Rotation Angle (Clockwise)", "error": { "failed": "An error occurred while rotating the PDF." }, @@ -1003,7 +1074,8 @@ "imagesExt": "Images (JPG, PNG, etc.)", "markdown": "Markdown", "textRtf": "Text/RTF", - "grayscale": "Greyscale" + "grayscale": "Greyscale", + "errorConversion": "An error occurred while converting the file." }, "imageToPdf": { "tags": "conversion,img,jpg,picture,photo" @@ -1030,7 +1102,7 @@ "header": "PDF Page Organiser", "submit": "Rearrange Pages", "mode": { - "_value": "Organization mode", + "_value": "Organisation mode", "1": "Custom Page Order", "2": "Reverse Order", "3": "Duplex Sort", @@ -1041,7 +1113,20 @@ "8": "Remove Last", "9": "Remove First and Last", "10": "Odd-Even Merge", - "11": "Duplicate all pages" + "11": "Duplicate all pages", + "desc": { + "BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).", + "CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.", + "DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).", + "DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).", + "ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.", + "ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.", + "REMOVE_FIRST": "Remove the first page from the document.", + "REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document.", + "REMOVE_LAST": "Remove the last page from the document.", + "REVERSE_ORDER": "Flip the document so the last page becomes first and so on.", + "SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimized for binding on the side)." + } }, "desc": { "CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.", @@ -1107,7 +1192,9 @@ "opacity": "Opacity (%)", "spacing": { "horizontal": "Horizontal Spacing", - "vertical": "Vertical Spacing" + "vertical": "Vertical Spacing", + "height": "Height Spacing", + "width": "Width Spacing" }, "convertToImage": "Flatten PDF pages to images" }, @@ -1250,6 +1337,10 @@ "bullet4": "Best for sensitive or copyrighted content" } } + }, + "type": { + "1": "Text", + "2": "Image" } }, "permissions": { @@ -1363,6 +1454,38 @@ }, "examples": { "title": "Examples" + }, + "complex": { + "bullet1": "1,3-5,8,2n → pages 1, 3–5, 8, plus evens", + "bullet2": "10-,2n-1 → from page 10 to end + odd pages", + "description": "Mix different types.", + "title": "Complex Combinations" + }, + "description": "Choose which pages to use for the operation. Supports single pages, ranges, formulas, and the all keyword.", + "individual": { + "bullet1": "1,3,5 → selects pages 1, 3, 5", + "bullet2": "2,7,12 → selects pages 2, 7, 12", + "description": "Enter numbers separated by commas.", + "title": "Individual Pages" + }, + "mathematical": { + "bullet1": "2n → all even pages (2, 4, 6…)", + "bullet2": "2n-1 → all odd pages (1, 3, 5…)", + "bullet3": "3n → every 3rd page (3, 6, 9…)", + "bullet4": "4n-1 → pages 3, 7, 11, 15…", + "description": "Use n in formulas for patterns.", + "title": "Mathematical Functions" + }, + "ranges": { + "bullet1": "3-6 → selects pages 3–6", + "bullet2": "10-15 → selects pages 10–15", + "bullet3": "5- → selects pages 5 to end", + "description": "Use - for consecutive pages.", + "title": "Page Ranges" + }, + "special": { + "bullet1": "all → selects all pages", + "title": "Special Keywords" } } }, @@ -1672,6 +1795,9 @@ "text": "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size." } } + }, + "error": { + "failed": "OCR operation failed" } }, "extractImages": { @@ -1792,8 +1918,14 @@ "title": "Sign", "header": "Sign PDFs", "upload": "Upload Image", - "draw": "Draw Signature", - "text": "Text Input", + "draw": { + "title": "Draw your signature", + "clear": "Clear" + }, + "text": { + "name": "Signer Name", + "placeholder": "Enter your full name" + }, "clear": "Clear", "add": "Add", "saved": "Saved Signatures", @@ -1822,19 +1954,11 @@ "image": "Image", "text": "Text" }, - "draw": { - "title": "Draw your signature", - "clear": "Clear" - }, "image": { "label": "Upload signature image", "placeholder": "Select image file", "hint": "Upload a PNG or JPG image of your signature" }, - "text": { - "name": "Signer Name", - "placeholder": "Enter your full name" - }, "instructions": { "title": "How to add signature", "canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.", @@ -1961,7 +2085,13 @@ "bullet3": "Can be disabled to reduce output file size" } }, - "submit": "Remove blank pages" + "submit": "Remove blank pages", + "error": { + "failed": "Failed to remove blank pages" + }, + "results": { + "title": "Removed Blank Pages" + } }, "removeAnnotations": { "tags": "comments,highlight,notes,markup,remove", @@ -2063,7 +2193,12 @@ "bullet3": "Choose which page to place the signature", "bullet4": "Optional logo can be included" } - } + }, + "invisible": "Invisible", + "options": { + "title": "Signature Details" + }, + "visible": "Visible" }, "sign": { "submit": "Sign PDF", @@ -2124,7 +2259,22 @@ "text": "Convert your file to a Java keystore (.jks) with keytool, then pick JKS." } } - } + }, + "chooseCertificate": "Choose Certificate File", + "chooseJksFile": "Choose JKS File", + "chooseP12File": "Choose PKCS12 File", + "choosePfxFile": "Choose PFX File", + "choosePrivateKey": "Choose Private Key File", + "location": "Location", + "logoTitle": "Logo", + "name": "Name", + "noLogo": "No Logo", + "pageNumber": "Page Number", + "password": "Certificate Password", + "passwordOptional": "Leave empty if no password", + "reason": "Reason", + "serverCertMessage": "Using server certificate - no files or password required", + "showLogo": "Show Logo" }, "removeCertSign": { "tags": "authenticate,PEM,P12,official,decrypt", @@ -2150,7 +2300,17 @@ "header": "Multi Page Layout", "pagesPerSheet": "Pages per sheet:", "addBorder": "Add Borders", - "submit": "Submit" + "submit": "Submit", + "desc": { + "2": "Place 2 pages side-by-side on a single sheet.", + "3": "Place 3 pages on a single sheet in a single row.", + "4": "Place 4 pages on a single sheet (2 × 2 grid).", + "9": "Place 9 pages on a single sheet (3 × 3 grid).", + "16": "Place 16 pages on a single sheet (4 × 4 grid)." + }, + "error": { + "failed": "An error occurred while creating the multi-page layout." + } }, "bookletImposition": { "tags": "booklet,imposition,printing,binding,folding,signature", @@ -2336,10 +2496,22 @@ "reset": "Reset to full PDF", "coordinates": { "title": "Position and Size", - "x": "X Position", - "y": "Y Position", - "width": "Width", - "height": "Height" + "x": { + "label": "X Position", + "desc": "Left edge (points)" + }, + "y": { + "label": "Y Position", + "desc": "Bottom edge (points)" + }, + "width": { + "label": "Width", + "desc": "Crop width (points)" + }, + "height": { + "label": "Height", + "desc": "Crop height (points)" + } }, "error": { "invalidArea": "Crop area extends beyond PDF boundaries", @@ -2357,6 +2529,10 @@ }, "results": { "title": "Crop Results" + }, + "automation": { + "info": "Enter crop coordinates in PDF points. Origin (0,0) is at bottom-left. These values will be applied to all PDFs processed in this automation.", + "reference": "Reference: A4 page is 595.28 × 841.89 points (210mm × 297mm). 1 inch = 72 points." } }, "autoSplitPDF": { @@ -2586,7 +2762,8 @@ "counts": { "label": "Overlay Counts (for Fixed Repeat Mode)", "placeholder": "Enter comma-separated counts (e.g., 2,3,1)", - "item": "Count for file" + "item": "Count for file", + "noFiles": "Add overlay files to configure counts" }, "position": { "label": "Select Overlay Position", @@ -2627,6 +2804,9 @@ "title": "Counts (Fixed Repeat only)", "text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat." } + }, + "error": { + "failed": "An error occurred while overlaying PDFs." } }, "split-by-sections": { @@ -2662,7 +2842,18 @@ "customMargin": "Custom Margin", "customColor": "Custom Text Colour", "submit": "Submit", - "noStampSelected": "No stamp selected. Return to Step 1." + "noStampSelected": "No stamp selected. Return to Step 1.", + "customPosition": "Drag the stamp to the desired location in the preview window.", + "error": { + "failed": "An error occurred while adding stamp to the PDF." + }, + "imageSize": "Image Size", + "margin": "Margin", + "positionAndFormatting": "Position & Formatting", + "quickPosition": "Select a position on the page to place the stamp.", + "results": { + "title": "Stamp Results" + } }, "removeImagePdf": { "tags": "Remove Image,Page operations,Back end,server side" @@ -2680,7 +2871,8 @@ "status": { "_value": "Status", "valid": "Valid", - "invalid": "Invalid" + "invalid": "Invalid", + "complete": "Validation complete" }, "signer": "Signer", "date": "Date", @@ -2707,16 +2899,71 @@ "version": "Version", "keyUsage": "Key Usage", "selfSigned": "Self-Signed", - "bits": "bits" + "bits": "bits", + "details": "Certificate Details" }, "signature": { "info": "Signature Information", "_value": "Signature", "mathValid": "Signature is mathematically valid BUT:" }, - "selectCustomCert": "Custom Certificate File X.509 (Optional)" + "selectCustomCert": "Custom Certificate File X.509 (Optional)", + "downloadCsv": "Download CSV", + "downloadJson": "Download JSON", + "downloadPdf": "Download PDF Report", + "downloadType": { + "csv": "CSV", + "json": "JSON", + "pdf": "PDF" + }, + "error": { + "allFailed": "Unable to validate the selected files.", + "partial": "Some files could not be validated.", + "reportGeneration": "Could not generate the PDF report. JSON and CSV are available.", + "unexpected": "Unexpected error during validation." + }, + "finalizing": "Preparing downloads...", + "issue": { + "certExpired": "Certificate expired", + "certRevocationUnknown": "Certificate revocation status unknown", + "certRevoked": "Certificate revoked", + "chainInvalid": "Certificate chain invalid", + "signatureInvalid": "Signature cryptographic check failed", + "trustInvalid": "Certificate not trusted" + }, + "noResults": "Run the validation to generate a report.", + "noSignaturesShort": "No signatures", + "processing": "Validating signatures...", + "report": { + "continued": "Continued", + "downloads": "Downloads", + "entryLabel": "Signature Summary", + "fields": { + "created": "Created", + "fileSize": "File Size", + "signatureCount": "Total Signatures", + "signatureDate": "Signature Date" + }, + "filesEvaluated": "{{count}} files evaluated", + "footer": "Validated via Stirling PDF", + "generatedAt": "Generated", + "noPdf": "PDF report will be available after a successful validation.", + "page": "Page", + "shortTitle": "Signature Summary", + "signatureCountLabel": "{{count}} signatures", + "signaturesFound": "{{count}} signatures detected", + "signaturesValid": "{{count}} fully valid", + "title": "Signature Validation Report" + }, + "settings": { + "certHint": "Upload a trusted X.509 certificate to validate against a custom trust source.", + "title": "Validation Settings" + }, + "signatureDate": "Signature Date", + "totalSignatures": "Total Signatures" }, "replaceColor": { + "tags": "Replace Colour,Page operations,Back end,server side", "labels": { "settings": "Settings", "colourOperation": "Colour operation" @@ -2757,9 +3004,6 @@ "failed": "An error occurred while processing the colour replacement." } }, - "replaceColor": { - "tags": "Replace Colour,Page operations,Back end,server side" - }, "login": { "title": "Sign in", "header": "Sign in", @@ -2875,7 +3119,19 @@ "contrast": "Contrast:", "brightness": "Brightness:", "saturation": "Saturation:", - "download": "Download" + "download": "Download", + "adjustColors": "Adjust Colors", + "blue": "Blue", + "confirm": "Confirm", + "error": { + "failed": "Failed to adjust colors/contrast" + }, + "green": "Green", + "noPreview": "Select a PDF to preview", + "red": "Red", + "results": { + "title": "Adjusted PDF" + } }, "compress": { "title": "Compress", @@ -3022,7 +3278,13 @@ "title": "Remove image", "header": "Remove image", "removeImage": "Remove image", - "submit": "Remove image" + "submit": "Remove image", + "error": { + "failed": "Failed to remove images from the PDF." + }, + "results": { + "title": "Remove Images Results" + } }, "splitByChapters": { "title": "Split PDF by Chapters", @@ -3167,7 +3429,9 @@ }, "search": { "title": "Search PDF", - "placeholder": "Enter search term..." + "placeholder": "Enter search term...", + "noResults": "No results found", + "searching": "Searching..." }, "guestBanner": { "title": "You're using Stirling PDF as a guest!", @@ -3289,7 +3553,17 @@ "selectedCount": "{{count}} selected", "download": "Download", "delete": "Delete", - "unsupported": "Unsupported" + "unsupported": "Unsupported", + "addToUpload": "Add to Upload", + "deleteAll": "Delete All", + "loadingFiles": "Loading files...", + "noFiles": "No files available", + "noFilesFound": "No files found matching your search", + "openInPageEditor": "Open in Page Editor", + "showAll": "Show All", + "sortByDate": "Sort by Date", + "sortByName": "Sort by Name", + "sortBySize": "Sort by Size" }, "storage": { "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", @@ -3544,16 +3818,6 @@ "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." } }, - "viewer": { - "firstPage": "First Page", - "lastPage": "Last Page", - "previousPage": "Previous Page", - "nextPage": "Next Page", - "zoomIn": "Zoom In", - "zoomOut": "Zoom Out", - "singlePageView": "Single Page View", - "dualPageView": "Dual Page View" - }, "common": { "copy": "Copy", "copied": "Copied!", @@ -3562,7 +3826,8 @@ "remaining": "remaining", "used": "used", "available": "available", - "cancel": "Cancel" + "cancel": "Cancel", + "preview": "Preview" }, "config": { "account": { @@ -3620,8 +3885,89 @@ "submit": "Add Attachments", "results": { "title": "Attachment Results" + }, + "error": { + "failed": "Add attachments operation failed" } }, "termsAndConditions": "Terms & Conditions", - "logOut": "Log out" + "logOut": "Log out", + "addAttachments": { + "error": { + "failed": "An error occurred while adding attachments to the PDF." + } + }, + "autoRename": { + "description": "This tool will automatically rename PDF files based on their content. It analyzes the document to find the most suitable title from the text." + }, + "customPosition": "Custom Position", + "details": "Details", + "downloadUnavailable": "Download unavailable for this item", + "invalidUndoData": "Cannot undo: invalid operation data", + "margin": { + "large": "Large", + "medium": "Medium", + "small": "Small", + "xLarge": "Extra Large" + }, + "noFilesToUndo": "Cannot undo: no files were processed in the last operation", + "noOperationToUndo": "No operation to undo", + "noValidFiles": "No valid files to process", + "operationCancelled": "Operation cancelled", + "pageEdit": { + "deselectAll": "Select None", + "selectAll": "Select All" + }, + "quickPosition": "Quick Position", + "reorganizePages": { + "error": { + "failed": "Failed to reorganize pages" + }, + "results": { + "title": "Pages Reorganized" + }, + "settings": { + "title": "Settings" + }, + "submit": "Reorganize Pages" + }, + "replace-color": { + "options": { + "fill": "Fill colour", + "gradient": "Gradient" + }, + "previewOverlayOpacity": "Preview overlay opacity", + "previewOverlayTransparency": "Preview overlay transparency", + "previewOverlayVisibility": "Show preview overlay", + "selectText": { + "1": "Replace or invert colour options", + "2": "Default (preset high contrast colours)", + "3": "Custom (choose your own colours)", + "4": "Full invert (invert all colours)", + "5": "High contrast color options", + "6": "White text on black background", + "7": "Black text on white background", + "8": "Yellow text on black background", + "9": "Green text on black background", + "10": "Choose text Color", + "11": "Choose background Color", + "12": "Choose start colour", + "13": "Choose end colour" + }, + "submit": "Replace", + "title": "Replace-Invert-Color" + }, + "size": "Size", + "submit": "Submit", + "success": "Success", + "tools": { + "noSearchResults": "No tools found", + "noTools": "No tools available" + }, + "undoDataMismatch": "Cannot undo: operation data is corrupted", + "undoFailed": "Failed to undo operation", + "undoQuotaError": "Cannot undo: insufficient storage space", + "undoStorageError": "Undo completed but some files could not be saved to storage", + "undoSuccess": "Operation undone successfully", + "unsupported": "Unsupported" } diff --git a/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx b/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx index be79f4e46..2907ff498 100644 --- a/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx +++ b/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx @@ -43,7 +43,7 @@ const AddPageNumbersPositionSettings = ({ {t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')} - + onCoordinateChange('x', value)} @@ -47,7 +47,7 @@ const CropCoordinateInputs = ({ size={showAutomationInfo ? "sm" : "xs"} /> onCoordinateChange('y', value)} @@ -62,7 +62,7 @@ const CropCoordinateInputs = ({ onCoordinateChange('width', value)} @@ -74,7 +74,7 @@ const CropCoordinateInputs = ({ size={showAutomationInfo ? "sm" : "xs"} /> onCoordinateChange('height', value)} diff --git a/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts b/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts index e2a101a1c..45eb8e8dd 100644 --- a/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts +++ b/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts @@ -33,6 +33,6 @@ export const useReplaceColorOperation = () => { return useToolOperation({ ...replaceColorOperationConfig, - getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the color replacement.')) + getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the colour replacement.')) }); -}; \ No newline at end of file +}; diff --git a/frontend/src/tests/missingTranslations.test.ts b/frontend/src/tests/missingTranslations.test.ts new file mode 100644 index 000000000..908ab1504 --- /dev/null +++ b/frontend/src/tests/missingTranslations.test.ts @@ -0,0 +1,178 @@ +import fs from 'fs'; +import path from 'path'; +import ts from 'typescript'; +import { describe, expect, test } from 'vitest'; + +const REPO_ROOT = path.join(__dirname, '../../../') +const SRC_ROOT = path.join(__dirname, '..'); +const EN_GB_FILE = path.join(__dirname, '../../public/locales/en-GB/translation.json'); + +const IGNORED_DIRS = new Set([ + 'tests', + '__mocks__', +]); +const IGNORED_FILE_PATTERNS = [ + /\.d\.ts$/, + /\.test\./, + /\.spec\./, + /\.stories\./, +]; +const IGNORED_KEYS = new Set([ + // If the script has found a false-positive that shouldn't be in the translations, include it here +]); + +type FoundKey = { + key: string; + file: string; + line: number; + column: number; +}; + +const flattenKeys = (node: unknown, prefix = '', acc = new Set()): Set => { + if (!node || typeof node !== 'object' || Array.isArray(node)) { + if (prefix) { + acc.add(prefix); + } + return acc; + } + + for (const [childKey, value] of Object.entries(node as Record)) { + const next = prefix ? `${prefix}.${childKey}` : childKey; + flattenKeys(value, next, acc); + } + + return acc; +}; + +const listSourceFiles = (): string[] => { + const files = ts.sys.readDirectory(SRC_ROOT, ['.ts', '.tsx', '.js', '.jsx'], undefined, [ + '**/*', + ]); + + return files + .filter((file) => !file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment))) + .filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file))); +}; + +const getScriptKind = (file: string): ts.ScriptKind => { + if (file.endsWith('.tsx')) { + return ts.ScriptKind.TSX; + } + + if (file.endsWith('.ts')) { + return ts.ScriptKind.TS; + } + + if (file.endsWith('.jsx')) { + return ts.ScriptKind.JSX; + } + + return ts.ScriptKind.JS; +}; + +/** + * Find all of the static first keys for translation functions that we can. + * Ignores dynamic strings because we can't know what the actual translation key will be. + */ +const extractKeys = (file: string): FoundKey[] => { + const code = fs.readFileSync(file, 'utf8'); + const sourceFile = ts.createSourceFile( + file, + code, + ts.ScriptTarget.Latest, + true, + getScriptKind(file), + ); + + const found: FoundKey[] = []; + + const record = (node: ts.Node, key: string) => { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); + found.push({ key, file, line: line + 1, column: character + 1 }); + }; + + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + const arg = node.arguments.at(0); + + const isT = + (ts.isIdentifier(callee) && callee.text === 't') || + (ts.isPropertyAccessExpression(callee) && callee.name.text === 't'); + + if (isT && arg && (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))) { + record(arg, arg.text); + } + } + + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + for (const attr of node.attributes.properties) { + if ( + !ts.isJsxAttribute(attr) || + attr.name.getText(sourceFile) !== 'i18nKey' || + !attr.initializer + ) { + continue; + } + + const init = attr.initializer; + + if (ts.isStringLiteral(init)) { + record(init, init.text); + continue; + } + + if ( + ts.isJsxExpression(init) && + init.expression && + ts.isStringLiteral(init.expression) + ) { + record(init.expression, init.expression.text); + } + } + } + + ts.forEachChild(node, visit); + }; + + ts.forEachChild(sourceFile, visit); + return found; +}; + +describe('Missing translation coverage', () => { + test('fails if any en-GB translation key used in source is missing', () => { + expect(fs.existsSync(EN_GB_FILE)).toBe(true); + + const localeContent = fs.readFileSync(EN_GB_FILE, 'utf8'); + const enGb = JSON.parse(localeContent); + const availableKeys = flattenKeys(enGb); + + const usedKeys = listSourceFiles() + .flatMap(extractKeys) + .filter(({ key }) => !IGNORED_KEYS.has(key)); + expect(usedKeys.length).toBeGreaterThan(100); // Sanity check + + const missingKeys = usedKeys.filter(({ key }) => !availableKeys.has(key)); + + const annotations = missingKeys.map(({ key, file, line, column }) => { + const workspaceRelativeRaw = path.relative(REPO_ROOT, file); + const workspaceRelativeFile = workspaceRelativeRaw.replace(/\\/g, '/'); + + return { + key, + file: workspaceRelativeFile, + line, + column, + }; + }); + + // Output errors in GitHub Annotations format so they appear tagged in the code in CI + for (const { key, file, line, column } of annotations) { + process.stderr.write( + `::error file=${file},line=${line},col=${column}::Missing en-GB translation for ${key}\n`, + ); + } + + expect(missingKeys).toEqual([]); + }); +});