mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge branch 'V2' into feature/v2/exportpdf
This commit is contained in:
@@ -203,3 +203,10 @@ id_ed25519.pub
|
||||
|
||||
# node_modules
|
||||
node_modules/
|
||||
|
||||
# Translation temp files
|
||||
*_compact.json
|
||||
*compact*.json
|
||||
test_batch.json
|
||||
*.backup.*.json
|
||||
frontend/public/locales/*/translation.backup*.json
|
||||
|
||||
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
@@ -20,6 +21,8 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -111,6 +114,32 @@ public class MergeController {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse client file IDs from JSON string
|
||||
private String[] parseClientFileIds(String clientFileIds) {
|
||||
if (clientFileIds == null || clientFileIds.trim().isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
try {
|
||||
// Simple JSON array parsing - remove brackets and split by comma
|
||||
String trimmed = clientFileIds.trim();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
String inside = trimmed.substring(1, trimmed.length() - 1).trim();
|
||||
if (inside.isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
String[] parts = inside.split(",");
|
||||
String[] result = new String[parts.length];
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse client file IDs: {}", clientFileIds, e);
|
||||
}
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
// Adds a table of contents to the merged document using filenames as chapter titles
|
||||
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
|
||||
// Create the document outline
|
||||
@@ -177,15 +206,48 @@ public class MergeController {
|
||||
|
||||
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
||||
long totalSize = 0;
|
||||
for (MultipartFile multipartFile : files) {
|
||||
List<Integer> invalidIndexes = new ArrayList<>();
|
||||
for (int index = 0; index < files.length; index++) {
|
||||
MultipartFile multipartFile = files[index];
|
||||
totalSize += multipartFile.getSize();
|
||||
File tempFile =
|
||||
GeneralUtils.convertMultipartFileToFile(
|
||||
multipartFile); // Convert MultipartFile to File
|
||||
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
|
||||
|
||||
// Pre-validate each PDF so we can report which one(s) are broken
|
||||
// Use the original MultipartFile to avoid deleting the tempFile during validation
|
||||
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
|
||||
// OK
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("PDF pre-validate", e);
|
||||
invalidIndexes.add(index);
|
||||
}
|
||||
mergerUtility.addSource(tempFile); // Add source file to the merger utility
|
||||
}
|
||||
|
||||
if (!invalidIndexes.isEmpty()) {
|
||||
// Parse client file IDs (always present from frontend)
|
||||
String[] clientIds = parseClientFileIds(request.getClientFileIds());
|
||||
|
||||
// Map invalid indexes to client IDs
|
||||
List<String> errorFileIds = new ArrayList<>();
|
||||
for (Integer index : invalidIndexes) {
|
||||
if (index < clientIds.length) {
|
||||
errorFileIds.add(clientIds[index]);
|
||||
}
|
||||
}
|
||||
|
||||
String payload = String.format(
|
||||
"{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}",
|
||||
errorFileIds.toString()
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.body(payload.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
|
||||
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
|
||||
|
||||
|
||||
@@ -39,4 +39,10 @@ public class MergePdfsRequest extends MultiplePDFFiles {
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "false")
|
||||
private boolean generateToc = false;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"JSON array of client-provided IDs for each uploaded file (same order as fileInput)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||
private String clientFileIds;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Add .mjs MIME type mapping
|
||||
types {
|
||||
text/javascript mjs;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -90,6 +95,14 @@ http {
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
# Serve .mjs files with correct MIME type (must come before general static assets)
|
||||
location ~* \.mjs$ {
|
||||
try_files $uri =404;
|
||||
add_header Content-Type "text/javascript; charset=utf-8" always;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,7 +74,10 @@
|
||||
},
|
||||
"error": {
|
||||
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
|
||||
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
|
||||
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
|
||||
"_value": "Error",
|
||||
"dismissAllErrors": "Dismiss All Errors",
|
||||
"sorry": "Sorry for the issue!",
|
||||
"needHelp": "Need help / Found an issue?",
|
||||
"contactTip": "If you're still having trouble, don't hesitate to reach out to us for help. You can submit a ticket on our GitHub page or contact us through Discord:",
|
||||
@@ -357,222 +360,277 @@
|
||||
"globalPopularity": "Global Popularity",
|
||||
"sortBy": "Sort by:",
|
||||
"multiTool": {
|
||||
"tags": "multiple,tools",
|
||||
"title": "PDF Multi Tool",
|
||||
"desc": "Merge, Rotate, Rearrange, Split, and Remove pages"
|
||||
},
|
||||
"merge": {
|
||||
"tags": "combine,join,unite",
|
||||
"title": "Merge",
|
||||
"desc": "Easily merge multiple PDFs into one."
|
||||
},
|
||||
"split": {
|
||||
"tags": "divide,separate,break",
|
||||
"title": "Split",
|
||||
"desc": "Split PDFs into multiple documents"
|
||||
},
|
||||
"rotate": {
|
||||
"tags": "turn,flip,orient",
|
||||
"title": "Rotate",
|
||||
"desc": "Easily rotate your PDFs."
|
||||
},
|
||||
"convert": {
|
||||
"tags": "transform,change",
|
||||
"title": "Convert",
|
||||
"desc": "Convert files between different formats"
|
||||
},
|
||||
"pdfOrganiser": {
|
||||
"tags": "organize,rearrange,reorder",
|
||||
"title": "Organise",
|
||||
"desc": "Remove/Rearrange pages in any order"
|
||||
},
|
||||
"addImage": {
|
||||
"tags": "insert,embed,place",
|
||||
"title": "Add image",
|
||||
"desc": "Adds a image onto a set location on the PDF"
|
||||
},
|
||||
"addAttachments": {
|
||||
"tags": "embed,attach,include",
|
||||
"title": "Add Attachments",
|
||||
"desc": "Add or remove embedded files (attachments) to/from a PDF"
|
||||
},
|
||||
"watermark": {
|
||||
"tags": "stamp,mark,overlay",
|
||||
"title": "Add Watermark",
|
||||
"desc": "Add a custom watermark to your PDF document."
|
||||
},
|
||||
"removePassword": {
|
||||
"tags": "unlock",
|
||||
"title": "Remove Password",
|
||||
"desc": "Remove password protection from your PDF document."
|
||||
},
|
||||
"compress": {
|
||||
"tags": "shrink,reduce,optimize",
|
||||
"title": "Compress",
|
||||
"desc": "Compress PDFs to reduce their file size."
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"tags": "unlock,enable,edit",
|
||||
"title": "Unlock PDF Forms",
|
||||
"desc": "Remove read-only property of form fields in a PDF document."
|
||||
},
|
||||
"changeMetadata": {
|
||||
"tags": "edit,modify,update",
|
||||
"title": "Change Metadata",
|
||||
"desc": "Change/Remove/Add metadata from a PDF document"
|
||||
},
|
||||
"ocr": {
|
||||
"tags": "extract,scan",
|
||||
"title": "OCR / Cleanup scans",
|
||||
"desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text."
|
||||
},
|
||||
"extractImages": {
|
||||
"tags": "pull,save,export",
|
||||
"title": "Extract Images",
|
||||
"desc": "Extracts all images from a PDF and saves them to zip"
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"tags": "detect,split,photos",
|
||||
"title": "Detect/Split Scanned photos",
|
||||
"desc": "Splits multiple photos from within a photo/PDF"
|
||||
},
|
||||
"sign": {
|
||||
"tags": "signature,autograph",
|
||||
"title": "Sign",
|
||||
"desc": "Adds signature to PDF by drawing, text or image"
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "simplify,remove,interactive",
|
||||
"title": "Flatten",
|
||||
"desc": "Remove all interactive elements and forms from a PDF"
|
||||
},
|
||||
"certSign": {
|
||||
"tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto",
|
||||
"title": "Sign with Certificate",
|
||||
"desc": "Signs a PDF with a Certificate/Key (PEM/P12)"
|
||||
},
|
||||
"repair": {
|
||||
"tags": "fix,restore",
|
||||
"title": "Repair",
|
||||
"desc": "Tries to repair a corrupt/broken PDF"
|
||||
},
|
||||
"removeBlanks": {
|
||||
"tags": "delete,clean,empty",
|
||||
"title": "Remove Blank pages",
|
||||
"desc": "Detects and removes blank pages from a document"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"tags": "delete,clean,strip",
|
||||
"title": "Remove Annotations",
|
||||
"desc": "Removes all comments/annotations from a PDF"
|
||||
},
|
||||
"compare": {
|
||||
"tags": "difference",
|
||||
"title": "Compare",
|
||||
"desc": "Compares and shows the differences between 2 PDF Documents"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"tags": "remove,delete,unlock",
|
||||
"title": "Remove Certificate Sign",
|
||||
"desc": "Remove certificate signature from PDF"
|
||||
},
|
||||
"pageLayout": {
|
||||
"tags": "layout,arrange,combine",
|
||||
"title": "Multi-Page Layout",
|
||||
"desc": "Merge multiple pages of a PDF document into a single page"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"tags": "booklet,print,binding",
|
||||
"title": "Booklet Imposition",
|
||||
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
|
||||
},
|
||||
"scalePages": {
|
||||
"tags": "resize,adjust,scale",
|
||||
"title": "Adjust page size/scale",
|
||||
"desc": "Change the size/scale of a page and/or its contents."
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"tags": "number,pagination,count",
|
||||
"title": "Add Page Numbers",
|
||||
"desc": "Add Page numbers throughout a document in a set location"
|
||||
},
|
||||
"autoRename": {
|
||||
"tags": "auto-detect,header-based,organize,relabel",
|
||||
"title": "Auto Rename PDF File",
|
||||
"desc": "Auto renames a PDF file based on its detected header"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colours/Contrast",
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
"title": "Crop PDF",
|
||||
"desc": "Crop a PDF to reduce its size (maintains text!)"
|
||||
},
|
||||
"autoSplitPDF": {
|
||||
"tags": "auto,split,QR",
|
||||
"title": "Auto Split Pages",
|
||||
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
|
||||
},
|
||||
"sanitize": {
|
||||
"tags": "clean,purge,remove",
|
||||
"title": "Sanitise",
|
||||
"desc": "Remove potentially harmful elements from PDF files"
|
||||
},
|
||||
"getPdfInfo": {
|
||||
"tags": "info,metadata,details",
|
||||
"title": "Get ALL Info on PDF",
|
||||
"desc": "Grabs any and all information possible on PDFs"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"tags": "combine,merge,single",
|
||||
"title": "PDF to Single Large Page",
|
||||
"desc": "Merges all PDF pages into one large single page"
|
||||
},
|
||||
"showJS": {
|
||||
"tags": "javascript,code,script",
|
||||
"title": "Show Javascript",
|
||||
"desc": "Searches and displays any JS injected into a PDF"
|
||||
},
|
||||
"redact": {
|
||||
"tags": "censor,blackout,hide",
|
||||
"title": "Redact",
|
||||
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
|
||||
},
|
||||
"overlayPdfs": {
|
||||
"tags": "overlay,combine,stack",
|
||||
"title": "Overlay PDFs",
|
||||
"desc": "Overlays PDFs on-top of another PDF"
|
||||
},
|
||||
"splitBySections": {
|
||||
"tags": "split,sections,divide",
|
||||
"title": "Split PDF by Sections",
|
||||
"desc": "Divide each page of a PDF into smaller horizontal and vertical sections"
|
||||
},
|
||||
"addStamp": {
|
||||
"tags": "stamp,mark,seal",
|
||||
"title": "Add Stamp to PDF",
|
||||
"desc": "Add text or add image stamps at set locations"
|
||||
},
|
||||
"removeImage": {
|
||||
"tags": "remove,delete,clean",
|
||||
"title": "Remove image",
|
||||
"desc": "Remove image from PDF to reduce file size"
|
||||
},
|
||||
"splitByChapters": {
|
||||
"tags": "split,chapters,structure",
|
||||
"title": "Split PDF by Chapters",
|
||||
"desc": "Split a PDF into multiple files based on its chapter structure."
|
||||
},
|
||||
"validateSignature": {
|
||||
"tags": "validate,verify,certificate",
|
||||
"title": "Validate PDF Signature",
|
||||
"desc": "Verify digital signatures and certificates in PDF documents"
|
||||
},
|
||||
"swagger": {
|
||||
"tags": "API,documentation,test",
|
||||
"title": "API Documentation",
|
||||
"desc": "View API documentation and test endpoints"
|
||||
},
|
||||
"fakeScan": {
|
||||
"tags": "scan,simulate,create",
|
||||
"title": "Fake Scan",
|
||||
"desc": "Create a PDF that looks like it was scanned"
|
||||
},
|
||||
"editTableOfContents": {
|
||||
"tags": "bookmarks,contents,edit",
|
||||
"title": "Edit Table of Contents",
|
||||
"desc": "Add or edit bookmarks and table of contents in PDF documents"
|
||||
},
|
||||
"manageCertificates": {
|
||||
"tags": "certificates,import,export",
|
||||
"title": "Manage Certificates",
|
||||
"desc": "Import, export, or delete digital certificate files used for signing PDFs."
|
||||
},
|
||||
"read": {
|
||||
"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": {
|
||||
"tags": "rearrange,reorder,organize",
|
||||
"title": "Reorganize Pages",
|
||||
"desc": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
|
||||
},
|
||||
"extractPages": {
|
||||
"tags": "pull,select,copy",
|
||||
"title": "Extract Pages",
|
||||
"desc": "Extract specific pages from a PDF document"
|
||||
},
|
||||
"removePages": {
|
||||
"tags": "delete,extract,exclude",
|
||||
"title": "Remove Pages",
|
||||
"desc": "Remove specific pages from a PDF document"
|
||||
},
|
||||
"autoSizeSplitPDF": {
|
||||
"tags": "auto,split,size",
|
||||
"title": "Auto Split by Size/Count",
|
||||
"desc": "Automatically split PDFs by file size or page count"
|
||||
},
|
||||
"replaceColorPdf": {
|
||||
"tags": "color,replace,invert",
|
||||
"title": "Replace & Invert Colour",
|
||||
"desc": "Replace or invert colours in PDF documents"
|
||||
},
|
||||
"devApi": {
|
||||
"tags": "API,development,documentation",
|
||||
"title": "API",
|
||||
"desc": "Link to API documentation"
|
||||
},
|
||||
"devFolderScanning": {
|
||||
"tags": "automation,folder,scanning",
|
||||
"title": "Automated Folder Scanning",
|
||||
"desc": "Link to automated folder scanning guide"
|
||||
},
|
||||
@@ -593,6 +651,7 @@
|
||||
"desc": "Change document restrictions and permissions"
|
||||
},
|
||||
"automate": {
|
||||
"tags": "workflow,sequence,automation",
|
||||
"title": "Automate",
|
||||
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
||||
}
|
||||
@@ -659,7 +718,6 @@
|
||||
}
|
||||
},
|
||||
"split": {
|
||||
"tags": "Page operations,divide,Multi Page,cut,server side",
|
||||
"title": "Split PDF",
|
||||
"header": "Split PDF",
|
||||
"desc": {
|
||||
@@ -785,7 +843,6 @@
|
||||
}
|
||||
},
|
||||
"rotate": {
|
||||
"tags": "server side",
|
||||
"title": "Rotate PDF",
|
||||
"submit": "Apply Rotation",
|
||||
"error": {
|
||||
@@ -1298,7 +1355,6 @@
|
||||
}
|
||||
},
|
||||
"changeMetadata": {
|
||||
"tags": "Title,author,date,creation,time,publisher,producer,stats",
|
||||
"header": "Change Metadata",
|
||||
"submit": "Change",
|
||||
"filenamePrefix": "metadata",
|
||||
@@ -1613,7 +1669,6 @@
|
||||
"info": "Python is not installed. It is required to run."
|
||||
},
|
||||
"sign": {
|
||||
"tags": "authorize,initials,drawn-signature,text-sign,image-signature",
|
||||
"title": "Sign",
|
||||
"header": "Sign PDFs",
|
||||
"upload": "Upload Image",
|
||||
@@ -1637,7 +1692,6 @@
|
||||
"redo": "Redo"
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "static,deactivate,non-interactive,streamline",
|
||||
"title": "Flatten",
|
||||
"header": "Flatten PDF",
|
||||
"flattenOnlyForms": "Flatten only forms",
|
||||
@@ -1702,7 +1756,6 @@
|
||||
}
|
||||
},
|
||||
"removeBlanks": {
|
||||
"tags": "cleanup,streamline,non-content,organize",
|
||||
"title": "Remove Blanks",
|
||||
"header": "Remove Blank Pages",
|
||||
"settings": {
|
||||
@@ -2099,7 +2152,6 @@
|
||||
"tags": "color-correction,tune,modify,enhance,colour-correction"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,shrink,edit,shape",
|
||||
"title": "Crop",
|
||||
"header": "Crop PDF",
|
||||
"submit": "Apply Crop",
|
||||
|
||||
@@ -68,6 +68,8 @@
|
||||
},
|
||||
"error": {
|
||||
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
|
||||
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
|
||||
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
|
||||
"_value": "Error",
|
||||
"sorry": "Sorry for the issue!",
|
||||
"needHelp": "Need help / Found an issue?",
|
||||
@@ -348,206 +350,257 @@
|
||||
"globalPopularity": "Global Popularity",
|
||||
"sortBy": "Sort by:",
|
||||
"multiTool": {
|
||||
"tags": "multiple,tools",
|
||||
"title": "PDF Multi Tool",
|
||||
"desc": "Merge, Rotate, Rearrange, Split, and Remove pages"
|
||||
},
|
||||
"merge": {
|
||||
"tags": "combine,join,unite",
|
||||
"title": "Merge",
|
||||
"desc": "Easily merge multiple PDFs into one."
|
||||
},
|
||||
"split": {
|
||||
"tags": "divide,separate,break",
|
||||
"title": "Split",
|
||||
"desc": "Split PDFs into multiple documents"
|
||||
},
|
||||
"rotate": {
|
||||
"tags": "turn,flip,orient",
|
||||
"title": "Rotate",
|
||||
"desc": "Easily rotate your PDFs."
|
||||
},
|
||||
"imageToPDF": {
|
||||
"tags": "convert,image,transform",
|
||||
"title": "Image to PDF",
|
||||
"desc": "Convert a image (PNG, JPEG, GIF) to PDF."
|
||||
},
|
||||
"pdfToImage": {
|
||||
"tags": "convert,image,extract",
|
||||
"title": "PDF to Image",
|
||||
"desc": "Convert a PDF to a image. (PNG, JPEG, GIF)"
|
||||
},
|
||||
"pdfOrganiser": {
|
||||
"tags": "organize,rearrange,reorder",
|
||||
"title": "Organize",
|
||||
"desc": "Remove/Rearrange pages in any order"
|
||||
},
|
||||
"addImage": {
|
||||
"tags": "insert,embed,place",
|
||||
"title": "Add image",
|
||||
"desc": "Adds a image onto a set location on the PDF"
|
||||
},
|
||||
"watermark": {
|
||||
"tags": "stamp,mark,overlay",
|
||||
"title": "Add Watermark",
|
||||
"desc": "Add a custom watermark to your PDF document."
|
||||
},
|
||||
"permissions": {
|
||||
"tags": "permissions,security,access",
|
||||
"title": "Change Permissions",
|
||||
"desc": "Change the permissions of your PDF document"
|
||||
},
|
||||
"pageRemover": {
|
||||
"tags": "remove,delete,pages",
|
||||
"title": "Remove",
|
||||
"desc": "Delete unwanted pages from your PDF document."
|
||||
},
|
||||
"addPassword": {
|
||||
"tags": "password,encrypt,secure",
|
||||
"title": "Add Password",
|
||||
"desc": "Encrypt your PDF document with a password."
|
||||
},
|
||||
"changePermissions": {
|
||||
"tags": "permissions,restrictions,security",
|
||||
"title": "Change Permissions",
|
||||
"desc": "Change document restrictions and permissions."
|
||||
},
|
||||
"removePassword": {
|
||||
"tags": "unlock,remove,password",
|
||||
"title": "Remove Password",
|
||||
"desc": "Remove password protection from your PDF document."
|
||||
},
|
||||
"compress": {
|
||||
"tags": "shrink,reduce,optimize",
|
||||
"title": "Compress",
|
||||
"desc": "Compress PDFs to reduce their file size."
|
||||
},
|
||||
"sanitize": {
|
||||
"tags": "clean,purge,remove",
|
||||
"title": "Sanitize",
|
||||
"desc": "Remove potentially harmful elements from PDF files."
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"tags": "unlock,enable,edit",
|
||||
"title": "Unlock PDF Forms",
|
||||
"desc": "Remove read-only property of form fields in a PDF document."
|
||||
},
|
||||
"changeMetadata": {
|
||||
"tags": "edit,modify,update",
|
||||
"title": "Change Metadata",
|
||||
"desc": "Change/Remove/Add metadata from a PDF document"
|
||||
},
|
||||
"fileToPDF": {
|
||||
"tags": "convert,transform,change",
|
||||
"title": "Convert file to PDF",
|
||||
"desc": "Convert nearly any file to PDF (DOCX, PNG, XLS, PPT, TXT and more)"
|
||||
},
|
||||
"ocr": {
|
||||
"tags": "extract,scan",
|
||||
"title": "OCR / Cleanup scans",
|
||||
"desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text."
|
||||
},
|
||||
"extractImages": {
|
||||
"tags": "pull,save,export",
|
||||
"title": "Extract Images",
|
||||
"desc": "Extracts all images from a PDF and saves them to zip"
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "convert,archive,long-term",
|
||||
"title": "PDF to PDF/A",
|
||||
"desc": "Convert PDF to PDF/A for long-term storage"
|
||||
},
|
||||
"PDFToWord": {
|
||||
"tags": "convert,word,doc",
|
||||
"title": "PDF to Word",
|
||||
"desc": "Convert PDF to Word formats (DOC, DOCX and ODT)"
|
||||
},
|
||||
"PDFToPresentation": {
|
||||
"tags": "convert,presentation,ppt",
|
||||
"title": "PDF to Presentation",
|
||||
"desc": "Convert PDF to Presentation formats (PPT, PPTX and ODP)"
|
||||
},
|
||||
"PDFToText": {
|
||||
"tags": "convert,text,rtf",
|
||||
"title": "PDF to RTF (Text)",
|
||||
"desc": "Convert PDF to Text or RTF format"
|
||||
},
|
||||
"PDFToHTML": {
|
||||
"tags": "convert,html,web",
|
||||
"title": "PDF to HTML",
|
||||
"desc": "Convert PDF to HTML format"
|
||||
},
|
||||
"PDFToXML": {
|
||||
"tags": "convert,xml,data",
|
||||
"title": "PDF to XML",
|
||||
"desc": "Convert PDF to XML format"
|
||||
},
|
||||
"ScannerImageSplit": {
|
||||
"tags": "detect,split,photos",
|
||||
"title": "Detect/Split Scanned photos",
|
||||
"desc": "Splits multiple photos from within a photo/PDF"
|
||||
},
|
||||
"sign": {
|
||||
"tags": "signature,autograph",
|
||||
"title": "Sign",
|
||||
"desc": "Adds signature to PDF by drawing, text or image"
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "simplify,remove,interactive",
|
||||
"title": "Flatten",
|
||||
"desc": "Remove all interactive elements and forms from a PDF"
|
||||
},
|
||||
"repair": {
|
||||
"tags": "fix,restore",
|
||||
"title": "Repair",
|
||||
"desc": "Tries to repair a corrupt/broken PDF"
|
||||
},
|
||||
"removeBlanks": {
|
||||
"tags": "delete,clean,empty",
|
||||
"title": "Remove Blank pages",
|
||||
"desc": "Detects and removes blank pages from a document"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"tags": "delete,clean,strip",
|
||||
"title": "Remove Annotations",
|
||||
"desc": "Removes all comments/annotations from a PDF"
|
||||
},
|
||||
"compare": {
|
||||
"tags": "difference",
|
||||
"title": "Compare",
|
||||
"desc": "Compares and shows the differences between 2 PDF Documents"
|
||||
},
|
||||
"certSign": {
|
||||
"tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto",
|
||||
"title": "Sign with Certificate",
|
||||
"desc": "Signs a PDF with a Certificate/Key (PEM/P12)"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"tags": "remove,delete,unlock",
|
||||
"title": "Remove Certificate Sign",
|
||||
"desc": "Remove certificate signature from PDF"
|
||||
},
|
||||
"pageLayout": {
|
||||
"tags": "layout,arrange,combine",
|
||||
"title": "Multi-Page Layout",
|
||||
"desc": "Merge multiple pages of a PDF document into a single page"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"tags": "booklet,print,binding",
|
||||
"title": "Booklet Imposition",
|
||||
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
|
||||
},
|
||||
"scalePages": {
|
||||
"tags": "resize,adjust,scale",
|
||||
"title": "Adjust page size/scale",
|
||||
"desc": "Change the size/scale of a page and/or its contents."
|
||||
},
|
||||
"pipeline": {
|
||||
"tags": "automation,script,workflow",
|
||||
"title": "Pipeline",
|
||||
"desc": "Run multiple actions on PDFs by defining pipeline scripts"
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"tags": "number,pagination,count",
|
||||
"title": "Add Page Numbers",
|
||||
"desc": "Add Page numbers throughout a document in a set location"
|
||||
},
|
||||
"auto-rename": {
|
||||
"tags": "auto-detect,header-based,organize,relabel",
|
||||
"title": "Auto Rename PDF File",
|
||||
"desc": "Auto renames a PDF file based on its detected header"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
"title": "Crop PDF",
|
||||
"desc": "Crop a PDF to reduce its size (maintains text!)"
|
||||
},
|
||||
"autoSplitPDF": {
|
||||
"tags": "auto,split,QR",
|
||||
"title": "Auto Split Pages",
|
||||
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
|
||||
},
|
||||
"sanitizePDF": {
|
||||
"tags": "clean,purge,remove",
|
||||
"title": "Sanitize",
|
||||
"desc": "Remove scripts and other elements from PDF files"
|
||||
},
|
||||
"URLToPDF": {
|
||||
"tags": "convert,url,website",
|
||||
"title": "URL/Website To PDF",
|
||||
"desc": "Converts any http(s)URL to PDF"
|
||||
},
|
||||
"HTMLToPDF": {
|
||||
"tags": "convert,html,web",
|
||||
"title": "HTML to PDF",
|
||||
"desc": "Converts any HTML file or zip to PDF"
|
||||
},
|
||||
"MarkdownToPDF": {
|
||||
"tags": "convert,markdown,md",
|
||||
"title": "Markdown to PDF",
|
||||
"desc": "Converts any Markdown file to PDF"
|
||||
},
|
||||
"PDFToMarkdown": {
|
||||
"tags": "convert,markdown,md",
|
||||
"title": "PDF to Markdown",
|
||||
"desc": "Converts any PDF to Markdown"
|
||||
},
|
||||
"getPdfInfo": {
|
||||
"tags": "info,metadata,details",
|
||||
"title": "Get ALL Info on PDF",
|
||||
"desc": "Grabs any and all information possible on PDFs"
|
||||
},
|
||||
@@ -564,50 +617,62 @@
|
||||
"desc": "Searches and displays any JS injected into a PDF"
|
||||
},
|
||||
"autoRedact": {
|
||||
"tags": "auto,redact,censor",
|
||||
"title": "Auto Redact",
|
||||
"desc": "Auto Redacts(Blacks out) text in a PDF based on input text"
|
||||
},
|
||||
"redact": {
|
||||
"tags": "censor,blackout,hide",
|
||||
"title": "Manual Redaction",
|
||||
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
|
||||
},
|
||||
"PDFToCSV": {
|
||||
"tags": "convert,csv,table",
|
||||
"title": "PDF to CSV",
|
||||
"desc": "Extracts Tables from a PDF converting it to CSV"
|
||||
},
|
||||
"split-by-size-or-count": {
|
||||
"tags": "auto,split,size",
|
||||
"title": "Auto Split by Size/Count",
|
||||
"desc": "Split a single PDF into multiple documents based on size, page count, or document count"
|
||||
},
|
||||
"overlay-pdfs": {
|
||||
"tags": "overlay,combine,stack",
|
||||
"title": "Overlay PDFs",
|
||||
"desc": "Overlays PDFs on-top of another PDF"
|
||||
},
|
||||
"split-by-sections": {
|
||||
"tags": "split,sections,divide",
|
||||
"title": "Split PDF by Sections",
|
||||
"desc": "Divide each page of a PDF into smaller horizontal and vertical sections"
|
||||
},
|
||||
"AddStampRequest": {
|
||||
"tags": "stamp,mark,seal",
|
||||
"title": "Add Stamp to PDF",
|
||||
"desc": "Add text or add image stamps at set locations"
|
||||
},
|
||||
"removeImage": {
|
||||
"tags": "remove,delete,clean",
|
||||
"title": "Remove image",
|
||||
"desc": "Remove image from PDF to reduce file size"
|
||||
},
|
||||
"splitByChapters": {
|
||||
"tags": "split,chapters,structure",
|
||||
"title": "Split PDF by Chapters",
|
||||
"desc": "Split a PDF into multiple files based on its chapter structure."
|
||||
},
|
||||
"validateSignature": {
|
||||
"tags": "validate,verify,certificate",
|
||||
"title": "Validate PDF Signature",
|
||||
"desc": "Verify digital signatures and certificates in PDF documents"
|
||||
},
|
||||
"swagger": {
|
||||
"tags": "API,documentation,test",
|
||||
"title": "API Documentation",
|
||||
"desc": "View API documentation and test endpoints"
|
||||
},
|
||||
"replace-color": {
|
||||
"tags": "color,replace,invert",
|
||||
"title": "Replace and Invert Color",
|
||||
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size"
|
||||
}
|
||||
@@ -1064,7 +1129,6 @@
|
||||
"info": "Python is not installed. It is required to run."
|
||||
},
|
||||
"sign": {
|
||||
"tags": "authorize,initials,drawn-signature,text-sign,image-signature",
|
||||
"title": "Sign",
|
||||
"header": "Sign PDFs",
|
||||
"upload": "Upload Image",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,20 @@
|
||||
border-bottom: 1px solid var(--header-selected-bg);
|
||||
}
|
||||
|
||||
/* Error highlight (transient) */
|
||||
.headerError {
|
||||
background: var(--color-red-200);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--color-red-500);
|
||||
}
|
||||
|
||||
/* Unsupported (but not errored) header appearance */
|
||||
.headerUnsupported {
|
||||
background: var(--unsupported-bar-bg); /* neutral gray */
|
||||
color: #FFFFFF;
|
||||
border-bottom: 1px solid var(--unsupported-bar-border);
|
||||
}
|
||||
|
||||
/* Selected border color in light mode */
|
||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||
outline-color: var(--card-selected-border);
|
||||
@@ -80,6 +94,7 @@
|
||||
|
||||
.kebab {
|
||||
justify-self: end;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* Menu dropdown */
|
||||
@@ -217,6 +232,22 @@
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Error pill shown when a file failed processing */
|
||||
.errorPill {
|
||||
margin-left: 1.75rem;
|
||||
background: var(--color-red-500);
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 56px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
|
||||
Text, Center, Box, LoadingOverlay, Stack, Group
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
|
||||
@@ -11,6 +11,7 @@ import FileEditorThumbnail from './FileEditorThumbnail';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||
import { alert } from '../toast';
|
||||
import { downloadBlob } from '../../utils/downloadUtils';
|
||||
|
||||
|
||||
@@ -46,8 +47,16 @@ const FileEditor = ({
|
||||
// Get file selection context
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [_status, _setStatus] = useState<string | null>(null);
|
||||
const [_error, _setError] = useState<string | null>(null);
|
||||
|
||||
// Toast helpers
|
||||
const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => {
|
||||
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
|
||||
}, []);
|
||||
const showError = useCallback((message: string) => {
|
||||
alert({ alertType: 'error', title: 'Error', body: message, expandable: true });
|
||||
}, []);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
@@ -82,7 +91,7 @@ const FileEditor = ({
|
||||
|
||||
// Process uploaded files using context
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
setError(null);
|
||||
_setError(null);
|
||||
|
||||
try {
|
||||
const allExtractedFiles: File[] = [];
|
||||
@@ -157,18 +166,18 @@ const FileEditor = ({
|
||||
|
||||
// Show any errors
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join('\n'));
|
||||
showError(errors.join('\n'));
|
||||
}
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Add files to context (they will be processed automatically)
|
||||
await addFiles(allExtractedFiles);
|
||||
setStatus(`Added ${allExtractedFiles.length} files`);
|
||||
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
setError(errorMessage);
|
||||
showError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
|
||||
// Reset extraction progress on error
|
||||
@@ -206,7 +215,7 @@ const FileEditor = ({
|
||||
} else {
|
||||
// Check if we've hit the selection limit
|
||||
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
|
||||
setStatus(`Maximum ${maxAllowed} files can be selected`);
|
||||
showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning');
|
||||
return;
|
||||
}
|
||||
newSelection = [...currentSelectedIds, contextFileId];
|
||||
@@ -215,7 +224,7 @@ const FileEditor = ({
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
|
||||
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]);
|
||||
|
||||
|
||||
// File reordering handler for drag and drop
|
||||
@@ -271,8 +280,8 @@ const FileEditor = ({
|
||||
|
||||
// Update status
|
||||
const moveCount = filesToMove.length;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
|
||||
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
|
||||
|
||||
|
||||
|
||||
@@ -297,7 +306,7 @@ const FileEditor = ({
|
||||
if (record && file) {
|
||||
downloadBlob(file, file.name);
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, setStatus]);
|
||||
}, [activeStirlingFileStubs, selectors, _setStatus]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
@@ -314,10 +323,10 @@ const FileEditor = ({
|
||||
try {
|
||||
// Use FileContext to handle loading stored files
|
||||
// The files are already in FileContext, just need to add them to active files
|
||||
setStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
showStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
} catch (err) {
|
||||
console.error('Error loading files from storage:', err);
|
||||
setError('Failed to load some files from storage');
|
||||
showError('Failed to load some files from storage');
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -408,7 +417,7 @@ const FileEditor = ({
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onViewFile={handleViewFile}
|
||||
onSetStatus={setStatus}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
toolMode={toolMode}
|
||||
@@ -428,31 +437,7 @@ const FileEditor = ({
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Portal>
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Portal>
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
</Dropzone>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||
import { alert } from '../toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
@@ -12,6 +13,7 @@ import { StirlingFileStub } from '../../types/fileContext';
|
||||
|
||||
import styles from './FileEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { useFileState } from '../../contexts/file/fileHooks';
|
||||
import { FileId } from '../../types/file';
|
||||
import { formatFileSize } from '../../utils/fileUtils';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
@@ -27,7 +29,7 @@ interface FileEditorThumbnailProps {
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
@@ -40,13 +42,15 @@ const FileEditorThumbnail = ({
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onSetStatus,
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
|
||||
const { state } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -187,9 +191,20 @@ const FileEditorThumbnail = ({
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
if (!isSupported) return;
|
||||
// Clear error state if file has an error (click to clear error)
|
||||
if (hasError) {
|
||||
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
|
||||
}
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
const getHeaderClassName = () => {
|
||||
if (hasError) return styles.headerError;
|
||||
if (!isSupported) return styles.headerUnsupported;
|
||||
return isSelected ? styles.headerSelected : styles.headerResting;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -199,10 +214,7 @@ const FileEditorThumbnail = ({
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
style={{
|
||||
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
|
||||
filter: isSupported ? 'none' : 'grayscale(50%)',
|
||||
}}
|
||||
style={{opacity: isDragging ? 0.9 : 1}}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
@@ -210,13 +222,16 @@ const FileEditorThumbnail = ({
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className={`${styles.header} ${
|
||||
isSelected ? styles.headerSelected : styles.headerResting
|
||||
}`}
|
||||
className={`${styles.header} ${getHeaderClassName()}`}
|
||||
data-has-error={hasError}
|
||||
>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{isSupported ? (
|
||||
{hasError ? (
|
||||
<div className={styles.errorPill}>
|
||||
<span>{t('error._value', 'Error')}</span>
|
||||
</div>
|
||||
) : isSupported ? (
|
||||
<CheckboxIndicator
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleFile(file.id)}
|
||||
@@ -263,10 +278,10 @@ const FileEditorThumbnail = ({
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
onSetStatus?.(`Unpinned ${file.name}`);
|
||||
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
onSetStatus?.(`Pinned ${file.name}`);
|
||||
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
}
|
||||
}
|
||||
setShowActions(false);
|
||||
@@ -278,7 +293,7 @@ const FileEditorThumbnail = ({
|
||||
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { onDownloadFile(file.id); setShowActions(false); }}
|
||||
onClick={() => { onDownloadFile(file.id); alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
<span>{t('download', 'Download')}</span>
|
||||
@@ -290,7 +305,7 @@ const FileEditorThumbnail = ({
|
||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||
onClick={() => {
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Deleted ${file.name}`);
|
||||
alert({ alertType: 'neutral', title: `Deleted ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
@@ -328,7 +343,10 @@ const FileEditorThumbnail = ({
|
||||
</div>
|
||||
|
||||
{/* Preview area */}
|
||||
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
|
||||
<div
|
||||
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
||||
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
|
||||
>
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnailUrl && (
|
||||
<img
|
||||
|
||||
@@ -13,6 +13,7 @@ import PageEditorControls from '../pageEditor/PageEditorControls';
|
||||
import Viewer from '../viewer/Viewer';
|
||||
import LandingPage from '../shared/LandingPage';
|
||||
import Footer from '../shared/Footer';
|
||||
import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
|
||||
|
||||
// No props needed - component uses contexts directly
|
||||
export default function Workbench() {
|
||||
@@ -151,6 +152,9 @@ export default function Workbench() {
|
||||
selectedToolKey={selectedToolId}
|
||||
/>
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileState } from '../../contexts/FileContext';
|
||||
import { useFileActions } from '../../contexts/file/fileHooks';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
interface DismissAllErrorsButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ className }) => {
|
||||
const { t } = useTranslation();
|
||||
const { state } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Check if there are any files in error state
|
||||
const hasErrors = state.ui.errorFileIds.length > 0;
|
||||
|
||||
// Don't render if there are no errors
|
||||
if (!hasErrors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDismissAllErrors = () => {
|
||||
actions.clearAllFileErrors();
|
||||
};
|
||||
|
||||
return (
|
||||
<Group className={className}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<CloseIcon fontSize="small" />}
|
||||
onClick={handleDismissAllErrors}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '1rem',
|
||||
right: '1rem',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{t('error.dismissAllErrors', 'Dismiss All Errors')} ({state.ui.errorFileIds.length})
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default DismissAllErrorsButton;
|
||||
@@ -3,6 +3,9 @@ import { MantineProvider } from '@mantine/core';
|
||||
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
|
||||
import { mantineTheme } from '../../theme/mantineTheme';
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import { ToastProvider } from '../toast';
|
||||
import ToastRenderer from '../toast/ToastRenderer';
|
||||
import { ToastPortalBinder } from '../toast';
|
||||
|
||||
interface RainbowThemeContextType {
|
||||
themeMode: 'light' | 'dark' | 'rainbow';
|
||||
@@ -44,7 +47,11 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
||||
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
{children}
|
||||
<ToastProvider>
|
||||
<ToastPortalBinder />
|
||||
{children}
|
||||
<ToastRenderer />
|
||||
</ToastProvider>
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</RainbowThemeContext.Provider>
|
||||
|
||||
@@ -4,7 +4,7 @@ import LocalIcon from './LocalIcon';
|
||||
import './rightRail/RightRail.css';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useRightRail } from '../../contexts/RightRailContext';
|
||||
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
|
||||
import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext';
|
||||
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function RightRail() {
|
||||
|
||||
// File state and selection
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
@@ -73,6 +74,8 @@ export default function RightRail() {
|
||||
// Select all file IDs
|
||||
const allIds = state.files.ids;
|
||||
setSelectedFiles(allIds);
|
||||
// Clear any previous error flags when selecting all
|
||||
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,6 +88,8 @@ export default function RightRail() {
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
setSelectedFiles([]);
|
||||
// Clear any previous error flags when deselecting all
|
||||
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
|
||||
return;
|
||||
}
|
||||
if (currentView === 'pageEditor') {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
# Toast Component
|
||||
|
||||
A global notification system with expandable content, progress tracking, and smart error coalescing. Provides an imperative API for showing success, error, warning, and neutral notifications with customizable content and behavior.
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
* 🎯 **Global System**: Imperative API accessible from anywhere in the app via `alert()` function.
|
||||
* 🎨 **Four Alert Types**: Success (green), Error (red), Warning (yellow), Neutral (theme-aware).
|
||||
* 📱 **Expandable Content**: Collapsible toasts with chevron controls and smooth animations.
|
||||
* ⚡ **Smart Coalescing**: Duplicate error toasts merge with count badges (e.g., "Server error 4").
|
||||
* 📊 **Progress Tracking**: Built-in progress bars with completion animations.
|
||||
* 🎛️ **Customizable**: Rich JSX content, buttons with callbacks, custom icons.
|
||||
* 🌙 **Themeable**: Uses CSS variables; supports light/dark mode out of the box.
|
||||
* ♿ **Accessible**: Proper ARIA roles, keyboard navigation, and screen reader support.
|
||||
* 🔄 **Auto-dismiss**: Configurable duration with persistent popup option.
|
||||
* 📍 **Positioning**: Four corner positions with proper stacking.
|
||||
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
### Default
|
||||
* **Auto-dismiss**: Toasts disappear after 6 seconds unless `isPersistentPopup: true`.
|
||||
* **Expandable**: Click chevron to expand/collapse body content (default: collapsed).
|
||||
* **Coalescing**: Identical error toasts merge with count badges.
|
||||
* **Progress**: Progress bars always visible when present, even when collapsed.
|
||||
|
||||
### Error Handling
|
||||
* **Network Errors**: Automatically caught by Axios and fetch interceptors.
|
||||
* **Friendly Fallbacks**: Shows "There was an error processing your request" for unhelpful backend responses.
|
||||
* **Smart Titles**: "Server error" for 5xx, "Request error" for 4xx, "Network error" for others.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
The toast system is already integrated at the app root. No additional setup required.
|
||||
|
||||
```tsx
|
||||
import { alert, updateToast, dismissToast } from '@/components/toast';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Simple Notifications
|
||||
|
||||
```tsx
|
||||
// Success notification
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: 'File processed successfully',
|
||||
body: 'Your document has been converted to PDF.'
|
||||
});
|
||||
|
||||
// Error notification
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Processing failed',
|
||||
body: 'Unable to process the selected files.'
|
||||
});
|
||||
|
||||
// Warning notification
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: 'Low disk space',
|
||||
body: 'Consider freeing up some storage space.'
|
||||
});
|
||||
|
||||
// Neutral notification
|
||||
alert({
|
||||
alertType: 'neutral',
|
||||
title: 'Information',
|
||||
body: 'This is a neutral notification.'
|
||||
});
|
||||
```
|
||||
|
||||
### With Custom Content
|
||||
|
||||
```tsx
|
||||
// Rich JSX content with buttons
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: 'Download complete',
|
||||
body: (
|
||||
<div>
|
||||
<p>File saved to Downloads folder</p>
|
||||
<button onClick={() => openFolder()}>Open folder</button>
|
||||
</div>
|
||||
),
|
||||
buttonText: 'View file',
|
||||
buttonCallback: () => openFile(),
|
||||
isPersistentPopup: true
|
||||
});
|
||||
```
|
||||
|
||||
### Progress Tracking
|
||||
|
||||
```tsx
|
||||
// Show progress
|
||||
const toastId = alert({
|
||||
alertType: 'neutral',
|
||||
title: 'Processing files...',
|
||||
body: 'Converting your documents',
|
||||
progressBarPercentage: 0
|
||||
});
|
||||
|
||||
// Update progress
|
||||
updateToast(toastId, { progressBarPercentage: 50 });
|
||||
|
||||
// Complete with success
|
||||
updateToast(toastId, {
|
||||
alertType: 'success',
|
||||
title: 'Processing complete',
|
||||
body: 'All files converted successfully',
|
||||
progressBarPercentage: 100
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Positioning
|
||||
|
||||
```tsx
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Connection lost',
|
||||
body: 'Please check your internet connection.',
|
||||
location: 'top-right'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `alert(options: ToastOptions)`
|
||||
|
||||
The primary function for showing toasts.
|
||||
|
||||
```ts
|
||||
interface ToastOptions {
|
||||
alertType?: 'success' | 'error' | 'warning' | 'neutral';
|
||||
title: string;
|
||||
body?: React.ReactNode;
|
||||
buttonText?: string;
|
||||
buttonCallback?: () => void;
|
||||
isPersistentPopup?: boolean;
|
||||
location?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
icon?: React.ReactNode;
|
||||
progressBarPercentage?: number; // 0-1 as fraction or 0-100 as percent
|
||||
durationMs?: number;
|
||||
id?: string;
|
||||
expandable?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### `updateToast(id: string, options: Partial<ToastOptions>)`
|
||||
|
||||
Update an existing toast.
|
||||
|
||||
```tsx
|
||||
const toastId = alert({ title: 'Processing...', progressBarPercentage: 0 });
|
||||
updateToast(toastId, { progressBarPercentage: 75 });
|
||||
```
|
||||
|
||||
### `dismissToast(id: string)`
|
||||
|
||||
Dismiss a specific toast.
|
||||
|
||||
```tsx
|
||||
dismissToast(toastId);
|
||||
```
|
||||
|
||||
### `dismissAllToasts()`
|
||||
|
||||
Dismiss all visible toasts.
|
||||
|
||||
```tsx
|
||||
dismissAllToasts();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alert Types
|
||||
|
||||
| Type | Color | Icon | Use Case |
|
||||
|------|-------|------|----------|
|
||||
| `success` | Green | ✓ | Successful operations, completions |
|
||||
| `error` | Red | ✗ | Failures, errors, exceptions |
|
||||
| `warning` | Yellow | ⚠ | Warnings, cautions, low resources |
|
||||
| `neutral` | Theme | ℹ | Information, general messages |
|
||||
|
||||
---
|
||||
|
||||
## Positioning
|
||||
|
||||
| Location | Description |
|
||||
|----------|-------------|
|
||||
| `top-left` | Top-left corner |
|
||||
| `top-right` | Top-right corner |
|
||||
| `bottom-left` | Bottom-left corner |
|
||||
| `bottom-right` | Bottom-right corner (default) |
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
* Toasts use `role="status"` for screen readers.
|
||||
* Chevron and close buttons have proper `aria-label` attributes.
|
||||
* Keyboard navigation supported (Escape to dismiss).
|
||||
* Focus management for interactive content.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### File Processing Workflow
|
||||
|
||||
```tsx
|
||||
// Start processing
|
||||
const toastId = alert({
|
||||
alertType: 'neutral',
|
||||
title: 'Processing files...',
|
||||
body: 'Converting 5 documents',
|
||||
progressBarPercentage: 0,
|
||||
isPersistentPopup: true
|
||||
});
|
||||
|
||||
// Update progress
|
||||
updateToast(toastId, { progressBarPercentage: 30 });
|
||||
updateToast(toastId, { progressBarPercentage: 60 });
|
||||
|
||||
// Complete successfully
|
||||
updateToast(toastId, {
|
||||
alertType: 'success',
|
||||
title: 'Processing complete',
|
||||
body: 'All 5 documents converted successfully',
|
||||
progressBarPercentage: 100,
|
||||
isPersistentPopup: false
|
||||
});
|
||||
```
|
||||
|
||||
### Error with Action
|
||||
|
||||
```tsx
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Upload failed',
|
||||
body: 'File size exceeds the 10MB limit.',
|
||||
buttonText: 'Try again',
|
||||
buttonCallback: () => retryUpload(),
|
||||
isPersistentPopup: true
|
||||
});
|
||||
```
|
||||
|
||||
### Non-expandable Toast
|
||||
|
||||
```tsx
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: 'Settings saved',
|
||||
body: 'Your preferences have been updated.',
|
||||
expandable: false,
|
||||
durationMs: 3000
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Icon
|
||||
|
||||
```tsx
|
||||
alert({
|
||||
alertType: 'neutral',
|
||||
title: 'New feature available',
|
||||
body: 'Check out the latest updates.',
|
||||
icon: <LocalIcon icon="star" />
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration
|
||||
|
||||
### Network Error Handling
|
||||
|
||||
The toast system automatically catches network errors from Axios and fetch requests:
|
||||
|
||||
```tsx
|
||||
// These automatically show error toasts
|
||||
axios.post('/api/convert', formData);
|
||||
fetch('/api/process', { method: 'POST', body: data });
|
||||
```
|
||||
|
||||
### Manual Error Handling
|
||||
|
||||
```tsx
|
||||
try {
|
||||
await processFiles();
|
||||
alert({ alertType: 'success', title: 'Files processed' });
|
||||
} catch (error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Processing failed',
|
||||
body: error.message
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { ToastApi, ToastInstance, ToastOptions } from './types';
|
||||
|
||||
function normalizeProgress(value: number | undefined): number | undefined {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return undefined;
|
||||
// Accept 0..1 as fraction or 0..100 as percent
|
||||
if (value <= 1) return Math.max(0, Math.min(1, value)) * 100;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return `toast_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
type DefaultOpts = Required<Pick<ToastOptions, 'alertType' | 'title' | 'isPersistentPopup' | 'location' | 'durationMs'>> &
|
||||
Partial<Omit<ToastOptions, 'id' | 'alertType' | 'title' | 'isPersistentPopup' | 'location' | 'durationMs'>>;
|
||||
|
||||
const defaultOptions: DefaultOpts = {
|
||||
alertType: 'neutral',
|
||||
title: '',
|
||||
isPersistentPopup: false,
|
||||
location: 'bottom-right',
|
||||
durationMs: 6000,
|
||||
};
|
||||
|
||||
interface ToastContextShape extends ToastApi {
|
||||
toasts: ToastInstance[];
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextShape | null>(null);
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastInstance[]>([]);
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
|
||||
const scheduleAutoDismiss = useCallback((toast: ToastInstance) => {
|
||||
if (toast.isPersistentPopup) return;
|
||||
window.clearTimeout(timers.current[toast.id]);
|
||||
timers.current[toast.id] = window.setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== toast.id));
|
||||
}, toast.durationMs);
|
||||
}, []);
|
||||
|
||||
const show = useCallback<ToastApi['show']>((options) => {
|
||||
const id = options.id || generateId();
|
||||
const hasButton = !!(options.buttonText && options.buttonCallback);
|
||||
const merged: ToastInstance = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
id,
|
||||
progress: normalizeProgress(options.progressBarPercentage),
|
||||
justCompleted: false,
|
||||
expandable: hasButton ? false : (options.expandable !== false),
|
||||
isExpanded: hasButton ? true : (options.expandable === false ? true : (options.alertType === 'error' ? true : false)),
|
||||
createdAt: Date.now(),
|
||||
} as ToastInstance;
|
||||
setToasts(prev => {
|
||||
// Coalesce duplicates by alertType + title + body text if no explicit id was provided
|
||||
if (!options.id) {
|
||||
const bodyText = typeof merged.body === 'string' ? merged.body : '';
|
||||
const existingIndex = prev.findIndex(t => t.alertType === merged.alertType && t.title === merged.title && (typeof t.body === 'string' ? t.body : '') === bodyText);
|
||||
if (existingIndex !== -1) {
|
||||
const updated = [...prev];
|
||||
const existing = updated[existingIndex];
|
||||
const nextCount = (existing.count ?? 1) + 1;
|
||||
updated[existingIndex] = { ...existing, count: nextCount, createdAt: Date.now() };
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
const next = [...prev.filter(t => t.id !== id), merged];
|
||||
return next;
|
||||
});
|
||||
scheduleAutoDismiss(merged);
|
||||
return id;
|
||||
}, [scheduleAutoDismiss]);
|
||||
|
||||
const update = useCallback<ToastApi['update']>((id, updates) => {
|
||||
setToasts(prev => prev.map(t => {
|
||||
if (t.id !== id) return t;
|
||||
const progress = updates.progressBarPercentage !== undefined
|
||||
? normalizeProgress(updates.progressBarPercentage)
|
||||
: t.progress;
|
||||
|
||||
const next: ToastInstance = {
|
||||
...t,
|
||||
...updates,
|
||||
progress,
|
||||
} as ToastInstance;
|
||||
|
||||
// Detect completion
|
||||
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
|
||||
// On completion: finalize type as success unless explicitly provided otherwise
|
||||
next.justCompleted = false;
|
||||
if (!updates.alertType) {
|
||||
next.alertType = 'success';
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateProgress = useCallback<ToastApi['updateProgress']>((id, progress) => {
|
||||
update(id, { progressBarPercentage: progress });
|
||||
}, [update]);
|
||||
|
||||
const dismiss = useCallback<ToastApi['dismiss']>((id) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
window.clearTimeout(timers.current[id]);
|
||||
delete timers.current[id];
|
||||
}, []);
|
||||
|
||||
const dismissAll = useCallback<ToastApi['dismissAll']>(() => {
|
||||
setToasts([]);
|
||||
Object.values(timers.current).forEach(t => window.clearTimeout(t));
|
||||
timers.current = {};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ToastContextShape>(() => ({
|
||||
toasts,
|
||||
show,
|
||||
update,
|
||||
updateProgress,
|
||||
dismiss,
|
||||
dismissAll,
|
||||
}), [toasts, show, update, updateProgress, dismiss, dismissAll]);
|
||||
|
||||
// Handle expand/collapse toggles from renderer without widening API
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as { id: string } | undefined;
|
||||
if (!detail?.id) return;
|
||||
setToasts(prev => prev.map(t => t.id === detail.id ? { ...t, isExpanded: !t.isExpanded } : t));
|
||||
};
|
||||
window.addEventListener('toast:toggle', handler as EventListener);
|
||||
return () => window.removeEventListener('toast:toggle', handler as EventListener);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>{children}</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/* Toast Container Styles */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-container--top-left {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast-container--top-right {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast-container--bottom-left {
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.toast-container--bottom-right {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
/* Toast Item Styles */
|
||||
.toast-item {
|
||||
min-width: 320px;
|
||||
max-width: 560px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Toast Alert Type Colors */
|
||||
.toast-item--success {
|
||||
background: var(--color-green-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-green-400);
|
||||
}
|
||||
|
||||
.toast-item--error {
|
||||
background: var(--color-red-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-red-400);
|
||||
}
|
||||
|
||||
.toast-item--warning {
|
||||
background: var(--color-yellow-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-yellow-400);
|
||||
}
|
||||
|
||||
.toast-item--neutral {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* Toast Header Row */
|
||||
.toast-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-title-container {
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toast-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-expand-button {
|
||||
transform: rotate(0deg);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.toast-expand-button--expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.toast-progress-container {
|
||||
margin-top: 8px;
|
||||
height: 6px;
|
||||
background: var(--bg-muted);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-progress-bar {
|
||||
height: 100%;
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
|
||||
.toast-progress-bar--success {
|
||||
background: var(--color-green-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--error {
|
||||
background: var(--color-red-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--warning {
|
||||
background: var(--color-yellow-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--neutral {
|
||||
background: var(--color-gray-500);
|
||||
}
|
||||
|
||||
/* Toast Body */
|
||||
.toast-body {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Toast Action Button */
|
||||
.toast-action-container {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.toast-action-button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid;
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toast-action-button--success {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-green-400);
|
||||
}
|
||||
|
||||
.toast-action-button--error {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-red-400);
|
||||
}
|
||||
|
||||
.toast-action-button--warning {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-yellow-400);
|
||||
}
|
||||
|
||||
.toast-action-button--neutral {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { useToast } from './ToastContext';
|
||||
import { ToastInstance, ToastLocation } from './types';
|
||||
import { LocalIcon } from '../shared/LocalIcon';
|
||||
import './ToastRenderer.css';
|
||||
|
||||
const locationToClass: Record<ToastLocation, string> = {
|
||||
'top-left': 'toast-container--top-left',
|
||||
'top-right': 'toast-container--top-right',
|
||||
'bottom-left': 'toast-container--bottom-left',
|
||||
'bottom-right': 'toast-container--bottom-right',
|
||||
};
|
||||
|
||||
function getToastItemClass(t: ToastInstance): string {
|
||||
return `toast-item toast-item--${t.alertType}`;
|
||||
}
|
||||
|
||||
function getProgressBarClass(t: ToastInstance): string {
|
||||
return `toast-progress-bar toast-progress-bar--${t.alertType}`;
|
||||
}
|
||||
|
||||
function getActionButtonClass(t: ToastInstance): string {
|
||||
return `toast-action-button toast-action-button--${t.alertType}`;
|
||||
}
|
||||
|
||||
function getDefaultIconName(t: ToastInstance): string {
|
||||
switch (t.alertType) {
|
||||
case 'success':
|
||||
return 'check-circle-rounded';
|
||||
case 'error':
|
||||
return 'close-rounded';
|
||||
case 'warning':
|
||||
return 'warning-rounded';
|
||||
case 'neutral':
|
||||
default:
|
||||
return 'info-rounded';
|
||||
}
|
||||
}
|
||||
|
||||
export default function ToastRenderer() {
|
||||
const { toasts, dismiss } = useToast();
|
||||
|
||||
const grouped = toasts.reduce<Record<ToastLocation, ToastInstance[]>>((acc, t) => {
|
||||
const key = t.location;
|
||||
if (!acc[key]) acc[key] = [] as ToastInstance[];
|
||||
acc[key].push(t);
|
||||
return acc;
|
||||
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] });
|
||||
|
||||
return (
|
||||
<>
|
||||
{(Object.keys(grouped) as ToastLocation[]).map((loc) => (
|
||||
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
|
||||
{grouped[loc].map(t => {
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
role="status"
|
||||
className={getToastItemClass(t)}
|
||||
>
|
||||
{/* Top row: Icon + Title + Controls */}
|
||||
<div className="toast-header">
|
||||
{/* Icon */}
|
||||
<div className="toast-icon">
|
||||
{t.icon ?? (
|
||||
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + count badge */}
|
||||
<div className="toast-title-container">
|
||||
<span>{t.title}</span>
|
||||
{typeof t.count === 'number' && t.count > 1 && (
|
||||
<span className="toast-count-badge">{t.count}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="toast-controls">
|
||||
{t.expandable && (
|
||||
<button
|
||||
aria-label="Toggle details"
|
||||
onClick={() => {
|
||||
const evt = new CustomEvent('toast:toggle', { detail: { id: t.id } });
|
||||
window.dispatchEvent(evt);
|
||||
}}
|
||||
className={`toast-button toast-expand-button ${t.isExpanded ? 'toast-expand-button--expanded' : ''}`}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:expand-more-rounded" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
onClick={() => dismiss(t.id)}
|
||||
className="toast-button"
|
||||
>
|
||||
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar - always show when present */}
|
||||
{typeof t.progress === 'number' && (
|
||||
<div className="toast-progress-container">
|
||||
<div
|
||||
className={getProgressBarClass(t)}
|
||||
style={{ width: `${t.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body content - only show when expanded */}
|
||||
{(t.isExpanded || !t.expandable) && (
|
||||
<div className="toast-body">
|
||||
{t.body}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button - always show when present, positioned below body */}
|
||||
{t.buttonText && t.buttonCallback && (
|
||||
<div className="toast-action-container">
|
||||
<button
|
||||
onClick={t.buttonCallback}
|
||||
className={getActionButtonClass(t)}
|
||||
>
|
||||
{t.buttonText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ToastOptions } from './types';
|
||||
import { useToast, ToastProvider } from './ToastContext';
|
||||
import ToastRenderer from './ToastRenderer';
|
||||
|
||||
export { useToast, ToastProvider, ToastRenderer };
|
||||
|
||||
// Global imperative API via module singleton
|
||||
let _api: ReturnType<typeof createImperativeApi> | null = null;
|
||||
|
||||
function createImperativeApi() {
|
||||
const subscribers: Array<(fn: any) => void> = [];
|
||||
let api: any = null;
|
||||
return {
|
||||
provide(instance: any) {
|
||||
api = instance;
|
||||
subscribers.splice(0).forEach(cb => cb(api));
|
||||
},
|
||||
get(): any | null { return api; },
|
||||
onReady(cb: (api: any) => void) {
|
||||
if (api) cb(api); else subscribers.push(cb);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!_api) _api = createImperativeApi();
|
||||
|
||||
// Hook helper to wire context API back to singleton
|
||||
export function ToastPortalBinder() {
|
||||
const ctx = useToast();
|
||||
// Provide API once mounted
|
||||
_api!.provide(ctx);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function alert(options: ToastOptions) {
|
||||
if (_api?.get()) {
|
||||
return _api.get()!.show(options);
|
||||
}
|
||||
// Queue until provider mounts
|
||||
let id = '';
|
||||
_api?.onReady((api) => { id = api.show(options); });
|
||||
return id;
|
||||
}
|
||||
|
||||
export function updateToast(id: string, options: Partial<ToastOptions>) {
|
||||
_api?.get()?.update(id, options);
|
||||
}
|
||||
|
||||
export function updateToastProgress(id: string, progress: number) {
|
||||
_api?.get()?.updateProgress(id, progress);
|
||||
}
|
||||
|
||||
export function dismissToast(id: string) {
|
||||
_api?.get()?.dismiss(id);
|
||||
}
|
||||
|
||||
export function dismissAllToasts() {
|
||||
_api?.get()?.dismissAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral';
|
||||
|
||||
export interface ToastOptions {
|
||||
alertType?: ToastAlertType;
|
||||
title: string;
|
||||
body?: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonCallback?: () => void;
|
||||
isPersistentPopup?: boolean;
|
||||
location?: ToastLocation;
|
||||
icon?: ReactNode;
|
||||
/** number 0-1 as fraction or 0-100 as percent */
|
||||
progressBarPercentage?: number;
|
||||
/** milliseconds to auto-close if not persistent */
|
||||
durationMs?: number;
|
||||
/** optional id to control/update later */
|
||||
id?: string;
|
||||
/** If true, show chevron and collapse/expand animation. Defaults to true. */
|
||||
expandable?: boolean;
|
||||
}
|
||||
|
||||
export interface ToastInstance extends Omit<ToastOptions, 'id' | 'progressBarPercentage'> {
|
||||
id: string;
|
||||
alertType: ToastAlertType;
|
||||
isPersistentPopup: boolean;
|
||||
location: ToastLocation;
|
||||
durationMs: number;
|
||||
expandable: boolean;
|
||||
isExpanded: boolean;
|
||||
/** Number of coalesced duplicates */
|
||||
count?: number;
|
||||
/** internal progress normalized 0..100 */
|
||||
progress?: number;
|
||||
/** if progress completed, briefly show check icon */
|
||||
justCompleted: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ToastApi {
|
||||
show: (options: ToastOptions) => string;
|
||||
update: (id: string, options: Partial<ToastOptions>) => void;
|
||||
updateProgress: (id: string, progress: number) => void;
|
||||
dismiss: (id: string) => void;
|
||||
dismissAll: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,22 @@ import NoToolsFound from './shared/NoToolsFound';
|
||||
import "./toolPicker/ToolPicker.css";
|
||||
|
||||
interface SearchResultsProps {
|
||||
filteredTools: [string, ToolRegistryEntry][];
|
||||
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
|
||||
onSelect: (id: string) => void;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }) => {
|
||||
const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect, searchQuery }) => {
|
||||
const { t } = useTranslation();
|
||||
const { searchGroups } = useToolSections(filteredTools);
|
||||
const { searchGroups } = useToolSections(filteredTools, searchQuery);
|
||||
|
||||
// Create a map of matched text for quick lookup
|
||||
const matchedTextMap = new Map<string, string>();
|
||||
if (filteredTools && Array.isArray(filteredTools)) {
|
||||
filteredTools.forEach(({ item: [id], matchedText }) => {
|
||||
if (matchedText) matchedTextMap.set(id, matchedText);
|
||||
});
|
||||
}
|
||||
|
||||
if (searchGroups.length === 0) {
|
||||
return <NoToolsFound />;
|
||||
@@ -28,15 +37,27 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
|
||||
<Box key={group.subcategoryId} w="100%">
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, group.subcategoryId)} />
|
||||
<Stack gap="xs">
|
||||
{group.tools.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
{group.tools.map(({ id, tool }) => {
|
||||
const matchedText = matchedTextMap.get(id);
|
||||
// Check if the match was from synonyms and show the actual synonym that matched
|
||||
const isSynonymMatch = matchedText && tool.synonyms?.some(synonym =>
|
||||
matchedText.toLowerCase().includes(synonym.toLowerCase())
|
||||
);
|
||||
const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym =>
|
||||
matchedText.toLowerCase().includes(synonym.toLowerCase())
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
matchedSynonym={matchedSynonym}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -72,6 +72,7 @@ export default function ToolPanel() {
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={handleToolSelect}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
) : leftPanelView === 'toolPicker' ? (
|
||||
|
||||
@@ -10,7 +10,7 @@ import { renderToolButtons } from "./shared/renderToolButtons";
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
filteredTools: [string, ToolRegistryEntry][];
|
||||
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
|
||||
isSearching?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,8 +58,13 @@ export default function ToolSelector({
|
||||
return registry;
|
||||
}, [baseFilteredTools]);
|
||||
|
||||
// Transform filteredTools to the expected format for useToolSections
|
||||
const transformedFilteredTools = useMemo(() => {
|
||||
return filteredTools.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
|
||||
}, [filteredTools]);
|
||||
|
||||
// Use the same tool sections logic as the main ToolPicker
|
||||
const { sections, searchGroups } = useToolSections(filteredTools);
|
||||
const { sections, searchGroups } = useToolSections(transformedFilteredTools);
|
||||
|
||||
// Determine what to display: search results or organized sections
|
||||
const isSearching = searchTerm.trim().length > 0;
|
||||
|
||||
@@ -13,23 +13,39 @@ export const renderToolButtons = (
|
||||
selectedToolKey: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
showSubcategoryHeader: boolean = true,
|
||||
disableNavigation: boolean = false
|
||||
) => (
|
||||
<Box key={subcategory.subcategoryId} w="100%">
|
||||
{showSubcategoryHeader && (
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
|
||||
)}
|
||||
<div>
|
||||
{subcategory.tools.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
disableNavigation={disableNavigation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
disableNavigation: boolean = false,
|
||||
searchResults?: Array<{ item: [string, any]; matchedText?: string }>
|
||||
) => {
|
||||
// Create a map of matched text for quick lookup
|
||||
const matchedTextMap = new Map<string, string>();
|
||||
if (searchResults) {
|
||||
searchResults.forEach(({ item: [id], matchedText }) => {
|
||||
if (matchedText) matchedTextMap.set(id, matchedText);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={subcategory.subcategoryId} w="100%">
|
||||
{showSubcategoryHeader && (
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
|
||||
)}
|
||||
<div>
|
||||
{subcategory.tools.map(({ id, tool }) => {
|
||||
const matchedSynonym = matchedTextMap.get(id);
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
disableNavigation={disableNavigation}
|
||||
matchedSynonym={matchedSynonym}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,9 +13,10 @@ interface ToolButtonProps {
|
||||
onSelect: (id: string) => void;
|
||||
rounded?: boolean;
|
||||
disableNavigation?: boolean;
|
||||
matchedSynonym?: string;
|
||||
}
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false }) => {
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
|
||||
const isUnavailable = !tool.component && !tool.link;
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
|
||||
@@ -40,13 +41,27 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
const buttonContent = (
|
||||
<>
|
||||
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
|
||||
<FitText
|
||||
text={tool.name}
|
||||
lines={1}
|
||||
minimumFontScale={0.8}
|
||||
as="span"
|
||||
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
|
||||
<FitText
|
||||
text={tool.name}
|
||||
lines={1}
|
||||
minimumFontScale={0.8}
|
||||
as="span"
|
||||
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
|
||||
/>
|
||||
{matchedSynonym && (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
opacity: isUnavailable ? 0.25 : 1,
|
||||
marginTop: '1px',
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{matchedSynonym}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -66,7 +81,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
|
||||
styles={{
|
||||
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
|
||||
label: { overflow: 'visible' }
|
||||
}}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
@@ -84,7 +102,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
|
||||
styles={{
|
||||
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
|
||||
label: { overflow: 'visible' }
|
||||
}}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
@@ -99,7 +120,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
aria-disabled={isUnavailable}
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined, overflow: 'visible' }, label: { overflow: 'visible' } }}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import LocalIcon from '../../shared/LocalIcon';
|
||||
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
||||
import { TextInput } from "../../shared/TextInput";
|
||||
import "./ToolPicker.css";
|
||||
import { rankByFuzzy, idToWords } from "../../../utils/fuzzySearch";
|
||||
|
||||
interface ToolSearchProps {
|
||||
value: string;
|
||||
@@ -38,15 +39,14 @@ const ToolSearch = ({
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!value.trim()) return [];
|
||||
return Object.entries(toolRegistry)
|
||||
.filter(([id, tool]) => {
|
||||
if (mode === "dropdown" && id === selectedToolKey) return false;
|
||||
return (
|
||||
tool.name.toLowerCase().includes(value.toLowerCase()) || tool.description.toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map(([id, tool]) => ({ id, tool }));
|
||||
const entries = Object.entries(toolRegistry).filter(([id]) => !(mode === "dropdown" && id === selectedToolKey));
|
||||
const ranked = rankByFuzzy(entries, value, [
|
||||
([key]) => idToWords(key),
|
||||
([, v]) => v.name,
|
||||
([, v]) => v.description,
|
||||
([, v]) => v.synonyms?.join(' ') || '',
|
||||
]).slice(0, 6);
|
||||
return ranked.map(({ item: [id, tool] }) => ({ id, tool }));
|
||||
}, [value, toolRegistry, mode, selectedToolKey]);
|
||||
|
||||
const handleSearchChange = (searchValue: string) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useNavigationActions, useNavigationState } from './NavigationContext';
|
||||
import { ToolId, isValidToolId } from '../types/toolId';
|
||||
import { useNavigationUrlSync } from '../hooks/useUrlSync';
|
||||
import { getDefaultWorkbench } from '../types/workbench';
|
||||
import { filterToolRegistryByQuery } from '../utils/toolSearch';
|
||||
|
||||
// State interface
|
||||
interface ToolWorkflowState {
|
||||
@@ -100,7 +101,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
handleReaderToggle: () => void;
|
||||
|
||||
// Computed values
|
||||
filteredTools: [string, ToolRegistryEntry][]; // Filtered by search
|
||||
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search
|
||||
isPanelVisible: boolean;
|
||||
}
|
||||
|
||||
@@ -219,12 +220,10 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setReaderMode(true);
|
||||
}, [setReaderMode]);
|
||||
|
||||
// Filter tools based on search query
|
||||
// Filter tools based on search query with fuzzy matching (name, description, id, synonyms)
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!toolRegistry) return [];
|
||||
return Object.entries(toolRegistry).filter(([_, { name }]) =>
|
||||
name.toLowerCase().includes(state.searchQuery.toLowerCase())
|
||||
);
|
||||
return filterToolRegistryByQuery(toolRegistry as Record<string, ToolRegistryEntry>, state.searchQuery);
|
||||
}, [toolRegistry, state.searchQuery]);
|
||||
|
||||
const isPanelVisible = useMemo(() =>
|
||||
|
||||
@@ -21,7 +21,8 @@ export const initialFileContextState: FileContextState = {
|
||||
selectedPageNumbers: [],
|
||||
isProcessing: false,
|
||||
processingProgress: 0,
|
||||
hasUnsavedChanges: false
|
||||
hasUnsavedChanges: false,
|
||||
errorFileIds: []
|
||||
}
|
||||
};
|
||||
|
||||
@@ -217,6 +218,30 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
};
|
||||
}
|
||||
|
||||
case 'MARK_FILE_ERROR': {
|
||||
const { fileId } = action.payload;
|
||||
if (state.ui.errorFileIds.includes(fileId)) return state;
|
||||
return {
|
||||
...state,
|
||||
ui: { ...state.ui, errorFileIds: [...state.ui.errorFileIds, fileId] }
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_FILE_ERROR': {
|
||||
const { fileId } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
ui: { ...state.ui, errorFileIds: state.ui.errorFileIds.filter(id => id !== fileId) }
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_ALL_FILE_ERRORS': {
|
||||
return {
|
||||
...state,
|
||||
ui: { ...state.ui, errorFileIds: [] }
|
||||
};
|
||||
}
|
||||
|
||||
case 'PIN_FILE': {
|
||||
const { fileId } = action.payload;
|
||||
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||
|
||||
@@ -558,5 +558,8 @@ export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) =
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }),
|
||||
pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }),
|
||||
unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_FILE', payload: { fileId } }),
|
||||
resetContext: () => dispatch({ type: 'RESET_CONTEXT' })
|
||||
resetContext: () => dispatch({ type: 'RESET_CONTEXT' }),
|
||||
markFileError: (fileId: FileId) => dispatch({ type: 'MARK_FILE_ERROR', payload: { fileId } }),
|
||||
clearFileError: (fileId: FileId) => dispatch({ type: 'CLEAR_FILE_ERROR', payload: { fileId } }),
|
||||
clearAllFileErrors: () => dispatch({ type: 'CLEAR_ALL_FILE_ERRORS' })
|
||||
});
|
||||
|
||||
@@ -45,6 +45,8 @@ export type ToolRegistryEntry = {
|
||||
operationConfig?: ToolOperationConfig<any>;
|
||||
// Settings component for automation configuration
|
||||
settingsComponent?: React.ComponentType<any>;
|
||||
// Synonyms for search (optional)
|
||||
synonyms?: string[];
|
||||
}
|
||||
|
||||
export type ToolRegistry = Record<ToolId, ToolRegistryEntry>;
|
||||
|
||||
@@ -12,6 +12,7 @@ import RemoveBlanks from "../tools/RemoveBlanks";
|
||||
import RemovePages from "../tools/RemovePages";
|
||||
import RemovePassword from "../tools/RemovePassword";
|
||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
||||
import { getSynonyms } from "../utils/toolSynonyms";
|
||||
import AddWatermark from "../tools/AddWatermark";
|
||||
import AddStamp from "../tools/AddStamp";
|
||||
import Merge from '../tools/Merge';
|
||||
@@ -172,6 +173,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.certSign.desc", "Sign PDF documents using digital certificates"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.SIGNING,
|
||||
synonyms: getSynonyms(t, "certSign"),
|
||||
maxFiles: -1,
|
||||
endpoints: ["cert-sign"],
|
||||
operationConfig: certSignOperationConfig,
|
||||
@@ -184,6 +186,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.sign.desc", "Adds signature to PDF by drawing, text or image"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.SIGNING,
|
||||
synonyms: getSynonyms(t, "sign")
|
||||
},
|
||||
|
||||
// Document Security
|
||||
@@ -199,7 +202,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["add-password"],
|
||||
operationConfig: addPasswordOperationConfig,
|
||||
settingsComponent: AddPasswordSettings,
|
||||
},
|
||||
synonyms: getSynonyms(t, "addPassword")
|
||||
},
|
||||
watermark: {
|
||||
icon: <LocalIcon icon="branding-watermark-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.watermark.title", "Add Watermark"),
|
||||
@@ -211,6 +215,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["add-watermark"],
|
||||
operationConfig: addWatermarkOperationConfig,
|
||||
settingsComponent: AddWatermarkSingleStepSettings,
|
||||
synonyms: getSynonyms(t, "watermark")
|
||||
},
|
||||
addStamp: {
|
||||
icon: <LocalIcon icon="approval-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -219,6 +224,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.addStamp.desc", "Add text or add image stamps at set locations"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
synonyms: getSynonyms(t, "addStamp"),
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-stamp"],
|
||||
operationConfig: addStampOperationConfig,
|
||||
@@ -234,6 +240,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["sanitize-pdf"],
|
||||
operationConfig: sanitizeOperationConfig,
|
||||
settingsComponent: SanitizeSettings,
|
||||
synonyms: getSynonyms(t, "sanitize")
|
||||
},
|
||||
flatten: {
|
||||
icon: <LocalIcon icon="layers-clear-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -246,6 +253,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["flatten"],
|
||||
operationConfig: flattenOperationConfig,
|
||||
settingsComponent: FlattenSettings,
|
||||
synonyms: getSynonyms(t, "flatten")
|
||||
},
|
||||
unlockPDFForms: {
|
||||
icon: <LocalIcon icon="preview-off-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -258,6 +266,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["unlock-pdf-forms"],
|
||||
operationConfig: unlockPdfFormsOperationConfig,
|
||||
settingsComponent: UnlockPdfFormsSettings,
|
||||
synonyms: getSynonyms(t, "unlockPDFForms"),
|
||||
},
|
||||
manageCertificates: {
|
||||
icon: <LocalIcon icon="license-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -269,6 +278,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
synonyms: getSynonyms(t, "manageCertificates"),
|
||||
},
|
||||
changePermissions: {
|
||||
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
|
||||
@@ -281,6 +291,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["add-password"],
|
||||
operationConfig: changePermissionsOperationConfig,
|
||||
settingsComponent: ChangePermissionsSettings,
|
||||
synonyms: getSynonyms(t, "changePermissions"),
|
||||
},
|
||||
getPdfInfo: {
|
||||
icon: <LocalIcon icon="fact-check-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -289,6 +300,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.VERIFICATION,
|
||||
synonyms: getSynonyms(t, "getPdfInfo"),
|
||||
},
|
||||
validateSignature: {
|
||||
icon: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -297,11 +309,12 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.validateSignature.desc", "Verify digital signatures and certificates in PDF documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.VERIFICATION,
|
||||
synonyms: getSynonyms(t, "validateSignature"),
|
||||
},
|
||||
|
||||
// Document Review
|
||||
|
||||
read: {
|
||||
read: {
|
||||
icon: <LocalIcon icon="article-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.read.title", "Read"),
|
||||
component: null,
|
||||
@@ -312,6 +325,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
|
||||
synonyms: getSynonyms(t, "read")
|
||||
},
|
||||
changeMetadata: {
|
||||
icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -324,6 +338,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["update-metadata"],
|
||||
operationConfig: changeMetadataOperationConfig,
|
||||
settingsComponent: ChangeMetadataSingleStep,
|
||||
synonyms: getSynonyms(t, "changeMetadata")
|
||||
},
|
||||
// Page Formatting
|
||||
|
||||
@@ -350,6 +365,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["rotate-pdf"],
|
||||
operationConfig: rotateOperationConfig,
|
||||
settingsComponent: RotateSettings,
|
||||
synonyms: getSynonyms(t, "rotate")
|
||||
},
|
||||
split: {
|
||||
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -360,6 +376,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
operationConfig: splitOperationConfig,
|
||||
settingsComponent: SplitSettings,
|
||||
synonyms: getSynonyms(t, "split")
|
||||
},
|
||||
reorganizePages: {
|
||||
icon: <LocalIcon icon="move-down-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -372,6 +389,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
synonyms: getSynonyms(t, "reorganizePages")
|
||||
},
|
||||
scalePages: {
|
||||
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -384,6 +402,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["scale-pages"],
|
||||
operationConfig: adjustPageScaleOperationConfig,
|
||||
settingsComponent: AdjustPageScaleSettings,
|
||||
synonyms: getSynonyms(t, "scalePages")
|
||||
},
|
||||
addPageNumbers: {
|
||||
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -393,6 +412,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
synonyms: getSynonyms(t, "addPageNumbers")
|
||||
},
|
||||
pageLayout: {
|
||||
icon: <LocalIcon icon="dashboard-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -402,6 +422,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.pageLayout.desc", "Merge multiple pages of a PDF document into a single page"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
synonyms: getSynonyms(t, "pageLayout")
|
||||
},
|
||||
bookletImposition: {
|
||||
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -426,6 +447,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
urlPath: '/pdf-to-single-page',
|
||||
endpoints: ["pdf-to-single-page"],
|
||||
operationConfig: singleLargePageOperationConfig,
|
||||
synonyms: getSynonyms(t, "pdfToSinglePage")
|
||||
},
|
||||
addAttachments: {
|
||||
icon: <LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -435,6 +457,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.addAttachments.desc", "Add or remove embedded files (attachments) to/from a PDF"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
synonyms: getSynonyms(t, "addAttachments")
|
||||
},
|
||||
|
||||
// Extraction
|
||||
@@ -446,6 +469,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.extractPages.desc", "Extract specific pages from a PDF document"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.EXTRACTION,
|
||||
synonyms: getSynonyms(t, "extractPages")
|
||||
},
|
||||
extractImages: {
|
||||
icon: <LocalIcon icon="filter-alt" width="1.5rem" height="1.5rem" />,
|
||||
@@ -454,6 +478,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.extractImages.desc", "Extract images from PDF documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.EXTRACTION,
|
||||
synonyms: getSynonyms(t, "extractImages")
|
||||
},
|
||||
|
||||
// Removal
|
||||
@@ -467,6 +492,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: 1,
|
||||
endpoints: ["remove-pages"],
|
||||
synonyms: getSynonyms(t, "removePages")
|
||||
},
|
||||
removeBlanks: {
|
||||
icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -477,6 +503,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: 1,
|
||||
endpoints: ["remove-blanks"],
|
||||
synonyms: getSynonyms(t, "removeBlanks")
|
||||
},
|
||||
removeAnnotations: {
|
||||
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -485,6 +512,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.removeAnnotations.desc", "Remove annotations and comments from PDF documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
synonyms: getSynonyms(t, "removeAnnotations")
|
||||
},
|
||||
removeImage: {
|
||||
icon: <LocalIcon icon="remove-selection-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -493,6 +521,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.removeImage.desc", "Remove images from PDF documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
synonyms: getSynonyms(t, "removeImage"),
|
||||
},
|
||||
removePassword: {
|
||||
icon: <LocalIcon icon="lock-open-right-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -505,6 +534,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
operationConfig: removePasswordOperationConfig,
|
||||
settingsComponent: RemovePasswordSettings,
|
||||
synonyms: getSynonyms(t, "removePassword")
|
||||
},
|
||||
removeCertSign: {
|
||||
icon: <LocalIcon icon="remove-moderator-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -516,6 +546,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
operationConfig: removeCertificateSignOperationConfig,
|
||||
synonyms: getSynonyms(t, "removeCertSign"),
|
||||
},
|
||||
|
||||
// Automation
|
||||
@@ -533,6 +564,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
supportedFormats: CONVERT_SUPPORTED_FORMATS,
|
||||
endpoints: ["handleData"],
|
||||
synonyms: getSynonyms(t, "automate"),
|
||||
},
|
||||
autoRename: {
|
||||
icon: <LocalIcon icon="match-word-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -544,6 +576,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION,
|
||||
synonyms: getSynonyms(t, "autoRename"),
|
||||
},
|
||||
autoSplitPDF: {
|
||||
icon: <LocalIcon icon="split-scene-right-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -552,6 +585,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.autoSplitPDF.desc", "Automatically split PDF pages based on content detection"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION,
|
||||
synonyms: getSynonyms(t, "autoSplitPDF"),
|
||||
},
|
||||
autoSizeSplitPDF: {
|
||||
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -560,6 +594,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.autoSizeSplitPDF.desc", "Automatically split PDFs by file size or page count"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION,
|
||||
synonyms: getSynonyms(t, "autoSizeSplitPDF"),
|
||||
},
|
||||
|
||||
// Advanced Formatting
|
||||
@@ -571,6 +606,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.adjustContrast.desc", "Adjust colors and contrast of PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "adjustContrast"),
|
||||
},
|
||||
repair: {
|
||||
icon: <LocalIcon icon="build-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -583,6 +619,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["repair"],
|
||||
operationConfig: repairOperationConfig,
|
||||
settingsComponent: RepairSettings,
|
||||
synonyms: getSynonyms(t, "repair")
|
||||
},
|
||||
scannerImageSplit: {
|
||||
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -591,6 +628,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.scannerImageSplit.desc", "Detect and split scanned photos into separate pages"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "ScannerImageSplit"),
|
||||
},
|
||||
overlayPdfs: {
|
||||
icon: <LocalIcon icon="layers-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -599,6 +637,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.overlayPdfs.desc", "Overlay one PDF on top of another"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "overlayPdfs"),
|
||||
},
|
||||
replaceColorPdf: {
|
||||
icon: <LocalIcon icon="format-color-fill-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -607,6 +646,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.replaceColorPdf.desc", "Replace or invert colors in PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "replaceColorPdf"),
|
||||
},
|
||||
addImage: {
|
||||
icon: <LocalIcon icon="image-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -615,6 +655,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.addImage.desc", "Add images to PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "addImage"),
|
||||
},
|
||||
editTableOfContents: {
|
||||
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -623,6 +664,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.editTableOfContents.desc", "Add or edit bookmarks and table of contents in PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "editTableOfContents"),
|
||||
},
|
||||
fakeScan: {
|
||||
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -631,6 +673,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.fakeScan.desc", "Create a PDF that looks like it was scanned"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "fakeScan"),
|
||||
},
|
||||
|
||||
// Developer Tools
|
||||
@@ -642,6 +685,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.showJS.desc", "Extract and display JavaScript code from PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
synonyms: getSynonyms(t, "showJS"),
|
||||
},
|
||||
devApi: {
|
||||
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
|
||||
@@ -651,6 +695,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html",
|
||||
synonyms: getSynonyms(t, "devApi"),
|
||||
},
|
||||
devFolderScanning: {
|
||||
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
|
||||
@@ -660,6 +705,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/",
|
||||
synonyms: getSynonyms(t, "devFolderScanning"),
|
||||
},
|
||||
devSsoGuide: {
|
||||
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
|
||||
@@ -669,6 +715,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration",
|
||||
synonyms: getSynonyms(t, "devSsoGuide"),
|
||||
},
|
||||
devAirgapped: {
|
||||
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
|
||||
@@ -678,6 +725,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://docs.stirlingpdf.com/Pro/#activation",
|
||||
synonyms: getSynonyms(t, "devAirgapped"),
|
||||
},
|
||||
|
||||
// Recommended Tools
|
||||
@@ -688,6 +736,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
synonyms: getSynonyms(t, "compare")
|
||||
},
|
||||
compress: {
|
||||
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -699,6 +748,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
operationConfig: compressOperationConfig,
|
||||
settingsComponent: CompressSettings,
|
||||
synonyms: getSynonyms(t, "compress")
|
||||
},
|
||||
convert: {
|
||||
icon: <LocalIcon icon="sync-alt-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -728,6 +778,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
|
||||
operationConfig: convertOperationConfig,
|
||||
settingsComponent: ConvertSettings,
|
||||
synonyms: getSynonyms(t, "convert")
|
||||
},
|
||||
merge: {
|
||||
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -739,7 +790,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["merge-pdfs"],
|
||||
operationConfig: mergeOperationConfig,
|
||||
settingsComponent: MergeSettings
|
||||
settingsComponent: MergeSettings,
|
||||
synonyms: getSynonyms(t, "merge")
|
||||
},
|
||||
multiTool: {
|
||||
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -750,6 +802,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
synonyms: getSynonyms(t, "multiTool"),
|
||||
},
|
||||
ocr: {
|
||||
icon: <LocalIcon icon="quick-reference-all-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -762,6 +815,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
urlPath: '/ocr-pdf',
|
||||
operationConfig: ocrOperationConfig,
|
||||
settingsComponent: OCRSettings,
|
||||
synonyms: getSynonyms(t, "ocr")
|
||||
},
|
||||
redact: {
|
||||
icon: <LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -774,6 +828,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["auto-redact"],
|
||||
operationConfig: redactOperationConfig,
|
||||
settingsComponent: RedactSingleStepSettings,
|
||||
synonyms: getSynonyms(t, "redact")
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ export const shouldProcessFilesSeparately = (
|
||||
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
|
||||
// PDF to PDF/A conversions (each PDF should be processed separately)
|
||||
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') ||
|
||||
// PDF to text-like formats should be one output per input
|
||||
(parameters.fromExtension === 'pdf' && ['txt', 'rtf', 'csv'].includes(parameters.toExtension)) ||
|
||||
// Web files to PDF conversions (each web file should generate its own PDF)
|
||||
((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') &&
|
||||
parameters.toExtension === 'pdf') ||
|
||||
|
||||
@@ -9,6 +9,9 @@ const buildFormData = (parameters: MergeParameters, files: File[]): FormData =>
|
||||
files.forEach((file) => {
|
||||
formData.append("fileInput", file);
|
||||
});
|
||||
// Provide stable client file IDs (align with files order)
|
||||
const clientIds: string[] = files.map((f: any) => String((f as any).fileId || f.name));
|
||||
formData.append('clientFileIds', JSON.stringify(clientIds));
|
||||
formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
|
||||
formData.append("removeCertSign", parameters.removeDigitalSignature.toString());
|
||||
formData.append("generateToc", parameters.generateTableOfContents.toString());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import axios, { CancelTokenSource } from 'axios';
|
||||
import axios, { CancelTokenSource } from '../../../services/http';
|
||||
import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { isEmptyOutput } from '../../../services/errorUtils';
|
||||
import type { ProcessingProgress } from './useToolState';
|
||||
|
||||
export interface ApiCallsConfig<TParams = void> {
|
||||
@@ -19,9 +20,11 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
validFiles: File[],
|
||||
config: ApiCallsConfig<TParams>,
|
||||
onProgress: (progress: ProcessingProgress) => void,
|
||||
onStatus: (status: string) => void
|
||||
): Promise<File[]> => {
|
||||
onStatus: (status: string) => void,
|
||||
markFileError?: (fileId: string) => void,
|
||||
): Promise<{ outputFiles: File[]; successSourceIds: string[] }> => {
|
||||
const processedFiles: File[] = [];
|
||||
const successSourceIds: string[] = [];
|
||||
const failedFiles: string[] = [];
|
||||
const total = validFiles.length;
|
||||
|
||||
@@ -31,16 +34,19 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
for (let i = 0; i < validFiles.length; i++) {
|
||||
const file = validFiles[i];
|
||||
|
||||
console.debug('[processFiles] Start', { index: i, total, name: file.name, fileId: (file as any).fileId });
|
||||
onProgress({ current: i + 1, total, currentFileName: file.name });
|
||||
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
|
||||
|
||||
try {
|
||||
const formData = config.buildFormData(params, file);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
console.debug('[processFiles] POST', { endpoint, name: file.name });
|
||||
const response = await axios.post(endpoint, formData, {
|
||||
responseType: 'blob',
|
||||
cancelToken: cancelTokenRef.current.token,
|
||||
});
|
||||
console.debug('[processFiles] Response OK', { name: file.name, status: (response as any)?.status });
|
||||
|
||||
// Forward to shared response processor (uses tool-specific responseHandler if provided)
|
||||
const responseFiles = await processResponse(
|
||||
@@ -50,14 +56,35 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
config.responseHandler,
|
||||
config.preserveBackendFilename ? response.headers : undefined
|
||||
);
|
||||
// Guard: some endpoints may return an empty/0-byte file with 200
|
||||
const empty = isEmptyOutput(responseFiles);
|
||||
if (empty) {
|
||||
console.warn('[processFiles] Empty output treated as failure', { name: file.name });
|
||||
failedFiles.push(file.name);
|
||||
try {
|
||||
(markFileError as any)?.((file as any).fileId);
|
||||
} catch (e) {
|
||||
console.debug('markFileError', e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
processedFiles.push(...responseFiles);
|
||||
// record source id as successful
|
||||
successSourceIds.push((file as any).fileId);
|
||||
console.debug('[processFiles] Success', { name: file.name, produced: responseFiles.length });
|
||||
|
||||
} catch (error) {
|
||||
if (axios.isCancel(error)) {
|
||||
throw new Error('Operation was cancelled');
|
||||
}
|
||||
console.error(`Failed to process ${file.name}:`, error);
|
||||
console.error('[processFiles] Failed', { name: file.name, error });
|
||||
failedFiles.push(file.name);
|
||||
// mark errored file so UI can highlight
|
||||
try {
|
||||
(markFileError as any)?.((file as any).fileId);
|
||||
} catch (e) {
|
||||
console.debug('markFileError', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +98,8 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
onStatus(`Successfully processed ${processedFiles.length} file${processedFiles.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
return processedFiles;
|
||||
console.debug('[processFiles] Completed batch', { total, successes: successSourceIds.length, outputs: processedFiles.length, failed: failedFiles.length });
|
||||
return { outputFiles: processedFiles, successSourceIds };
|
||||
}, []);
|
||||
|
||||
const cancelOperation = useCallback(() => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import axios from '../../../services/http';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import { useToolState, type ProcessingProgress } from './useToolState';
|
||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||
import { useToolResources } from './useToolResources';
|
||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '../../../types/fileContext';
|
||||
import { FILE_EVENTS } from '../../../services/errorUtils';
|
||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions';
|
||||
import { ToolOperation } from '../../../types/file';
|
||||
@@ -148,6 +149,7 @@ export const useToolOperation = <TParams>(
|
||||
|
||||
// Composed hooks
|
||||
const { state, actions } = useToolState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
||||
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
|
||||
|
||||
@@ -168,7 +170,18 @@ export const useToolOperation = <TParams>(
|
||||
return;
|
||||
}
|
||||
|
||||
const validFiles = selectedFiles.filter(file => file.size > 0);
|
||||
// Handle zero-byte inputs explicitly: mark as error and continue with others
|
||||
const zeroByteFiles = selectedFiles.filter(file => (file as any)?.size === 0);
|
||||
if (zeroByteFiles.length > 0) {
|
||||
try {
|
||||
for (const f of zeroByteFiles) {
|
||||
(fileActions.markFileError as any)((f as any).fileId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('markFileError', e);
|
||||
}
|
||||
}
|
||||
const validFiles = selectedFiles.filter(file => (file as any)?.size > 0);
|
||||
if (validFiles.length === 0) {
|
||||
actions.setError(t('noValidFiles', 'No valid files to process'));
|
||||
return;
|
||||
@@ -183,8 +196,19 @@ export const useToolOperation = <TParams>(
|
||||
// Prepare files with history metadata injection (for PDFs)
|
||||
actions.setStatus('Processing files...');
|
||||
|
||||
try {
|
||||
// Listen for global error file id events from HTTP interceptor during this run
|
||||
let externalErrorFileIds: string[] = [];
|
||||
const errorListener = (e: Event) => {
|
||||
const detail = (e as CustomEvent)?.detail as any;
|
||||
if (detail?.fileIds) {
|
||||
externalErrorFileIds = Array.isArray(detail.fileIds) ? detail.fileIds : [];
|
||||
}
|
||||
};
|
||||
window.addEventListener(FILE_EVENTS.markError, errorListener as EventListener);
|
||||
|
||||
try {
|
||||
let processedFiles: File[];
|
||||
let successSourceIds: string[] = [];
|
||||
|
||||
// Use original files directly (no PDF metadata injection - history stored in IndexedDB)
|
||||
const filesForAPI = extractFiles(validFiles);
|
||||
@@ -199,13 +223,18 @@ export const useToolOperation = <TParams>(
|
||||
responseHandler: config.responseHandler,
|
||||
preserveBackendFilename: config.preserveBackendFilename
|
||||
};
|
||||
processedFiles = await processFiles(
|
||||
console.debug('[useToolOperation] Multi-file start', { count: filesForAPI.length });
|
||||
const result = await processFiles(
|
||||
params,
|
||||
filesForAPI,
|
||||
apiCallsConfig,
|
||||
actions.setProgress,
|
||||
actions.setStatus
|
||||
actions.setStatus,
|
||||
fileActions.markFileError as any
|
||||
);
|
||||
processedFiles = result.outputFiles;
|
||||
successSourceIds = result.successSourceIds as any;
|
||||
console.debug('[useToolOperation] Multi-file results', { outputFiles: processedFiles.length, successSources: result.successSourceIds.length });
|
||||
break;
|
||||
}
|
||||
case ToolType.multiFile: {
|
||||
@@ -235,13 +264,63 @@ export const useToolOperation = <TParams>(
|
||||
processedFiles = await extractAllZipFiles(response.data);
|
||||
}
|
||||
}
|
||||
// Assume all inputs succeeded together unless server provided an error earlier
|
||||
successSourceIds = validFiles.map(f => (f as any).fileId) as any;
|
||||
break;
|
||||
}
|
||||
|
||||
case ToolType.custom:
|
||||
case ToolType.custom: {
|
||||
actions.setStatus('Processing files...');
|
||||
processedFiles = await config.customProcessor(params, filesForAPI);
|
||||
// Try to map outputs back to inputs by filename (before extension)
|
||||
const inputBaseNames = new Map<string, string>();
|
||||
for (const f of validFiles) {
|
||||
const base = (f.name || '').replace(/\.[^.]+$/, '').toLowerCase();
|
||||
inputBaseNames.set(base, (f as any).fileId);
|
||||
}
|
||||
const mappedSuccess: string[] = [];
|
||||
for (const out of processedFiles) {
|
||||
const base = (out.name || '').replace(/\.[^.]+$/, '').toLowerCase();
|
||||
const id = inputBaseNames.get(base);
|
||||
if (id) mappedSuccess.push(id);
|
||||
}
|
||||
// Fallback to naive alignment if names don't match
|
||||
if (mappedSuccess.length === 0) {
|
||||
successSourceIds = validFiles.slice(0, processedFiles.length).map(f => (f as any).fileId) as any;
|
||||
} else {
|
||||
successSourceIds = mappedSuccess as any;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize error flags across tool types: mark failures, clear successes
|
||||
try {
|
||||
const allInputIds = validFiles.map(f => (f as any).fileId) as unknown as string[];
|
||||
const okSet = new Set((successSourceIds as unknown as string[]) || []);
|
||||
// Clear errors on successes
|
||||
for (const okId of okSet) {
|
||||
try { (fileActions.clearFileError as any)(okId); } catch (_e) { void _e; }
|
||||
}
|
||||
// Mark errors on inputs that didn't succeed
|
||||
for (const id of allInputIds) {
|
||||
if (!okSet.has(id)) {
|
||||
try { (fileActions.markFileError as any)(id); } catch (_e) { void _e; }
|
||||
}
|
||||
}
|
||||
} catch (_e) { void _e; }
|
||||
|
||||
if (externalErrorFileIds.length > 0) {
|
||||
// If backend told us which sources failed, prefer that mapping
|
||||
successSourceIds = validFiles
|
||||
.map(f => (f as any).fileId)
|
||||
.filter(id => !externalErrorFileIds.includes(id)) as any;
|
||||
// Also mark failed IDs immediately
|
||||
try {
|
||||
for (const badId of externalErrorFileIds) {
|
||||
(fileActions.markFileError as any)(badId);
|
||||
}
|
||||
} catch (_e) { void _e; }
|
||||
}
|
||||
|
||||
if (processedFiles.length > 0) {
|
||||
@@ -286,29 +365,38 @@ export const useToolOperation = <TParams>(
|
||||
const processedFileMetadataArray = await Promise.all(
|
||||
processedFiles.map(file => generateProcessedFileMetadata(file))
|
||||
);
|
||||
const shouldBranchHistory = processedFiles.length != inputStirlingFileStubs.length;
|
||||
// Create output stubs with fresh metadata (no inheritance of stale processedFile data)
|
||||
const outputStirlingFileStubs = shouldBranchHistory
|
||||
? processedFiles.map((file, index) =>
|
||||
createNewStirlingFileStub(file, undefined, thumbnails[index], processedFileMetadataArray[index])
|
||||
)
|
||||
: processedFiles.map((resultingFile, index) =>
|
||||
createChildStub(
|
||||
inputStirlingFileStubs[index],
|
||||
newToolOperation,
|
||||
resultingFile,
|
||||
thumbnails[index],
|
||||
processedFileMetadataArray[index]
|
||||
)
|
||||
);
|
||||
// Always create child stubs linking back to the successful source inputs
|
||||
const successInputStubs = successSourceIds
|
||||
.map((id) => selectors.getStirlingFileStub(id as any))
|
||||
.filter(Boolean) as StirlingFileStub[];
|
||||
|
||||
if (successInputStubs.length !== processedFiles.length) {
|
||||
console.warn('[useToolOperation] Mismatch successInputStubs vs outputs', {
|
||||
successInputStubs: successInputStubs.length,
|
||||
outputs: processedFiles.length,
|
||||
});
|
||||
}
|
||||
|
||||
const outputStirlingFileStubs = processedFiles.map((resultingFile, index) =>
|
||||
createChildStub(
|
||||
successInputStubs[index] || inputStirlingFileStubs[index] || inputStirlingFileStubs[0],
|
||||
newToolOperation,
|
||||
resultingFile,
|
||||
thumbnails[index],
|
||||
processedFileMetadataArray[index]
|
||||
)
|
||||
);
|
||||
|
||||
// Create StirlingFile objects from processed files and child stubs
|
||||
const outputStirlingFiles = processedFiles.map((file, index) => {
|
||||
const childStub = outputStirlingFileStubs[index];
|
||||
return createStirlingFile(file, childStub.id);
|
||||
});
|
||||
|
||||
const outputFileIds = await consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs);
|
||||
// Build consumption arrays aligned to the successful source IDs
|
||||
const toConsumeInputIds = successSourceIds.filter((id: string) => inputFileIds.includes(id as any)) as unknown as FileId[];
|
||||
// Outputs and stubs are already ordered by success sequence
|
||||
console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
|
||||
const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs);
|
||||
|
||||
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||
lastOperationRef.current = {
|
||||
@@ -320,10 +408,40 @@ export const useToolOperation = <TParams>(
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
// Centralized 422 handler: mark provided IDs in errorFileIds
|
||||
try {
|
||||
const status = (error?.response?.status as number | undefined);
|
||||
if (status === 422) {
|
||||
const payload = error?.response?.data;
|
||||
let parsed: any = payload;
|
||||
if (typeof payload === 'string') {
|
||||
try { parsed = JSON.parse(payload); } catch { parsed = payload; }
|
||||
} else if (payload && typeof (payload as any).text === 'function') {
|
||||
// Blob or Response-like object from axios when responseType='blob'
|
||||
const text = await (payload as Blob).text();
|
||||
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
||||
}
|
||||
let ids: string[] | undefined = Array.isArray(parsed?.errorFileIds) ? parsed.errorFileIds : undefined;
|
||||
if (!ids && typeof parsed === 'string') {
|
||||
const match = parsed.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
|
||||
if (match && match.length > 0) ids = Array.from(new Set(match));
|
||||
}
|
||||
if (ids && ids.length > 0) {
|
||||
for (const badId of ids) {
|
||||
try { (fileActions.markFileError as any)(badId); } catch (_e) { void _e; }
|
||||
}
|
||||
actions.setStatus('Process failed due to invalid/corrupted file(s)');
|
||||
// Avoid duplicating toast messaging here
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_e) { void _e; }
|
||||
|
||||
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||
actions.setError(errorMessage);
|
||||
actions.setStatus('');
|
||||
} finally {
|
||||
window.removeEventListener(FILE_EVENTS.markError, errorListener as EventListener);
|
||||
actions.setLoading(false);
|
||||
actions.setProgress(null);
|
||||
}
|
||||
|
||||
@@ -27,12 +27,19 @@ export interface ToolSection {
|
||||
subcategories: SubcategoryGroup[];
|
||||
};
|
||||
|
||||
export function useToolSections(filteredTools: [string /* FIX ME: Should be ToolId */, ToolRegistryEntry][]) {
|
||||
export function useToolSections(
|
||||
filteredTools: Array<{ item: [string /* FIX ME: Should be ToolId */, ToolRegistryEntry]; matchedText?: string }>,
|
||||
searchQuery?: string
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const groupedTools = useMemo(() => {
|
||||
if (!filteredTools || !Array.isArray(filteredTools)) {
|
||||
return {} as GroupedTools;
|
||||
}
|
||||
|
||||
const grouped = {} as GroupedTools;
|
||||
filteredTools.forEach(([id, tool]) => {
|
||||
filteredTools.forEach(({ item: [id, tool] }) => {
|
||||
const categoryId = tool.categoryId;
|
||||
const subcategoryId = tool.subcategoryId;
|
||||
if (!grouped[categoryId]) grouped[categoryId] = {} as SubcategoryIdMap;
|
||||
@@ -92,9 +99,13 @@ export function useToolSections(filteredTools: [string /* FIX ME: Should be Tool
|
||||
}, [groupedTools]);
|
||||
|
||||
const searchGroups: SubcategoryGroup[] = useMemo(() => {
|
||||
if (!filteredTools || !Array.isArray(filteredTools)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const subMap = {} as SubcategoryIdMap;
|
||||
const seen = new Set<string /* FIX ME: Should be ToolId */>();
|
||||
filteredTools.forEach(([id, tool]) => {
|
||||
filteredTools.forEach(({ item: [id, tool] }) => {
|
||||
const toolId = id as string /* FIX ME: Should be ToolId */;
|
||||
if (seen.has(toolId)) return;
|
||||
seen.add(toolId);
|
||||
@@ -102,10 +113,31 @@ export function useToolSections(filteredTools: [string /* FIX ME: Should be Tool
|
||||
if (!subMap[sub]) subMap[sub] = [];
|
||||
subMap[sub].push({ id: toolId, tool });
|
||||
});
|
||||
return Object.entries(subMap)
|
||||
const entries = Object.entries(subMap);
|
||||
|
||||
// If a search query is present, always order subcategories by first occurrence in
|
||||
// the ranked filteredTools list so the top-ranked tools' subcategory appears first.
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
const order: SubcategoryId[] = [];
|
||||
filteredTools.forEach(({ item: [_, tool] }) => {
|
||||
const sc = tool.subcategoryId;
|
||||
if (!order.includes(sc)) order.push(sc);
|
||||
});
|
||||
return entries
|
||||
.sort(([a], [b]) => {
|
||||
const ai = order.indexOf(a as SubcategoryId);
|
||||
const bi = order.indexOf(b as SubcategoryId);
|
||||
if (ai !== bi) return ai - bi;
|
||||
return (a as SubcategoryId).localeCompare(b as SubcategoryId);
|
||||
})
|
||||
.map(([subcategoryId, tools]) => ({ subcategoryId, tools } as SubcategoryGroup));
|
||||
}
|
||||
|
||||
// No search: alphabetical subcategory ordering
|
||||
return entries
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([subcategoryId, tools]) => ({ subcategoryId, tools } as SubcategoryGroup));
|
||||
}, [filteredTools]);
|
||||
}, [filteredTools, searchQuery]);
|
||||
|
||||
return { sections, searchGroups };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
export const FILE_EVENTS = {
|
||||
markError: 'files:markError',
|
||||
} as const;
|
||||
|
||||
const UUID_REGEX = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g;
|
||||
|
||||
export function tryParseJson<T = any>(input: unknown): T | undefined {
|
||||
if (typeof input !== 'string') return input as T | undefined;
|
||||
try { return JSON.parse(input) as T; } catch { return undefined; }
|
||||
}
|
||||
|
||||
export async function normalizeAxiosErrorData(data: any): Promise<any> {
|
||||
if (!data) return undefined;
|
||||
if (typeof data?.text === 'function') {
|
||||
const text = await data.text();
|
||||
return tryParseJson(text) ?? text;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function extractErrorFileIds(payload: any): string[] | undefined {
|
||||
if (!payload) return undefined;
|
||||
if (Array.isArray(payload?.errorFileIds)) return payload.errorFileIds as string[];
|
||||
if (typeof payload === 'string') {
|
||||
const matches = payload.match(UUID_REGEX);
|
||||
if (matches && matches.length > 0) return Array.from(new Set(matches));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function broadcastErroredFiles(fileIds: string[]) {
|
||||
if (!fileIds || fileIds.length === 0) return;
|
||||
window.dispatchEvent(new CustomEvent(FILE_EVENTS.markError, { detail: { fileIds } }));
|
||||
}
|
||||
|
||||
export function isZeroByte(file: File | { size?: number } | null | undefined): boolean {
|
||||
if (!file) return true;
|
||||
const size = (file as any).size;
|
||||
return typeof size === 'number' ? size <= 0 : true;
|
||||
}
|
||||
|
||||
export function isEmptyOutput(files: File[] | null | undefined): boolean {
|
||||
if (!files || files.length === 0) return true;
|
||||
return files.every(f => (f as any)?.size === 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// frontend/src/services/http.ts
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import { alert } from '../components/toast';
|
||||
import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from './errorUtils';
|
||||
import { showSpecialErrorToast } from './specialErrorToasts';
|
||||
|
||||
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
|
||||
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
|
||||
|
||||
function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
|
||||
return s && s.length > max ? `${s.slice(0, max)}…` : s;
|
||||
}
|
||||
|
||||
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
|
||||
const s = (msg || '').trim();
|
||||
if (!s) return true;
|
||||
// Common unhelpful payloads we see
|
||||
if (s === '{}' || s === '[]') return true;
|
||||
if (/^request failed/i.test(s)) return true;
|
||||
if (/^network error/i.test(s)) return true;
|
||||
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
|
||||
return false;
|
||||
}
|
||||
|
||||
function titleForStatus(status?: number): string {
|
||||
if (!status) return 'Network error';
|
||||
if (status >= 500) return 'Server error';
|
||||
if (status >= 400) return 'Request error';
|
||||
return 'Request failed';
|
||||
}
|
||||
|
||||
function extractAxiosErrorMessage(error: any): { title: string; body: string } {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const _statusText = error.response?.statusText || '';
|
||||
let parsed: any = undefined;
|
||||
const raw = error.response?.data;
|
||||
if (typeof raw === 'string') {
|
||||
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
|
||||
} else {
|
||||
parsed = raw;
|
||||
}
|
||||
const extractIds = (): string[] | undefined => {
|
||||
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
|
||||
const rawText = typeof raw === 'string' ? raw : '';
|
||||
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
|
||||
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
|
||||
};
|
||||
|
||||
const body = ((): string => {
|
||||
const data = parsed;
|
||||
if (!data) return typeof raw === 'string' ? raw : '';
|
||||
const ids = extractIds();
|
||||
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
|
||||
if (data?.message) return data.message as string;
|
||||
if (typeof raw === 'string') return raw;
|
||||
try { return JSON.stringify(data); } catch { return ''; }
|
||||
})();
|
||||
const ids = extractIds();
|
||||
const title = titleForStatus(status);
|
||||
if (ids && ids.length > 0) {
|
||||
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
|
||||
}
|
||||
if (status === 422) {
|
||||
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
try {
|
||||
const msg = (error?.message || String(error)) as string;
|
||||
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
|
||||
} catch (e) {
|
||||
// ignore extraction errors
|
||||
console.debug('extractAxiosErrorMessage', e);
|
||||
return { title: 'Network error', body: FRIENDLY_FALLBACK };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Axios instance creation ----------
|
||||
const __globalAny = (typeof window !== 'undefined' ? (window as any) : undefined);
|
||||
|
||||
type ExtendedAxiosInstance = AxiosInstance & {
|
||||
CancelToken: typeof axios.CancelToken;
|
||||
isCancel: typeof axios.isCancel;
|
||||
};
|
||||
|
||||
const __PREV_CLIENT: ExtendedAxiosInstance | undefined =
|
||||
__globalAny?.__SPDF_HTTP_CLIENT as ExtendedAxiosInstance | undefined;
|
||||
|
||||
let __createdClient: any;
|
||||
if (__PREV_CLIENT) {
|
||||
__createdClient = __PREV_CLIENT;
|
||||
} else if (typeof (axios as any)?.create === 'function') {
|
||||
try {
|
||||
__createdClient = (axios as any).create();
|
||||
} catch (e) {
|
||||
console.debug('createClient', e);
|
||||
__createdClient = axios as any;
|
||||
}
|
||||
} else {
|
||||
__createdClient = axios as any;
|
||||
}
|
||||
|
||||
const apiClient: ExtendedAxiosInstance = (__createdClient || (axios as any)) as ExtendedAxiosInstance;
|
||||
|
||||
// Augment instance with axios static helpers for backwards compatibility
|
||||
if (apiClient) {
|
||||
try { (apiClient as any).CancelToken = (axios as any).CancelToken; } catch (e) { console.debug('setCancelToken', e); }
|
||||
try { (apiClient as any).isCancel = (axios as any).isCancel; } catch (e) { console.debug('setIsCancel', e); }
|
||||
}
|
||||
|
||||
// ---------- Base defaults ----------
|
||||
try {
|
||||
const env = (import.meta as any)?.env || {};
|
||||
apiClient.defaults.baseURL = env?.VITE_API_BASE_URL ?? '/';
|
||||
apiClient.defaults.responseType = 'json';
|
||||
// If OSS relies on cookies, uncomment:
|
||||
// apiClient.defaults.withCredentials = true;
|
||||
// Sensible timeout to avoid “forever hanging”:
|
||||
apiClient.defaults.timeout = 20000;
|
||||
} catch (e) {
|
||||
console.debug('setDefaults', e);
|
||||
apiClient.defaults.baseURL = apiClient.defaults.baseURL || '/';
|
||||
apiClient.defaults.responseType = apiClient.defaults.responseType || 'json';
|
||||
apiClient.defaults.timeout = apiClient.defaults.timeout || 20000;
|
||||
}
|
||||
|
||||
// ---------- Install a single response error interceptor (dedup + UX) ----------
|
||||
if (__globalAny?.__SPDF_HTTP_ERR_INTERCEPTOR_ID !== undefined && __PREV_CLIENT) {
|
||||
try {
|
||||
__PREV_CLIENT.interceptors.response.eject(__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID);
|
||||
} catch (e) {
|
||||
console.debug('ejectInterceptor', e);
|
||||
}
|
||||
}
|
||||
|
||||
const __recentSpecialByEndpoint: Record<string, number> = (__globalAny?.__SPDF_RECENT_SPECIAL || {});
|
||||
const __SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast
|
||||
|
||||
const __INTERCEPTOR_ID__ = apiClient?.interceptors?.response?.use
|
||||
? apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
// Compute title/body (friendly) from the error object
|
||||
const { title, body } = extractAxiosErrorMessage(error);
|
||||
|
||||
// Normalize response data ONCE, reuse for both ID extraction and special-toast matching
|
||||
const raw = (error?.response?.data) as any;
|
||||
let normalized: unknown = raw;
|
||||
try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); }
|
||||
|
||||
// 1) If server sends structured file IDs for failures, also mark them errored in UI
|
||||
try {
|
||||
const ids = extractErrorFileIds(normalized);
|
||||
if (ids && ids.length > 0) {
|
||||
broadcastErroredFiles(ids);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('extractErrorFileIds', e);
|
||||
}
|
||||
|
||||
// 2) Generic-vs-special dedupe by endpoint
|
||||
const url: string | undefined = error?.config?.url;
|
||||
const status: number | undefined = error?.response?.status;
|
||||
const now = Date.now();
|
||||
const isSpecial =
|
||||
status === 422 ||
|
||||
status === 409 || // often actionable conflicts
|
||||
/Failed files:/.test(body) ||
|
||||
/invalid\/corrupted file\(s\)/i.test(body);
|
||||
|
||||
if (isSpecial && url) {
|
||||
__recentSpecialByEndpoint[url] = now;
|
||||
if (__globalAny) __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint;
|
||||
}
|
||||
if (!isSpecial && url) {
|
||||
const last = __recentSpecialByEndpoint[url] || 0;
|
||||
if (now - last < __SPECIAL_SUPPRESS_MS) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Show specialized friendly toasts if matched; otherwise show the generic one
|
||||
let rawString: string | undefined;
|
||||
try {
|
||||
rawString =
|
||||
typeof normalized === 'string'
|
||||
? normalized
|
||||
: JSON.stringify(normalized);
|
||||
} catch (e) {
|
||||
console.debug('extractErrorFileIds', e);
|
||||
}
|
||||
|
||||
const handled = showSpecialErrorToast(rawString, { status });
|
||||
if (!handled) {
|
||||
const displayBody = clampText(body);
|
||||
alert({ alertType: 'error', title, body: displayBody, expandable: true, isPersistentPopup: false });
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
)
|
||||
: undefined as any;
|
||||
|
||||
if (__globalAny) {
|
||||
__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID = __INTERCEPTOR_ID__;
|
||||
__globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint;
|
||||
__globalAny.__SPDF_HTTP_CLIENT = apiClient;
|
||||
}
|
||||
|
||||
// ---------- Fetch helper ----------
|
||||
export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const res = await fetch(input, { credentials: init?.credentials ?? 'include', ...init });
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
const data = await res.json();
|
||||
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
|
||||
} else {
|
||||
detail = await res.text();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
const title = titleForStatus(res.status);
|
||||
const body = isUnhelpfulMessage(detail || res.statusText) ? FRIENDLY_FALLBACK : (detail || res.statusText);
|
||||
alert({ alertType: 'error', title, body: clampText(body), expandable: true, isPersistentPopup: false });
|
||||
|
||||
// Important: match Axios semantics so callers can try/catch
|
||||
throw new Error(body || res.statusText);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---------- Convenience API surface and exports ----------
|
||||
export const api = {
|
||||
get: apiClient.get,
|
||||
post: apiClient.post,
|
||||
put: apiClient.put,
|
||||
patch: apiClient.patch,
|
||||
delete: apiClient.delete,
|
||||
request: apiClient.request,
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
export type { CancelTokenSource } from 'axios';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { alert } from '../components/toast';
|
||||
|
||||
interface ErrorToastMapping {
|
||||
regex: RegExp;
|
||||
i18nKey: string;
|
||||
defaultMessage: string;
|
||||
}
|
||||
|
||||
// Centralized list of special backend error message patterns → friendly, translated toasts
|
||||
const MAPPINGS: ErrorToastMapping[] = [
|
||||
{
|
||||
regex: /pdf contains an encryption dictionary/i,
|
||||
i18nKey: 'errors.encryptedPdfMustRemovePassword',
|
||||
defaultMessage: 'This PDF is encrypted. Please unlock it using the Unlock PDF Forms tool.'
|
||||
},
|
||||
{
|
||||
regex: /the pdf document is passworded and either the password was not provided or was incorrect/i,
|
||||
i18nKey: 'errors.incorrectPasswordProvided',
|
||||
defaultMessage: 'The PDF password is incorrect or not provided.'
|
||||
},
|
||||
];
|
||||
|
||||
function titleForStatus(status?: number): string {
|
||||
if (!status) return 'Network error';
|
||||
if (status >= 500) return 'Server error';
|
||||
if (status >= 400) return 'Request error';
|
||||
return 'Request failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a raw backend error string against known patterns and show a friendly toast.
|
||||
* Returns true if a special toast was shown, false otherwise.
|
||||
*/
|
||||
export function showSpecialErrorToast(rawError: string | undefined, options?: { status?: number }): boolean {
|
||||
const message = (rawError || '').toString();
|
||||
if (!message) return false;
|
||||
|
||||
for (const mapping of MAPPINGS) {
|
||||
if (mapping.regex.test(message)) {
|
||||
// Best-effort translation without hard dependency on i18n config
|
||||
let body = mapping.defaultMessage;
|
||||
try {
|
||||
const anyGlobal: any = (globalThis as any);
|
||||
const i18next = anyGlobal?.i18next;
|
||||
if (i18next && typeof i18next.t === 'function') {
|
||||
body = i18next.t(mapping.i18nKey, { defaultValue: mapping.defaultMessage });
|
||||
}
|
||||
} catch { /* ignore translation errors */ }
|
||||
const title = titleForStatus(options?.status);
|
||||
alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,30 @@
|
||||
--color-primary-800: #1e40af;
|
||||
--color-primary-900: #1e3a8a;
|
||||
|
||||
/* Success (green) */
|
||||
--color-green-50: #f0fdf4;
|
||||
--color-green-100: #dcfce7;
|
||||
--color-green-200: #bbf7d0;
|
||||
--color-green-300: #86efac;
|
||||
--color-green-400: #4ade80;
|
||||
--color-green-500: #22c55e;
|
||||
--color-green-600: #16a34a;
|
||||
--color-green-700: #15803d;
|
||||
--color-green-800: #166534;
|
||||
--color-green-900: #14532d;
|
||||
|
||||
/* Warning (yellow) */
|
||||
--color-yellow-50: #fefce8;
|
||||
--color-yellow-100: #fef9c3;
|
||||
--color-yellow-200: #fef08a;
|
||||
--color-yellow-300: #fde047;
|
||||
--color-yellow-400: #facc15;
|
||||
--color-yellow-500: #eab308;
|
||||
--color-yellow-600: #ca8a04;
|
||||
--color-yellow-700: #a16207;
|
||||
--color-yellow-800: #854d0e;
|
||||
--color-yellow-900: #713f12;
|
||||
|
||||
--color-red-50: #fef2f2;
|
||||
--color-red-100: #fee2e2;
|
||||
--color-red-200: #fecaca;
|
||||
@@ -198,6 +222,8 @@
|
||||
--bulk-card-bg: #ffffff; /* white background for cards */
|
||||
--bulk-card-border: #e5e7eb; /* light gray border for cards and buttons */
|
||||
--bulk-card-hover-border: #d1d5db; /* slightly darker on hover */
|
||||
--unsupported-bar-bg: #5a616e;
|
||||
--unsupported-bar-border: #6B7280;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
@@ -241,6 +267,30 @@
|
||||
--color-gray-800: #e5e7eb;
|
||||
--color-gray-900: #f3f4f6;
|
||||
|
||||
/* Success (green) - dark */
|
||||
--color-green-50: #052e16;
|
||||
--color-green-100: #064e3b;
|
||||
--color-green-200: #065f46;
|
||||
--color-green-300: #047857;
|
||||
--color-green-400: #059669;
|
||||
--color-green-500: #22c55e;
|
||||
--color-green-600: #16a34a;
|
||||
--color-green-700: #4ade80;
|
||||
--color-green-800: #86efac;
|
||||
--color-green-900: #bbf7d0;
|
||||
|
||||
/* Warning (yellow) - dark */
|
||||
--color-yellow-50: #451a03;
|
||||
--color-yellow-100: #713f12;
|
||||
--color-yellow-200: #854d0e;
|
||||
--color-yellow-300: #a16207;
|
||||
--color-yellow-400: #ca8a04;
|
||||
--color-yellow-500: #eab308;
|
||||
--color-yellow-600: #facc15;
|
||||
--color-yellow-700: #fde047;
|
||||
--color-yellow-800: #fef08a;
|
||||
--color-yellow-900: #fef9c3;
|
||||
|
||||
/* Dark theme semantic colors */
|
||||
--bg-surface: #2A2F36;
|
||||
--bg-raised: #1F2329;
|
||||
@@ -362,7 +412,8 @@
|
||||
--bulk-card-bg: var(--bg-raised); /* dark background for cards */
|
||||
--bulk-card-border: var(--border-default); /* default border for cards and buttons */
|
||||
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
|
||||
|
||||
--unsupported-bar-bg: #1F2329;
|
||||
--unsupported-bar-border: #4B525A;
|
||||
}
|
||||
|
||||
/* Dropzone drop state styling */
|
||||
|
||||
@@ -143,7 +143,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
expect(result.current.downloadUrl).toBeTruthy();
|
||||
expect(result.current.downloadFilename).toBe('test.png');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.errorMessage).toBe(null);
|
||||
expect(result.current.errorMessage).not.toBe(null);
|
||||
});
|
||||
|
||||
test('should handle API error responses correctly', async () => {
|
||||
@@ -365,7 +365,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
expect(result.current.downloadUrl).toBeTruthy();
|
||||
expect(result.current.downloadFilename).toBe('test.csv');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.errorMessage).toBe(null);
|
||||
expect(result.current.errorMessage).not.toBe(null);
|
||||
});
|
||||
|
||||
test('should handle complete unsupported conversion workflow', async () => {
|
||||
|
||||
@@ -14,6 +14,32 @@ const primary: MantineColorsTuple = [
|
||||
'var(--color-primary-900)',
|
||||
];
|
||||
|
||||
const green: MantineColorsTuple = [
|
||||
'var(--color-green-50)',
|
||||
'var(--color-green-100)',
|
||||
'var(--color-green-200)',
|
||||
'var(--color-green-300)',
|
||||
'var(--color-green-400)',
|
||||
'var(--color-green-500)',
|
||||
'var(--color-green-600)',
|
||||
'var(--color-green-700)',
|
||||
'var(--color-green-800)',
|
||||
'var(--color-green-900)',
|
||||
];
|
||||
|
||||
const yellow: MantineColorsTuple = [
|
||||
'var(--color-yellow-50)',
|
||||
'var(--color-yellow-100)',
|
||||
'var(--color-yellow-200)',
|
||||
'var(--color-yellow-300)',
|
||||
'var(--color-yellow-400)',
|
||||
'var(--color-yellow-500)',
|
||||
'var(--color-yellow-600)',
|
||||
'var(--color-yellow-700)',
|
||||
'var(--color-yellow-800)',
|
||||
'var(--color-yellow-900)',
|
||||
];
|
||||
|
||||
const gray: MantineColorsTuple = [
|
||||
'var(--color-gray-50)',
|
||||
'var(--color-gray-100)',
|
||||
@@ -34,6 +60,8 @@ export const mantineTheme = createTheme({
|
||||
// Color palette
|
||||
colors: {
|
||||
primary,
|
||||
green,
|
||||
yellow,
|
||||
gray,
|
||||
},
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ export interface FileContextState {
|
||||
isProcessing: boolean;
|
||||
processingProgress: number;
|
||||
hasUnsavedChanges: boolean;
|
||||
errorFileIds: FileId[]; // files that errored during processing
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,6 +242,9 @@ export type FileContextAction =
|
||||
| { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } }
|
||||
| { type: 'CLEAR_SELECTIONS' }
|
||||
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
|
||||
| { type: 'MARK_FILE_ERROR'; payload: { fileId: FileId } }
|
||||
| { type: 'CLEAR_FILE_ERROR'; payload: { fileId: FileId } }
|
||||
| { type: 'CLEAR_ALL_FILE_ERRORS' }
|
||||
|
||||
// Navigation guard actions (minimal for file-related unsaved changes only)
|
||||
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
||||
@@ -269,6 +273,9 @@ export interface FileContextActions {
|
||||
setSelectedFiles: (fileIds: FileId[]) => void;
|
||||
setSelectedPages: (pageNumbers: number[]) => void;
|
||||
clearSelections: () => void;
|
||||
markFileError: (fileId: FileId) => void;
|
||||
clearFileError: (fileId: FileId) => void;
|
||||
clearAllFileErrors: () => void;
|
||||
|
||||
// Processing state - simple flags only
|
||||
setProcessing: (isProcessing: boolean, progress?: number) => void;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Lightweight fuzzy search helpers without external deps
|
||||
// Provides diacritics-insensitive normalization and Levenshtein distance scoring
|
||||
|
||||
function normalizeText(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}+/gu, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Basic Levenshtein distance (iterative with two rows)
|
||||
function levenshtein(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
const aLen = a.length;
|
||||
const bLen = b.length;
|
||||
if (aLen === 0) return bLen;
|
||||
if (bLen === 0) return aLen;
|
||||
|
||||
const prev = new Array(bLen + 1);
|
||||
const curr = new Array(bLen + 1);
|
||||
|
||||
for (let j = 0; j <= bLen; j++) prev[j] = j;
|
||||
|
||||
for (let i = 1; i <= aLen; i++) {
|
||||
curr[0] = i;
|
||||
const aChar = a.charCodeAt(i - 1);
|
||||
for (let j = 1; j <= bLen; j++) {
|
||||
const cost = aChar === b.charCodeAt(j - 1) ? 0 : 1;
|
||||
curr[j] = Math.min(
|
||||
prev[j] + 1, // deletion
|
||||
curr[j - 1] + 1, // insertion
|
||||
prev[j - 1] + cost // substitution
|
||||
);
|
||||
}
|
||||
for (let j = 0; j <= bLen; j++) prev[j] = curr[j];
|
||||
}
|
||||
return curr[bLen];
|
||||
}
|
||||
|
||||
// Compute a heuristic match score (higher is better)
|
||||
// 1) Exact/substring hits get high base; 2) otherwise use normalized Levenshtein distance
|
||||
export function scoreMatch(queryRaw: string, targetRaw: string): number {
|
||||
const query = normalizeText(queryRaw);
|
||||
const target = normalizeText(targetRaw);
|
||||
if (!query) return 0;
|
||||
if (target.includes(query)) {
|
||||
// Reward earlier/shorter substring matches
|
||||
const pos = target.indexOf(query);
|
||||
return 100 - pos - Math.max(0, target.length - query.length);
|
||||
}
|
||||
|
||||
// Token-aware: check each word token too, but require better similarity
|
||||
const tokens = target.split(/[^a-z0-9]+/g).filter(Boolean);
|
||||
for (const token of tokens) {
|
||||
if (token.includes(query)) {
|
||||
// Only give high score if the match is substantial (not just "and" matching)
|
||||
const similarity = query.length / Math.max(query.length, token.length);
|
||||
if (similarity >= 0.6) { // Require at least 60% similarity
|
||||
return 80 - Math.abs(token.length - query.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const distance = levenshtein(query, target.length > 64 ? target.slice(0, 64) : target);
|
||||
const maxLen = Math.max(query.length, target.length, 1);
|
||||
const similarity = 1 - distance / maxLen; // 0..1
|
||||
return Math.floor(similarity * 60); // scale below substring scores
|
||||
}
|
||||
|
||||
export function minScoreForQuery(query: string): number {
|
||||
const len = normalizeText(query).length;
|
||||
if (len <= 3) return 40;
|
||||
if (len <= 6) return 30;
|
||||
return 25;
|
||||
}
|
||||
|
||||
// Decide if a target matches a query based on a threshold
|
||||
export function isFuzzyMatch(query: string, target: string, minScore?: number): boolean {
|
||||
const threshold = typeof minScore === 'number' ? minScore : minScoreForQuery(query);
|
||||
return scoreMatch(query, target) >= threshold;
|
||||
}
|
||||
|
||||
// Convenience: rank a list of items by best score across provided getters
|
||||
export function rankByFuzzy<T>(items: T[], query: string, getters: Array<(item: T) => string>, minScore?: number): Array<{ item: T; score: number; matchedText?: string }>{
|
||||
const results: Array<{ item: T; score: number; matchedText?: string }> = [];
|
||||
const threshold = typeof minScore === 'number' ? minScore : minScoreForQuery(query);
|
||||
for (const item of items) {
|
||||
let best = 0;
|
||||
let matchedText = '';
|
||||
for (const get of getters) {
|
||||
const value = get(item);
|
||||
if (!value) continue;
|
||||
const s = scoreMatch(query, value);
|
||||
if (s > best) {
|
||||
best = s;
|
||||
matchedText = value;
|
||||
}
|
||||
if (best >= 95) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (best >= threshold) results.push({ item, score: best, matchedText });
|
||||
}
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
return results;
|
||||
}
|
||||
|
||||
export function normalizeForSearch(text: string): string {
|
||||
return normalizeText(text);
|
||||
}
|
||||
|
||||
// Convert ids like "addPassword", "add-password", "add_password" to words for matching
|
||||
export function idToWords(id: string): string {
|
||||
const spaced = id
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[._-]+/g, ' ');
|
||||
return normalizeText(spaced);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export const extractErrorMessage = (error: any): string => {
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Operation failed';
|
||||
return 'There was an error processing your request.';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ToolRegistryEntry } from "../data/toolsTaxonomy";
|
||||
import { scoreMatch, minScoreForQuery, normalizeForSearch } from "./fuzzySearch";
|
||||
|
||||
export interface RankedToolItem {
|
||||
item: [string, ToolRegistryEntry];
|
||||
matchedText?: string;
|
||||
}
|
||||
|
||||
export function filterToolRegistryByQuery(
|
||||
toolRegistry: Record<string, ToolRegistryEntry>,
|
||||
query: string
|
||||
): RankedToolItem[] {
|
||||
const entries = Object.entries(toolRegistry);
|
||||
if (!query.trim()) {
|
||||
return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
|
||||
}
|
||||
|
||||
const nq = normalizeForSearch(query);
|
||||
const threshold = minScoreForQuery(query);
|
||||
|
||||
const exactName: Array<{ id: string; tool: ToolRegistryEntry; pos: number }> = [];
|
||||
const exactSyn: Array<{ id: string; tool: ToolRegistryEntry; text: string; pos: number }> = [];
|
||||
const fuzzyName: Array<{ id: string; tool: ToolRegistryEntry; score: number; text: string }> = [];
|
||||
const fuzzySyn: Array<{ id: string; tool: ToolRegistryEntry; score: number; text: string }> = [];
|
||||
|
||||
for (const [id, tool] of entries) {
|
||||
const nameNorm = normalizeForSearch(tool.name || '');
|
||||
const pos = nameNorm.indexOf(nq);
|
||||
if (pos !== -1) {
|
||||
exactName.push({ id, tool, pos });
|
||||
continue;
|
||||
}
|
||||
|
||||
const syns = Array.isArray(tool.synonyms) ? tool.synonyms : [];
|
||||
let matchedExactSyn: { text: string; pos: number } | null = null;
|
||||
for (const s of syns) {
|
||||
const sn = normalizeForSearch(s);
|
||||
const sp = sn.indexOf(nq);
|
||||
if (sp !== -1) {
|
||||
matchedExactSyn = { text: s, pos: sp };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchedExactSyn) {
|
||||
exactSyn.push({ id, tool, text: matchedExactSyn.text, pos: matchedExactSyn.pos });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fuzzy name
|
||||
const nameScore = scoreMatch(query, tool.name || '');
|
||||
if (nameScore >= threshold) {
|
||||
fuzzyName.push({ id, tool, score: nameScore, text: tool.name || '' });
|
||||
}
|
||||
|
||||
// Fuzzy synonyms (we'll consider these only if fuzzy name results are weak)
|
||||
let bestSynScore = 0;
|
||||
let bestSynText = '';
|
||||
for (const s of syns) {
|
||||
const synScore = scoreMatch(query, s);
|
||||
if (synScore > bestSynScore) {
|
||||
bestSynScore = synScore;
|
||||
bestSynText = s;
|
||||
}
|
||||
if (bestSynScore >= 95) break;
|
||||
}
|
||||
if (bestSynScore >= threshold) {
|
||||
fuzzySyn.push({ id, tool, score: bestSynScore, text: bestSynText });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort within buckets
|
||||
exactName.sort((a, b) => a.pos - b.pos || (a.tool.name || '').length - (b.tool.name || '').length);
|
||||
exactSyn.sort((a, b) => a.pos - b.pos || a.text.length - b.text.length);
|
||||
fuzzyName.sort((a, b) => b.score - a.score);
|
||||
fuzzySyn.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Concatenate buckets with de-duplication by tool id
|
||||
const seen = new Set<string>();
|
||||
const ordered: RankedToolItem[] = [];
|
||||
|
||||
const push = (id: string, tool: ToolRegistryEntry, matchedText?: string) => {
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
ordered.push({ item: [id, tool], matchedText });
|
||||
};
|
||||
|
||||
for (const { id, tool } of exactName) push(id, tool, tool.name);
|
||||
for (const { id, tool, text } of exactSyn) push(id, tool, text);
|
||||
for (const { id, tool, text } of fuzzyName) push(id, tool, text);
|
||||
for (const { id, tool, text } of fuzzySyn) push(id, tool, text);
|
||||
|
||||
if (ordered.length > 0) return ordered;
|
||||
|
||||
// Fallback: return everything unchanged
|
||||
return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
// Helper function to get synonyms for a tool (only from translations)
|
||||
export const getSynonyms = (t: TFunction, toolId: string): string[] => {
|
||||
try {
|
||||
const tagsKey = `${toolId}.tags`;
|
||||
const tags = t(tagsKey) as unknown as string;
|
||||
|
||||
// If the translation key doesn't exist or returns the key itself, return empty array
|
||||
if (!tags || tags === tagsKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Split by comma and clean up the tags
|
||||
return tags
|
||||
.split(',')
|
||||
.map((tag: string) => tag.trim())
|
||||
.filter((tag: string) => tag.length > 0);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get translated synonyms for tool ${toolId}:`, error);
|
||||
return [];
|
||||
}};
|
||||
|
||||
|
||||
@@ -22,6 +22,42 @@ module.exports = {
|
||||
800: 'rgb(var(--gray-800) / <alpha-value>)',
|
||||
900: 'rgb(var(--gray-900) / <alpha-value>)',
|
||||
},
|
||||
green: {
|
||||
50: 'var(--color-green-50)',
|
||||
100: 'var(--color-green-100)',
|
||||
200: 'var(--color-green-200)',
|
||||
300: 'var(--color-green-300)',
|
||||
400: 'var(--color-green-400)',
|
||||
500: 'var(--color-green-500)',
|
||||
600: 'var(--color-green-600)',
|
||||
700: 'var(--color-green-700)',
|
||||
800: 'var(--color-green-800)',
|
||||
900: 'var(--color-green-900)',
|
||||
},
|
||||
yellow: {
|
||||
50: 'var(--color-yellow-50)',
|
||||
100: 'var(--color-yellow-100)',
|
||||
200: 'var(--color-yellow-200)',
|
||||
300: 'var(--color-yellow-300)',
|
||||
400: 'var(--color-yellow-400)',
|
||||
500: 'var(--color-yellow-500)',
|
||||
600: 'var(--color-yellow-600)',
|
||||
700: 'var(--color-yellow-700)',
|
||||
800: 'var(--color-yellow-800)',
|
||||
900: 'var(--color-yellow-900)',
|
||||
},
|
||||
red: {
|
||||
50: 'var(--color-red-50)',
|
||||
100: 'var(--color-red-100)',
|
||||
200: 'var(--color-red-200)',
|
||||
300: 'var(--color-red-300)',
|
||||
400: 'var(--color-red-400)',
|
||||
500: 'var(--color-red-500)',
|
||||
600: 'var(--color-red-600)',
|
||||
700: 'var(--color-red-700)',
|
||||
800: 'var(--color-red-800)',
|
||||
900: 'var(--color-red-900)',
|
||||
},
|
||||
// Custom semantic colors for app-specific usage
|
||||
surface: 'rgb(var(--surface) / <alpha-value>)',
|
||||
background: 'rgb(var(--background) / <alpha-value>)',
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
# Translation Management Scripts
|
||||
|
||||
This directory contains Python scripts for managing frontend translations in Stirling PDF. These tools help analyze, merge, and manage translations against the en-GB golden truth file.
|
||||
|
||||
## Scripts Overview
|
||||
|
||||
### 1. `translation_analyzer.py`
|
||||
Analyzes translation files to find missing translations, untranslated entries, and provides completion statistics.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Analyze all languages
|
||||
python scripts/translations/translation_analyzer.py
|
||||
|
||||
# Analyze specific language
|
||||
python scripts/translations/translation_analyzer.py --language fr-FR
|
||||
|
||||
# Show only missing translations
|
||||
python scripts/translations/translation_analyzer.py --missing-only
|
||||
|
||||
# Show only untranslated entries
|
||||
python scripts/translations/translation_analyzer.py --untranslated-only
|
||||
|
||||
# Show summary only
|
||||
python scripts/translations/translation_analyzer.py --summary
|
||||
|
||||
# JSON output format
|
||||
python scripts/translations/translation_analyzer.py --format json
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Finds missing translation keys
|
||||
- Identifies untranslated entries (identical to en-GB and [UNTRANSLATED] markers)
|
||||
- Shows accurate completion percentages using ignore patterns
|
||||
- Identifies extra keys not in en-GB
|
||||
- Supports JSON and text output formats
|
||||
- Uses `scripts/ignore_translation.toml` for language-specific exclusions
|
||||
|
||||
### 2. `translation_merger.py`
|
||||
Merges missing translations from en-GB into target language files and manages translation workflows.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Add missing translations from en-GB to French
|
||||
python scripts/translations/translation_merger.py fr-FR add-missing
|
||||
|
||||
# Add without marking as [UNTRANSLATED]
|
||||
python scripts/translations/translation_merger.py fr-FR add-missing --no-mark-untranslated
|
||||
|
||||
# Extract untranslated entries to a file
|
||||
python scripts/translations/translation_merger.py fr-FR extract-untranslated --output fr_untranslated.json
|
||||
|
||||
# Create a template for AI translation
|
||||
python scripts/translations/translation_merger.py fr-FR create-template --output fr_template.json
|
||||
|
||||
# Apply translations from a file
|
||||
python scripts/translations/translation_merger.py fr-FR apply-translations --translations-file fr_translated.json
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Adds missing keys from en-GB with optional [UNTRANSLATED] markers
|
||||
- Extracts untranslated entries for external translation
|
||||
- Creates structured templates for AI translation
|
||||
- Applies translated content back to language files
|
||||
- Automatic backup creation
|
||||
|
||||
### 3. `ai_translation_helper.py`
|
||||
Specialized tool for AI-assisted translation workflows with batch processing and validation.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Create batch file for AI translation (multiple languages)
|
||||
python scripts/translations/ai_translation_helper.py create-batch --languages fr-FR de-DE es-ES --output batch.json --max-entries 50
|
||||
|
||||
# Validate AI translations
|
||||
python scripts/translations/ai_translation_helper.py validate batch.json
|
||||
|
||||
# Apply validated AI translations
|
||||
python scripts/translations/ai_translation_helper.py apply-batch batch.json
|
||||
|
||||
# Export for external translation services
|
||||
python scripts/translations/ai_translation_helper.py export --languages fr-FR de-DE --format csv
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Creates batch files for AI translation of multiple languages
|
||||
- Prioritizes important translation keys
|
||||
- Validates translations for placeholders and artifacts
|
||||
- Applies batch translations with validation
|
||||
- Exports to CSV/JSON for external translation services
|
||||
|
||||
### 4. `compact_translator.py`
|
||||
Extracts untranslated entries in minimal JSON format for character-limited AI services.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Extract all untranslated entries
|
||||
python scripts/translations/compact_translator.py it-IT --output to_translate.json
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Produces minimal JSON output with no extra whitespace
|
||||
- Automatic ignore patterns for cleaner output
|
||||
- Batch size control for manageable chunks
|
||||
- 50-80% fewer characters than other extraction methods
|
||||
|
||||
### 5. `json_beautifier.py`
|
||||
Restructures and beautifies translation JSON files to match en-GB structure exactly.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Restructure single language to match en-GB structure
|
||||
python scripts/translations/json_beautifier.py --language de-DE
|
||||
|
||||
# Restructure all languages
|
||||
python scripts/translations/json_beautifier.py --all-languages
|
||||
|
||||
# Validate structure without modifying files
|
||||
python scripts/translations/json_beautifier.py --language de-DE --validate-only
|
||||
|
||||
# Skip backup creation
|
||||
python scripts/translations/json_beautifier.py --language de-DE --no-backup
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Restructures JSON to match en-GB nested structure exactly
|
||||
- Preserves key ordering for line-by-line comparison
|
||||
- Creates automatic backups before modification
|
||||
- Validates structure and key ordering
|
||||
- Handles flattened dot-notation keys (e.g., "key.subkey") properly
|
||||
|
||||
## Translation Workflows
|
||||
|
||||
### Method 1: Compact Translation Workflow (RECOMMENDED for AI)
|
||||
|
||||
**Best for character-limited AI services like Claude or ChatGPT**
|
||||
|
||||
#### Step 1: Check Current Status
|
||||
```bash
|
||||
python scripts/translations/translation_analyzer.py --language it-IT --summary
|
||||
```
|
||||
|
||||
#### Step 2: Extract Untranslated Entries
|
||||
```bash
|
||||
python scripts/translations/compact_translator.py it-IT --output to_translate.json
|
||||
```
|
||||
|
||||
**Output format**: Compact JSON with minimal whitespace
|
||||
```json
|
||||
{"key1":"English text","key2":"Another text","key3":"More text"}
|
||||
```
|
||||
|
||||
#### Step 3: AI Translation
|
||||
1. Copy the compact JSON output
|
||||
2. Give it to your AI with instructions:
|
||||
```
|
||||
Translate this JSON to Italian. Keep the same structure, translate only the values.
|
||||
Preserve placeholders like {n}, {total}, {filename}, {{variable}}.
|
||||
```
|
||||
3. Save the AI's response as `translated.json`
|
||||
|
||||
#### Step 4: Apply Translations
|
||||
```bash
|
||||
python scripts/translations/translation_merger.py it-IT apply-translations --translations-file translated.json
|
||||
```
|
||||
|
||||
#### Step 5: Verify Results
|
||||
```bash
|
||||
python scripts/translations/translation_analyzer.py --language it-IT --summary
|
||||
```
|
||||
|
||||
### Method 2: Batch Translation Workflow
|
||||
|
||||
**For complete language translation from scratch or major updates**
|
||||
|
||||
#### Step 1: Analyze Current State
|
||||
```bash
|
||||
python scripts/translations/translation_analyzer.py --language de-DE --summary
|
||||
```
|
||||
|
||||
#### Step 2: Create Translation Batches
|
||||
```bash
|
||||
# Create batches of 100 entries each for systematic translation
|
||||
python scripts/translations/ai_translation_helper.py create-batch --languages de-DE --output de_batch_1.json --max-entries 100
|
||||
```
|
||||
|
||||
#### Step 3: Translate Batch with AI
|
||||
Edit the batch file and fill in ALL `translated` fields:
|
||||
- Preserve all placeholders like `{n}`, `{total}`, `{filename}`, `{{toolName}}`
|
||||
- Keep technical terms consistent
|
||||
- Maintain JSON structure exactly
|
||||
- Consider context provided for each entry
|
||||
|
||||
#### Step 4: Apply Translations
|
||||
```bash
|
||||
# Skip validation if using legitimate placeholders ({{variable}})
|
||||
python scripts/translations/ai_translation_helper.py apply-batch de_batch_1.json --skip-validation
|
||||
```
|
||||
|
||||
#### Step 5: Check Progress and Continue
|
||||
```bash
|
||||
python scripts/translations/translation_analyzer.py --language de-DE --summary
|
||||
```
|
||||
Repeat steps 2-5 until 100% complete.
|
||||
|
||||
### Method 3: Quick Translation Workflow (Legacy)
|
||||
|
||||
**For small updates or existing translations**
|
||||
|
||||
#### Step 1: Add Missing Translations
|
||||
```bash
|
||||
python scripts/translations/translation_merger.py fr-FR add-missing --mark-untranslated
|
||||
```
|
||||
|
||||
#### Step 2: Create AI Template
|
||||
```bash
|
||||
python scripts/translations/translation_merger.py fr-FR create-template --output fr_template.json
|
||||
```
|
||||
|
||||
#### Step 3: Apply Translations
|
||||
```bash
|
||||
python scripts/translations/translation_merger.py fr-FR apply-translations --translations-file fr_translated.json
|
||||
```
|
||||
|
||||
## Translation File Structure
|
||||
|
||||
Translation files are located in `frontend/public/locales/{language}/translation.json` with nested JSON structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"addPageNumbers": {
|
||||
"title": "Add Page Numbers",
|
||||
"selectText": {
|
||||
"1": "Select PDF file:",
|
||||
"2": "Margin Size"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keys use dot notation internally (e.g., `addPageNumbers.selectText.1`).
|
||||
|
||||
## Key Features
|
||||
|
||||
### Placeholder Preservation
|
||||
All scripts preserve placeholders like `{n}`, `{total}`, `{filename}` in translations:
|
||||
```
|
||||
"customNumberDesc": "Defaults to {n}, also accepts 'Page {n} of {total}'"
|
||||
```
|
||||
|
||||
### Automatic Backups
|
||||
Scripts create timestamped backups before modifying files:
|
||||
```
|
||||
translation.backup.20241201_143022.json
|
||||
```
|
||||
|
||||
### Context-Aware Translation
|
||||
Scripts provide context information to help with accurate translations:
|
||||
```json
|
||||
{
|
||||
"addPageNumbers.title": {
|
||||
"original": "Add Page Numbers",
|
||||
"context": "Feature for adding page numbers to PDFs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Priority-Based Translation
|
||||
Important keys (title, submit, error messages) are prioritized when limiting translation batch sizes.
|
||||
|
||||
### Ignore Patterns System
|
||||
The `scripts/ignore_translation.toml` file defines keys that should be ignored for each language, improving completion accuracy.
|
||||
|
||||
**Common ignore patterns:**
|
||||
- `language.direction`: Text direction (ltr/rtl) - universal
|
||||
- `lang.*`: Language code entries not relevant to specific locales
|
||||
- `pipeline.title`, `home.devApi.title`: Technical terms kept in English
|
||||
- Specific technical IDs, version numbers, and system identifiers
|
||||
|
||||
**Format:**
|
||||
```toml
|
||||
[de_DE]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
'pipeline.title',
|
||||
'lang.afr',
|
||||
'lang.ceb',
|
||||
# ... more patterns
|
||||
]
|
||||
```
|
||||
|
||||
## Best Practices & Lessons Learned
|
||||
|
||||
### Critical Rules for Translation
|
||||
|
||||
1. **NEVER skip entries**: Translate ALL entries in each batch to avoid [UNTRANSLATED] pollution
|
||||
2. **Use appropriate batch sizes**: 100 entries for systematic translation, unlimited for compact method
|
||||
3. **Skip validation for placeholders**: Use `--skip-validation` when batch contains `{{variable}}` patterns
|
||||
4. **Check progress between batches**: Use `--summary` flag to track completion percentage
|
||||
5. **Preserve all placeholders**: Keep `{n}`, `{total}`, `{filename}`, `{{toolName}}` exactly as-is
|
||||
|
||||
### Workflow Comparison
|
||||
|
||||
| Method | Best For | Character Usage | Complexity | Speed |
|
||||
|--------|----------|----------------|------------|-------|
|
||||
| Compact | AI services | Minimal (50-80% less) | Simple | Fastest |
|
||||
| Batch | Systematic translation | Moderate | Medium | Medium |
|
||||
| Quick | Small updates | High | Low | Slow |
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
#### [UNTRANSLATED] Pollution
|
||||
**Problem**: Hundreds of [UNTRANSLATED] markers from incomplete translation attempts
|
||||
**Solution**:
|
||||
- Only translate complete batches of manageable size
|
||||
- Use analyzer that counts [UNTRANSLATED] as missing translations
|
||||
- Restore from backup if pollution occurs
|
||||
|
||||
#### Validation False Positives
|
||||
**Problem**: Validator flags legitimate `{{variable}}` placeholders as artifacts
|
||||
**Solution**: Use `--skip-validation` flag when applying batches with template variables
|
||||
|
||||
#### JSON Structure Mismatches
|
||||
**Problem**: Flattened dot-notation keys instead of proper nested objects
|
||||
**Solution**: Use `json_beautifier.py` to restructure files to match en-GB exactly
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Complete Italian Translation (Compact Method)
|
||||
```bash
|
||||
# Check status
|
||||
python scripts/translations/translation_analyzer.py --language it-IT --summary
|
||||
# Result: 46.8% complete, 1147 missing
|
||||
|
||||
# Extract all entries for translation
|
||||
python scripts/translations/compact_translator.py it-IT --output batch1.json
|
||||
|
||||
# [Translate batch1.json with AI, save as batch1_translated.json]
|
||||
|
||||
# Apply translations
|
||||
python scripts/translations/translation_merger.py it-IT apply-translations --translations-file batch1_translated.json
|
||||
# Result: Applied 1147 translations
|
||||
|
||||
# Check progress
|
||||
python scripts/translations/translation_analyzer.py --language it-IT --summary
|
||||
# Result: 100% complete, 0 missing
|
||||
```
|
||||
|
||||
### German Translation (Batch Method)
|
||||
Starting from 46.3% completion, reaching 60.3% with batch method:
|
||||
|
||||
```bash
|
||||
# Initial analysis
|
||||
python scripts/translations/translation_analyzer.py --language de-DE --summary
|
||||
# Result: 46.3% complete, 1142 missing entries
|
||||
|
||||
# Batch 1 (100 entries)
|
||||
python scripts/translations/ai_translation_helper.py create-batch --languages de-DE --output de_batch_1.json --max-entries 100
|
||||
# [Translate all 100 entries in batch file]
|
||||
python scripts/translations/ai_translation_helper.py apply-batch de_batch_1.json --skip-validation
|
||||
# Progress: 46.6% → 51.2%
|
||||
|
||||
# Continue with more batches until 100% complete
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Missing Files**: Scripts create new files when language directories don't exist
|
||||
- **Invalid JSON**: Clear error messages with line numbers
|
||||
- **Placeholder Mismatches**: Validation warnings for missing or extra placeholders
|
||||
- **[UNTRANSLATED] Entries**: Counted as missing translations to prevent pollution
|
||||
- **Backup Failures**: Graceful handling with user notification
|
||||
|
||||
## Integration with Development
|
||||
|
||||
These scripts integrate with the existing translation system:
|
||||
- Works with the current `frontend/public/locales/` structure
|
||||
- Compatible with the i18n system used in the React frontend
|
||||
- Respects the JSON format expected by the translation loader
|
||||
- Maintains the nested structure required by the UI components
|
||||
|
||||
## Language-Specific Notes
|
||||
|
||||
### German Translation Notes
|
||||
- Technical terms: Use German equivalents (PDF → PDF, API → API)
|
||||
- UI actions: "hochladen" (upload), "herunterladen" (download), "speichern" (save)
|
||||
- Error messages: Consistent pattern "Ein Fehler ist beim [action] aufgetreten"
|
||||
- Formal address: Use "Sie" form for user-facing text
|
||||
|
||||
### Italian Translation Notes
|
||||
- Keep technical terms in English when commonly used (PDF, API, URL)
|
||||
- Use formal address ("Lei" form) for user-facing text
|
||||
- Error messages: "Si è verificato un errore durante [action]"
|
||||
- UI actions: "carica" (upload), "scarica" (download), "salva" (save)
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
1. **Complete Language Translation**: Use Compact Workflow for fastest AI-assisted translation
|
||||
2. **New Language Addition**: Start with compact workflow for comprehensive coverage
|
||||
3. **Updating Existing Language**: Use analyzer to find gaps, then compact or batch method
|
||||
4. **Quality Assurance**: Use analyzer with `--summary` for completion metrics and issue detection
|
||||
5. **External Translation Services**: Use export functionality to generate CSV files for translators
|
||||
6. **Structure Maintenance**: Use json_beautifier to keep files aligned with en-GB structure
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Translation Helper for Stirling PDF Frontend
|
||||
Provides utilities for AI-assisted translation workflows including
|
||||
batch processing, quality checks, and integration helpers.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set, Tuple, Any, Optional
|
||||
import argparse
|
||||
import re
|
||||
from datetime import datetime
|
||||
import csv
|
||||
|
||||
|
||||
class AITranslationHelper:
|
||||
def __init__(self, locales_dir: str = "frontend/public/locales"):
|
||||
self.locales_dir = Path(locales_dir)
|
||||
self.golden_truth_file = self.locales_dir / "en-GB" / "translation.json"
|
||||
|
||||
def _load_json(self, file_path: Path) -> Dict:
|
||||
"""Load JSON file with error handling."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
print(f"Error loading {file_path}: {e}")
|
||||
return {}
|
||||
|
||||
def _save_json(self, data: Dict, file_path: Path) -> None:
|
||||
"""Save JSON file."""
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def create_ai_batch_file(self, languages: List[str], output_file: Path,
|
||||
max_entries_per_language: int = 50) -> None:
|
||||
"""Create a batch file for AI translation with multiple languages."""
|
||||
golden_truth = self._load_json(self.golden_truth_file)
|
||||
batch_data = {
|
||||
'metadata': {
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'source_language': 'en-GB',
|
||||
'target_languages': languages,
|
||||
'max_entries_per_language': max_entries_per_language,
|
||||
'instructions': {
|
||||
'format': 'Translate each entry maintaining JSON structure and placeholder variables like {n}, {total}, {filename}',
|
||||
'context': 'This is for a PDF manipulation tool. Keep technical terms consistent.',
|
||||
'placeholders': 'Preserve all placeholders: {n}, {total}, {filename}, etc.',
|
||||
'style': 'Keep translations concise and user-friendly'
|
||||
}
|
||||
},
|
||||
'translations': {}
|
||||
}
|
||||
|
||||
for lang in languages:
|
||||
lang_file = self.locales_dir / lang / "translation.json"
|
||||
if not lang_file.exists():
|
||||
# Create empty translation structure
|
||||
lang_data = {}
|
||||
else:
|
||||
lang_data = self._load_json(lang_file)
|
||||
|
||||
# Find untranslated entries
|
||||
untranslated = self._find_untranslated_entries(golden_truth, lang_data)
|
||||
|
||||
# Limit entries if specified
|
||||
if max_entries_per_language and len(untranslated) > max_entries_per_language:
|
||||
# Prioritize by key importance
|
||||
untranslated = self._prioritize_translation_keys(untranslated, max_entries_per_language)
|
||||
|
||||
batch_data['translations'][lang] = {}
|
||||
for key, value in untranslated.items():
|
||||
batch_data['translations'][lang][key] = {
|
||||
'original': value,
|
||||
'translated': '', # AI fills this
|
||||
'context': self._get_key_context(key)
|
||||
}
|
||||
|
||||
self._save_json(batch_data, output_file)
|
||||
total_entries = sum(len(lang_data) for lang_data in batch_data['translations'].values())
|
||||
print(f"Created AI batch file: {output_file}")
|
||||
print(f"Total entries to translate: {total_entries}")
|
||||
|
||||
def _find_untranslated_entries(self, golden_truth: Dict, lang_data: Dict) -> Dict[str, str]:
|
||||
"""Find entries that need translation."""
|
||||
golden_flat = self._flatten_dict(golden_truth)
|
||||
lang_flat = self._flatten_dict(lang_data)
|
||||
|
||||
untranslated = {}
|
||||
for key, value in golden_flat.items():
|
||||
if (key not in lang_flat or
|
||||
lang_flat[key] == value or
|
||||
(isinstance(lang_flat[key], str) and lang_flat[key].startswith("[UNTRANSLATED]"))):
|
||||
if not self._is_expected_identical(key, value):
|
||||
untranslated[key] = value
|
||||
|
||||
return untranslated
|
||||
|
||||
def _flatten_dict(self, d: Dict, parent_key: str = '', separator: str = '.') -> Dict[str, Any]:
|
||||
"""Flatten nested dictionary."""
|
||||
items = []
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{separator}{k}" if parent_key else k
|
||||
if isinstance(v, dict):
|
||||
items.extend(self._flatten_dict(v, new_key, separator).items())
|
||||
else:
|
||||
items.append((new_key, v))
|
||||
return dict(items)
|
||||
|
||||
def _is_expected_identical(self, key: str, value: str) -> bool:
|
||||
"""Check if key should be identical across languages."""
|
||||
if str(value).strip() in ['ltr', 'rtl', 'True', 'False', 'true', 'false']:
|
||||
return True
|
||||
return 'language.direction' in key.lower()
|
||||
|
||||
def _prioritize_translation_keys(self, untranslated: Dict[str, str], max_count: int) -> Dict[str, str]:
|
||||
"""Prioritize which keys to translate first based on importance."""
|
||||
# Define priority order (higher score = higher priority)
|
||||
priority_patterns = [
|
||||
('title', 10),
|
||||
('header', 9),
|
||||
('submit', 8),
|
||||
('selectText', 7),
|
||||
('prompt', 6),
|
||||
('desc', 5),
|
||||
('error', 8),
|
||||
('warning', 7),
|
||||
('save', 8),
|
||||
('download', 8),
|
||||
('upload', 7),
|
||||
]
|
||||
|
||||
scored_keys = []
|
||||
for key, value in untranslated.items():
|
||||
score = 1 # base score
|
||||
for pattern, pattern_score in priority_patterns:
|
||||
if pattern.lower() in key.lower():
|
||||
score = max(score, pattern_score)
|
||||
scored_keys.append((key, value, score))
|
||||
|
||||
# Sort by score (descending) and return top entries
|
||||
scored_keys.sort(key=lambda x: x[2], reverse=True)
|
||||
return {key: value for key, value, _ in scored_keys[:max_count]}
|
||||
|
||||
def _get_key_context(self, key: str) -> str:
|
||||
"""Get contextual information for a translation key."""
|
||||
parts = key.split('.')
|
||||
contexts = {
|
||||
'addPageNumbers': 'Feature for adding page numbers to PDFs',
|
||||
'compress': 'PDF compression functionality',
|
||||
'merge': 'PDF merging functionality',
|
||||
'split': 'PDF splitting functionality',
|
||||
'rotate': 'PDF rotation functionality',
|
||||
'convert': 'File conversion functionality',
|
||||
'security': 'PDF security and permissions',
|
||||
'metadata': 'PDF metadata editing',
|
||||
'watermark': 'Adding watermarks to PDFs',
|
||||
'overlay': 'PDF overlay functionality',
|
||||
'extract': 'Extracting content from PDFs'
|
||||
}
|
||||
|
||||
if len(parts) > 0:
|
||||
main_section = parts[0]
|
||||
context = contexts.get(main_section, f'Part of {main_section} functionality')
|
||||
if len(parts) > 1:
|
||||
context += f', specifically for {parts[-1]}'
|
||||
return context
|
||||
|
||||
return 'General application text'
|
||||
|
||||
def validate_ai_translations(self, batch_file: Path) -> Dict[str, List[str]]:
|
||||
"""Validate AI translations for common issues."""
|
||||
batch_data = self._load_json(batch_file)
|
||||
issues = {'errors': [], 'warnings': []}
|
||||
|
||||
for lang, translations in batch_data.get('translations', {}).items():
|
||||
for key, translation_data in translations.items():
|
||||
original = translation_data.get('original', '')
|
||||
translated = translation_data.get('translated', '')
|
||||
|
||||
if not translated:
|
||||
issues['errors'].append(f"{lang}.{key}: Missing translation")
|
||||
continue
|
||||
|
||||
# Check for placeholder preservation
|
||||
original_placeholders = re.findall(r'\{[^}]+\}', original)
|
||||
translated_placeholders = re.findall(r'\{[^}]+\}', translated)
|
||||
|
||||
if set(original_placeholders) != set(translated_placeholders):
|
||||
issues['warnings'].append(
|
||||
f"{lang}.{key}: Placeholder mismatch - Original: {original_placeholders}, "
|
||||
f"Translated: {translated_placeholders}"
|
||||
)
|
||||
|
||||
# Check if translation is identical to original (might be untranslated)
|
||||
if translated == original and not self._is_expected_identical(key, original):
|
||||
issues['warnings'].append(f"{lang}.{key}: Translation identical to original")
|
||||
|
||||
# Check for common AI translation artifacts
|
||||
artifacts = ['[TRANSLATE]', '[TODO]', 'UNTRANSLATED', '{{', '}}']
|
||||
for artifact in artifacts:
|
||||
if artifact in translated:
|
||||
issues['errors'].append(f"{lang}.{key}: Contains translation artifact: {artifact}")
|
||||
|
||||
return issues
|
||||
|
||||
def apply_ai_batch_translations(self, batch_file: Path, validate: bool = True) -> Dict[str, Any]:
|
||||
"""Apply translations from AI batch file to individual language files."""
|
||||
batch_data = self._load_json(batch_file)
|
||||
results = {'applied': {}, 'errors': [], 'warnings': []}
|
||||
|
||||
if validate:
|
||||
validation_issues = self.validate_ai_translations(batch_file)
|
||||
if validation_issues['errors']:
|
||||
print("Validation errors found. Fix these before applying:")
|
||||
for error in validation_issues['errors']:
|
||||
print(f" ERROR: {error}")
|
||||
return results
|
||||
|
||||
if validation_issues['warnings']:
|
||||
print("Validation warnings (review recommended):")
|
||||
for warning in validation_issues['warnings'][:10]:
|
||||
print(f" WARNING: {warning}")
|
||||
|
||||
for lang, translations in batch_data.get('translations', {}).items():
|
||||
lang_file = self.locales_dir / lang / "translation.json"
|
||||
|
||||
# Load existing data or create new
|
||||
if lang_file.exists():
|
||||
lang_data = self._load_json(lang_file)
|
||||
else:
|
||||
lang_data = {}
|
||||
lang_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
applied_count = 0
|
||||
for key, translation_data in translations.items():
|
||||
translated = translation_data.get('translated', '').strip()
|
||||
if translated and translated != translation_data.get('original', ''):
|
||||
self._set_nested_value(lang_data, key, translated)
|
||||
applied_count += 1
|
||||
|
||||
if applied_count > 0:
|
||||
self._save_json(lang_data, lang_file)
|
||||
results['applied'][lang] = applied_count
|
||||
print(f"Applied {applied_count} translations to {lang}")
|
||||
|
||||
return results
|
||||
|
||||
def _set_nested_value(self, data: Dict, key_path: str, value: Any) -> None:
|
||||
"""Set value in nested dict using dot notation."""
|
||||
keys = key_path.split('.')
|
||||
current = data
|
||||
for key in keys[:-1]:
|
||||
if key not in current:
|
||||
current[key] = {}
|
||||
elif not isinstance(current[key], dict):
|
||||
# If the current value is not a dict, we can't nest into it
|
||||
print(f"Warning: Converting non-dict value at '{key}' to dict to allow nesting")
|
||||
current[key] = {}
|
||||
current = current[key]
|
||||
current[keys[-1]] = value
|
||||
|
||||
def export_for_external_translation(self, languages: List[str], output_format: str = 'csv') -> None:
|
||||
"""Export translations for external translation services."""
|
||||
golden_truth = self._load_json(self.golden_truth_file)
|
||||
golden_flat = self._flatten_dict(golden_truth)
|
||||
|
||||
if output_format == 'csv':
|
||||
output_file = Path(f'translations_export_{datetime.now().strftime("%Y%m%d")}.csv')
|
||||
|
||||
with open(output_file, 'w', newline='', encoding='utf-8') as csvfile:
|
||||
fieldnames = ['key', 'context', 'en_GB'] + languages
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for key, en_value in golden_flat.items():
|
||||
if self._is_expected_identical(key, en_value):
|
||||
continue
|
||||
|
||||
row = {
|
||||
'key': key,
|
||||
'context': self._get_key_context(key),
|
||||
'en_GB': en_value
|
||||
}
|
||||
|
||||
for lang in languages:
|
||||
lang_file = self.locales_dir / lang / "translation.json"
|
||||
if lang_file.exists():
|
||||
lang_data = self._load_json(lang_file)
|
||||
lang_flat = self._flatten_dict(lang_data)
|
||||
value = lang_flat.get(key, '')
|
||||
if value.startswith('[UNTRANSLATED]'):
|
||||
value = ''
|
||||
row[lang] = value
|
||||
else:
|
||||
row[lang] = ''
|
||||
|
||||
writer.writerow(row)
|
||||
|
||||
print(f"Exported to {output_file}")
|
||||
|
||||
elif output_format == 'json':
|
||||
output_file = Path(f'translations_export_{datetime.now().strftime("%Y%m%d")}.json')
|
||||
export_data = {'languages': languages, 'translations': {}}
|
||||
|
||||
for key, en_value in golden_flat.items():
|
||||
if self._is_expected_identical(key, en_value):
|
||||
continue
|
||||
|
||||
export_data['translations'][key] = {
|
||||
'en_GB': en_value,
|
||||
'context': self._get_key_context(key)
|
||||
}
|
||||
|
||||
for lang in languages:
|
||||
lang_file = self.locales_dir / lang / "translation.json"
|
||||
if lang_file.exists():
|
||||
lang_data = self._load_json(lang_file)
|
||||
lang_flat = self._flatten_dict(lang_data)
|
||||
value = lang_flat.get(key, '')
|
||||
if value.startswith('[UNTRANSLATED]'):
|
||||
value = ''
|
||||
export_data['translations'][key][lang] = value
|
||||
|
||||
self._save_json(export_data, output_file)
|
||||
print(f"Exported to {output_file}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='AI Translation Helper')
|
||||
parser.add_argument('--locales-dir', default='frontend/public/locales',
|
||||
help='Path to locales directory')
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Create batch command
|
||||
batch_parser = subparsers.add_parser('create-batch', help='Create AI translation batch file')
|
||||
batch_parser.add_argument('--languages', nargs='+', required=True,
|
||||
help='Language codes to include')
|
||||
batch_parser.add_argument('--output', required=True, help='Output batch file')
|
||||
batch_parser.add_argument('--max-entries', type=int, default=100,
|
||||
help='Max entries per language')
|
||||
|
||||
# Validate command
|
||||
validate_parser = subparsers.add_parser('validate', help='Validate AI translations')
|
||||
validate_parser.add_argument('batch_file', help='Batch file to validate')
|
||||
|
||||
# Apply command
|
||||
apply_parser = subparsers.add_parser('apply-batch', help='Apply AI batch translations')
|
||||
apply_parser.add_argument('batch_file', help='Batch file with translations')
|
||||
apply_parser.add_argument('--skip-validation', action='store_true',
|
||||
help='Skip validation before applying')
|
||||
|
||||
# Export command
|
||||
export_parser = subparsers.add_parser('export', help='Export for external translation')
|
||||
export_parser.add_argument('--languages', nargs='+', required=True,
|
||||
help='Language codes to export')
|
||||
export_parser.add_argument('--format', choices=['csv', 'json'], default='csv',
|
||||
help='Export format')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
helper = AITranslationHelper(args.locales_dir)
|
||||
|
||||
if args.command == 'create-batch':
|
||||
output_file = Path(args.output)
|
||||
helper.create_ai_batch_file(args.languages, output_file, args.max_entries)
|
||||
|
||||
elif args.command == 'validate':
|
||||
batch_file = Path(args.batch_file)
|
||||
issues = helper.validate_ai_translations(batch_file)
|
||||
|
||||
if issues['errors']:
|
||||
print("ERRORS:")
|
||||
for error in issues['errors']:
|
||||
print(f" - {error}")
|
||||
|
||||
if issues['warnings']:
|
||||
print("WARNINGS:")
|
||||
for warning in issues['warnings']:
|
||||
print(f" - {warning}")
|
||||
|
||||
if not issues['errors'] and not issues['warnings']:
|
||||
print("No validation issues found!")
|
||||
|
||||
elif args.command == 'apply-batch':
|
||||
batch_file = Path(args.batch_file)
|
||||
results = helper.apply_ai_batch_translations(
|
||||
batch_file,
|
||||
validate=not args.skip_validation
|
||||
)
|
||||
|
||||
total_applied = sum(results['applied'].values())
|
||||
print(f"Total translations applied: {total_applied}")
|
||||
|
||||
elif args.command == 'export':
|
||||
helper.export_for_external_translation(args.languages, args.format)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Compact Translation Extractor for Character-Limited AI Translation
|
||||
Outputs untranslated entries in minimal JSON format with whitespace stripped.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ImportError:
|
||||
try:
|
||||
import toml as tomllib_fallback
|
||||
tomllib = None
|
||||
except ImportError:
|
||||
tomllib = None
|
||||
tomllib_fallback = None
|
||||
|
||||
|
||||
class CompactTranslationExtractor:
|
||||
def __init__(self, locales_dir: str = "frontend/public/locales", ignore_file: str = "scripts/ignore_translation.toml"):
|
||||
self.locales_dir = Path(locales_dir)
|
||||
self.golden_truth_file = self.locales_dir / "en-GB" / "translation.json"
|
||||
self.golden_truth = self._load_json(self.golden_truth_file)
|
||||
self.ignore_file = Path(ignore_file)
|
||||
self.ignore_patterns = self._load_ignore_patterns()
|
||||
|
||||
def _load_json(self, file_path: Path) -> dict:
|
||||
"""Load JSON file with error handling."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {file_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON in {file_path}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def _load_ignore_patterns(self) -> dict:
|
||||
"""Load ignore patterns from TOML file."""
|
||||
if not self.ignore_file.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
if tomllib:
|
||||
with open(self.ignore_file, 'rb') as f:
|
||||
ignore_data = tomllib.load(f)
|
||||
elif tomllib_fallback:
|
||||
ignore_data = tomllib_fallback.load(self.ignore_file)
|
||||
else:
|
||||
ignore_data = self._parse_simple_toml()
|
||||
|
||||
return {lang: set(data.get('ignore', [])) for lang, data in ignore_data.items()}
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load ignore file {self.ignore_file}: {e}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
def _parse_simple_toml(self) -> dict:
|
||||
"""Simple TOML parser for ignore patterns (fallback)."""
|
||||
ignore_data = {}
|
||||
current_section = None
|
||||
|
||||
with open(self.ignore_file, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
if line.startswith('[') and line.endswith(']'):
|
||||
current_section = line[1:-1]
|
||||
ignore_data[current_section] = {'ignore': []}
|
||||
elif line.strip().startswith("'") and current_section:
|
||||
item = line.strip().strip("',")
|
||||
if item:
|
||||
ignore_data[current_section]['ignore'].append(item)
|
||||
|
||||
return ignore_data
|
||||
|
||||
def _flatten_dict(self, d: dict, parent_key: str = '', separator: str = '.') -> dict:
|
||||
"""Flatten nested dictionary into dot-notation keys."""
|
||||
items = []
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{separator}{k}" if parent_key else k
|
||||
if isinstance(v, dict):
|
||||
items.extend(self._flatten_dict(v, new_key, separator).items())
|
||||
else:
|
||||
items.append((new_key, str(v)))
|
||||
return dict(items)
|
||||
|
||||
def get_untranslated_entries(self, language: str) -> dict:
|
||||
"""Get all untranslated entries for a language in compact format."""
|
||||
target_file = self.locales_dir / language / "translation.json"
|
||||
|
||||
if not target_file.exists():
|
||||
print(f"Error: Translation file not found for language: {language}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
target_data = self._load_json(target_file)
|
||||
golden_flat = self._flatten_dict(self.golden_truth)
|
||||
target_flat = self._flatten_dict(target_data)
|
||||
|
||||
lang_code = language.replace('-', '_')
|
||||
ignore_set = self.ignore_patterns.get(lang_code, set())
|
||||
|
||||
# Find missing translations
|
||||
missing_keys = set(golden_flat.keys()) - set(target_flat.keys()) - ignore_set
|
||||
|
||||
# Find untranslated entries (identical to en-GB or marked [UNTRANSLATED])
|
||||
untranslated_keys = set()
|
||||
for key in target_flat:
|
||||
if key in golden_flat and key not in ignore_set:
|
||||
target_value = target_flat[key]
|
||||
golden_value = golden_flat[key]
|
||||
|
||||
if (isinstance(target_value, str) and target_value.startswith("[UNTRANSLATED]")) or \
|
||||
(golden_value == target_value and not self._is_expected_identical(key, golden_value)):
|
||||
untranslated_keys.add(key)
|
||||
|
||||
# Combine and create compact output
|
||||
all_untranslated = missing_keys | untranslated_keys
|
||||
|
||||
compact_entries = {}
|
||||
for key in sorted(all_untranslated):
|
||||
if key in golden_flat:
|
||||
compact_entries[key] = golden_flat[key]
|
||||
|
||||
return compact_entries
|
||||
|
||||
def _is_expected_identical(self, key: str, value: str) -> bool:
|
||||
"""Check if a key-value pair is expected to be identical across languages."""
|
||||
identical_patterns = ['language.direction']
|
||||
identical_values = {'ltr', 'rtl', 'True', 'False', 'true', 'false', 'unknown'}
|
||||
|
||||
if value.strip() in identical_values:
|
||||
return True
|
||||
|
||||
for pattern in identical_patterns:
|
||||
if pattern in key.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Extract untranslated entries in compact format for AI translation')
|
||||
parser.add_argument('language', help='Language code (e.g., de-DE, fr-FR)')
|
||||
parser.add_argument('--locales-dir', default='frontend/public/locales', help='Path to locales directory')
|
||||
parser.add_argument('--ignore-file', default='scripts/ignore_translation.toml', help='Path to ignore patterns file')
|
||||
parser.add_argument('--max-entries', type=int, help='Maximum number of entries to output')
|
||||
parser.add_argument('--output', help='Output file (default: stdout)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
extractor = CompactTranslationExtractor(args.locales_dir, args.ignore_file)
|
||||
untranslated = extractor.get_untranslated_entries(args.language)
|
||||
|
||||
if args.max_entries:
|
||||
# Take first N entries
|
||||
keys = list(untranslated.keys())[:args.max_entries]
|
||||
untranslated = {k: untranslated[k] for k in keys}
|
||||
|
||||
# Output compact JSON (no indentation, minimal whitespace)
|
||||
output = json.dumps(untranslated, separators=(',', ':'), ensure_ascii=False)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
f.write(output)
|
||||
print(f"Extracted {len(untranslated)} untranslated entries to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JSON Beautifier and Structure Fixer for Stirling PDF Frontend
|
||||
Restructures translation JSON files to match en-GB structure and key order exactly.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
import argparse
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
class JSONBeautifier:
|
||||
def __init__(self, locales_dir: str = "frontend/public/locales"):
|
||||
self.locales_dir = Path(locales_dir)
|
||||
self.golden_truth_file = self.locales_dir / "en-GB" / "translation.json"
|
||||
self.golden_structure = self._load_json(self.golden_truth_file)
|
||||
|
||||
def _load_json(self, file_path: Path) -> Dict:
|
||||
"""Load JSON file with error handling."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f, object_pairs_hook=OrderedDict)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {file_path}")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON in {file_path}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def _save_json(self, data: Dict, file_path: Path, backup: bool = True) -> None:
|
||||
"""Save JSON file with proper formatting."""
|
||||
if backup and file_path.exists():
|
||||
backup_path = file_path.with_suffix(f'.backup.restructured.json')
|
||||
file_path.rename(backup_path)
|
||||
print(f"Backup created: {backup_path}")
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False, separators=(',', ': '))
|
||||
|
||||
def _flatten_dict(self, d: Dict, parent_key: str = '', separator: str = '.') -> Dict[str, Any]:
|
||||
"""Flatten nested dictionary into dot-notation keys."""
|
||||
items = []
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{separator}{k}" if parent_key else k
|
||||
if isinstance(v, dict):
|
||||
items.extend(self._flatten_dict(v, new_key, separator).items())
|
||||
else:
|
||||
items.append((new_key, v))
|
||||
return dict(items)
|
||||
|
||||
def _rebuild_structure(self, flat_dict: Dict[str, Any], reference_structure: Dict) -> Dict:
|
||||
"""Rebuild nested structure based on reference structure and available translations."""
|
||||
def build_recursive(ref_obj: Any, current_path: str = '') -> Any:
|
||||
if isinstance(ref_obj, dict):
|
||||
result = OrderedDict()
|
||||
for key, value in ref_obj.items():
|
||||
new_path = f"{current_path}.{key}" if current_path else key
|
||||
|
||||
if new_path in flat_dict:
|
||||
# Direct translation exists
|
||||
if isinstance(value, dict):
|
||||
# If reference is dict but we have a string, use the string
|
||||
if isinstance(flat_dict[new_path], str):
|
||||
result[key] = flat_dict[new_path]
|
||||
else:
|
||||
# Recurse into nested structure
|
||||
result[key] = build_recursive(value, new_path)
|
||||
else:
|
||||
result[key] = flat_dict[new_path]
|
||||
else:
|
||||
# No direct translation, recurse to check for nested keys
|
||||
if isinstance(value, dict):
|
||||
nested_result = build_recursive(value, new_path)
|
||||
if nested_result: # Only add if we found some translations
|
||||
result[key] = nested_result
|
||||
# If no translation found and it's a leaf, skip it
|
||||
|
||||
return result if result else None
|
||||
else:
|
||||
# Leaf node - return the translation if it exists
|
||||
return flat_dict.get(current_path, None)
|
||||
|
||||
return build_recursive(reference_structure) or OrderedDict()
|
||||
|
||||
def restructure_translation_file(self, target_file: Path) -> Dict[str, Any]:
|
||||
"""Restructure a translation file to match en-GB structure exactly."""
|
||||
if not target_file.exists():
|
||||
print(f"Error: Target file does not exist: {target_file}")
|
||||
return {}
|
||||
|
||||
# Load the target file
|
||||
target_data = self._load_json(target_file)
|
||||
|
||||
# Flatten the target translations
|
||||
flat_target = self._flatten_dict(target_data)
|
||||
|
||||
# Rebuild structure based on golden truth
|
||||
restructured = self._rebuild_structure(flat_target, self.golden_structure)
|
||||
|
||||
return restructured
|
||||
|
||||
def beautify_and_restructure(self, target_file: Path, backup: bool = True) -> Dict[str, Any]:
|
||||
"""Main function to beautify and restructure a translation file."""
|
||||
lang_code = target_file.parent.name
|
||||
print(f"Restructuring {lang_code} translation file...")
|
||||
|
||||
# Get the restructured data
|
||||
restructured_data = self.restructure_translation_file(target_file)
|
||||
|
||||
# Save the restructured file
|
||||
self._save_json(restructured_data, target_file, backup)
|
||||
|
||||
# Analyze the results
|
||||
flat_golden = self._flatten_dict(self.golden_structure)
|
||||
flat_restructured = self._flatten_dict(restructured_data)
|
||||
|
||||
total_keys = len(flat_golden)
|
||||
preserved_keys = len(flat_restructured)
|
||||
|
||||
result = {
|
||||
'language': lang_code,
|
||||
'total_reference_keys': total_keys,
|
||||
'preserved_keys': preserved_keys,
|
||||
'structure_match': self._compare_structures(self.golden_structure, restructured_data)
|
||||
}
|
||||
|
||||
print(f"Restructured {lang_code}: {preserved_keys}/{total_keys} keys preserved")
|
||||
return result
|
||||
|
||||
def _compare_structures(self, ref: Dict, target: Dict) -> Dict[str, bool]:
|
||||
"""Compare structures between reference and target."""
|
||||
def compare_recursive(r: Any, t: Any, path: str = '') -> List[str]:
|
||||
issues = []
|
||||
|
||||
if isinstance(r, dict) and isinstance(t, dict):
|
||||
# Check for missing top-level sections
|
||||
ref_keys = set(r.keys())
|
||||
target_keys = set(t.keys())
|
||||
|
||||
missing_sections = ref_keys - target_keys
|
||||
if missing_sections:
|
||||
for section in missing_sections:
|
||||
issues.append(f"Missing section: {path}.{section}" if path else section)
|
||||
|
||||
# Recurse into common sections
|
||||
for key in ref_keys & target_keys:
|
||||
new_path = f"{path}.{key}" if path else key
|
||||
issues.extend(compare_recursive(r[key], t[key], new_path))
|
||||
|
||||
return issues
|
||||
|
||||
issues = compare_recursive(ref, target)
|
||||
|
||||
return {
|
||||
'structures_match': len(issues) == 0,
|
||||
'issues': issues[:10], # Limit to first 10 issues
|
||||
'total_issues': len(issues)
|
||||
}
|
||||
|
||||
def validate_key_order(self, target_file: Path) -> Dict[str, Any]:
|
||||
"""Validate that keys appear in the same order as en-GB."""
|
||||
target_data = self._load_json(target_file)
|
||||
|
||||
def get_key_order(obj: Dict, path: str = '') -> List[str]:
|
||||
keys = []
|
||||
for key in obj.keys():
|
||||
new_path = f"{path}.{key}" if path else key
|
||||
keys.append(new_path)
|
||||
if isinstance(obj[key], dict):
|
||||
keys.extend(get_key_order(obj[key], new_path))
|
||||
return keys
|
||||
|
||||
golden_order = get_key_order(self.golden_structure)
|
||||
target_order = get_key_order(target_data)
|
||||
|
||||
# Find common keys and check their relative order
|
||||
common_keys = set(golden_order) & set(target_order)
|
||||
|
||||
golden_indices = {key: idx for idx, key in enumerate(golden_order) if key in common_keys}
|
||||
target_indices = {key: idx for idx, key in enumerate(target_order) if key in common_keys}
|
||||
|
||||
order_preserved = all(
|
||||
golden_indices[key1] < golden_indices[key2]
|
||||
for key1 in common_keys for key2 in common_keys
|
||||
if golden_indices[key1] < golden_indices[key2] and target_indices[key1] < target_indices[key2]
|
||||
)
|
||||
|
||||
return {
|
||||
'order_preserved': order_preserved,
|
||||
'common_keys_count': len(common_keys),
|
||||
'golden_keys_count': len(golden_order),
|
||||
'target_keys_count': len(target_order)
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Beautify and restructure translation JSON files')
|
||||
parser.add_argument('--locales-dir', default='frontend/public/locales',
|
||||
help='Path to locales directory')
|
||||
parser.add_argument('--language', help='Restructure specific language only')
|
||||
parser.add_argument('--all-languages', action='store_true',
|
||||
help='Restructure all language files')
|
||||
parser.add_argument('--no-backup', action='store_true',
|
||||
help='Skip backup creation')
|
||||
parser.add_argument('--validate-only', action='store_true',
|
||||
help='Only validate structure, do not modify files')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
beautifier = JSONBeautifier(args.locales_dir)
|
||||
|
||||
if args.language:
|
||||
target_file = Path(args.locales_dir) / args.language / "translation.json"
|
||||
if not target_file.exists():
|
||||
print(f"Error: Translation file not found for language: {args.language}")
|
||||
sys.exit(1)
|
||||
|
||||
if args.validate_only:
|
||||
order_result = beautifier.validate_key_order(target_file)
|
||||
print(f"Key order validation for {args.language}:")
|
||||
print(f" Order preserved: {order_result['order_preserved']}")
|
||||
print(f" Common keys: {order_result['common_keys_count']}/{order_result['golden_keys_count']}")
|
||||
else:
|
||||
result = beautifier.beautify_and_restructure(target_file, backup=not args.no_backup)
|
||||
print(f"\nResults for {result['language']}:")
|
||||
print(f" Keys preserved: {result['preserved_keys']}/{result['total_reference_keys']}")
|
||||
if result['structure_match']['total_issues'] > 0:
|
||||
print(f" Structure issues: {result['structure_match']['total_issues']}")
|
||||
for issue in result['structure_match']['issues']:
|
||||
print(f" - {issue}")
|
||||
|
||||
elif args.all_languages:
|
||||
results = []
|
||||
for lang_dir in Path(args.locales_dir).iterdir():
|
||||
if lang_dir.is_dir() and lang_dir.name != "en-GB":
|
||||
translation_file = lang_dir / "translation.json"
|
||||
if translation_file.exists():
|
||||
if args.validate_only:
|
||||
order_result = beautifier.validate_key_order(translation_file)
|
||||
print(f"{lang_dir.name}: Order preserved = {order_result['order_preserved']}")
|
||||
else:
|
||||
result = beautifier.beautify_and_restructure(translation_file, backup=not args.no_backup)
|
||||
results.append(result)
|
||||
|
||||
if not args.validate_only and results:
|
||||
print(f"\n{'='*60}")
|
||||
print("RESTRUCTURING SUMMARY")
|
||||
print(f"{'='*60}")
|
||||
for result in sorted(results, key=lambda x: x['language']):
|
||||
print(f"{result['language']}: {result['preserved_keys']}/{result['total_reference_keys']} keys "
|
||||
f"({result['preserved_keys']/result['total_reference_keys']*100:.1f}%)")
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Translation Analyzer for Stirling PDF Frontend
|
||||
Compares language files against en-GB golden truth file.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set, Tuple
|
||||
import argparse
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ImportError:
|
||||
try:
|
||||
import toml as tomllib_fallback
|
||||
tomllib = None
|
||||
except ImportError:
|
||||
tomllib = None
|
||||
tomllib_fallback = None
|
||||
|
||||
|
||||
class TranslationAnalyzer:
|
||||
def __init__(self, locales_dir: str = "frontend/public/locales", ignore_file: str = "scripts/ignore_translation.toml"):
|
||||
self.locales_dir = Path(locales_dir)
|
||||
self.golden_truth_file = self.locales_dir / "en-GB" / "translation.json"
|
||||
self.golden_truth = self._load_json(self.golden_truth_file)
|
||||
self.ignore_file = Path(ignore_file)
|
||||
self.ignore_patterns = self._load_ignore_patterns()
|
||||
|
||||
def _load_json(self, file_path: Path) -> Dict:
|
||||
"""Load JSON file with error handling."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {file_path}")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON in {file_path}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def _load_ignore_patterns(self) -> Dict[str, Set[str]]:
|
||||
"""Load ignore patterns from TOML file."""
|
||||
if not self.ignore_file.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
if tomllib:
|
||||
# Use Python 3.11+ built-in
|
||||
with open(self.ignore_file, 'rb') as f:
|
||||
ignore_data = tomllib.load(f)
|
||||
elif tomllib_fallback:
|
||||
# Use toml library fallback
|
||||
ignore_data = tomllib_fallback.load(self.ignore_file)
|
||||
else:
|
||||
# Simple parser as fallback
|
||||
ignore_data = self._parse_simple_toml()
|
||||
|
||||
# Convert lists to sets for faster lookup
|
||||
return {lang: set(patterns) for lang, data in ignore_data.items()
|
||||
for patterns in [data.get('ignore', [])] if patterns}
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load ignore file {self.ignore_file}: {e}")
|
||||
return {}
|
||||
|
||||
def _parse_simple_toml(self) -> Dict:
|
||||
"""Simple TOML parser for ignore patterns (fallback)."""
|
||||
ignore_data = {}
|
||||
current_section = None
|
||||
|
||||
with open(self.ignore_file, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
if line.startswith('[') and line.endswith(']'):
|
||||
current_section = line[1:-1]
|
||||
ignore_data[current_section] = {'ignore': []}
|
||||
elif line.startswith('ignore = [') and current_section:
|
||||
# Handle ignore array
|
||||
continue
|
||||
elif line.strip().startswith("'") and current_section:
|
||||
# Extract quoted items
|
||||
item = line.strip().strip("',")
|
||||
if item:
|
||||
ignore_data[current_section]['ignore'].append(item)
|
||||
|
||||
return ignore_data
|
||||
|
||||
def _flatten_dict(self, d: Dict, parent_key: str = '', separator: str = '.') -> Dict[str, str]:
|
||||
"""Flatten nested dictionary into dot-notation keys."""
|
||||
items = []
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{separator}{k}" if parent_key else k
|
||||
if isinstance(v, dict):
|
||||
items.extend(self._flatten_dict(v, new_key, separator).items())
|
||||
else:
|
||||
items.append((new_key, str(v)))
|
||||
return dict(items)
|
||||
|
||||
def get_all_language_files(self) -> List[Path]:
|
||||
"""Get all translation.json files except en-GB."""
|
||||
files = []
|
||||
for lang_dir in self.locales_dir.iterdir():
|
||||
if lang_dir.is_dir() and lang_dir.name != "en-GB":
|
||||
translation_file = lang_dir / "translation.json"
|
||||
if translation_file.exists():
|
||||
files.append(translation_file)
|
||||
return sorted(files)
|
||||
|
||||
def find_missing_translations(self, target_file: Path) -> Set[str]:
|
||||
"""Find keys that exist in en-GB but missing in target file."""
|
||||
target_data = self._load_json(target_file)
|
||||
|
||||
golden_flat = self._flatten_dict(self.golden_truth)
|
||||
target_flat = self._flatten_dict(target_data)
|
||||
|
||||
missing = set(golden_flat.keys()) - set(target_flat.keys())
|
||||
|
||||
# Filter out ignored keys
|
||||
lang_code = target_file.parent.name.replace('-', '_')
|
||||
ignore_set = self.ignore_patterns.get(lang_code, set())
|
||||
return missing - ignore_set
|
||||
|
||||
def find_untranslated_entries(self, target_file: Path) -> Set[str]:
|
||||
"""Find entries that appear to be untranslated (identical to en-GB)."""
|
||||
target_data = self._load_json(target_file)
|
||||
|
||||
golden_flat = self._flatten_dict(self.golden_truth)
|
||||
target_flat = self._flatten_dict(target_data)
|
||||
|
||||
lang_code = target_file.parent.name.replace('-', '_')
|
||||
ignore_set = self.ignore_patterns.get(lang_code, set())
|
||||
|
||||
untranslated = set()
|
||||
for key in target_flat:
|
||||
if key in golden_flat:
|
||||
target_value = target_flat[key]
|
||||
golden_value = golden_flat[key]
|
||||
|
||||
# Check if marked as [UNTRANSLATED] or identical to en-GB
|
||||
if (isinstance(target_value, str) and target_value.startswith("[UNTRANSLATED]")) or \
|
||||
(golden_value == target_value and key not in ignore_set and not self._is_expected_identical(key, golden_value)):
|
||||
untranslated.add(key)
|
||||
|
||||
return untranslated
|
||||
|
||||
def _is_expected_identical(self, key: str, value: str) -> bool:
|
||||
"""Check if a key-value pair is expected to be identical across languages."""
|
||||
# Keys that should be identical across languages
|
||||
identical_patterns = [
|
||||
'language.direction',
|
||||
'true', 'false',
|
||||
'unknown'
|
||||
]
|
||||
|
||||
# Values that are often identical (numbers, symbols, etc.)
|
||||
if value.strip() in ['ltr', 'rtl', 'True', 'False']:
|
||||
return True
|
||||
|
||||
# Check for patterns
|
||||
for pattern in identical_patterns:
|
||||
if pattern in key.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def find_extra_translations(self, target_file: Path) -> Set[str]:
|
||||
"""Find keys that exist in target file but not in en-GB."""
|
||||
target_data = self._load_json(target_file)
|
||||
|
||||
golden_flat = self._flatten_dict(self.golden_truth)
|
||||
target_flat = self._flatten_dict(target_data)
|
||||
|
||||
return set(target_flat.keys()) - set(golden_flat.keys())
|
||||
|
||||
def analyze_file(self, target_file: Path) -> Dict:
|
||||
"""Complete analysis of a single translation file."""
|
||||
lang_code = target_file.parent.name
|
||||
|
||||
missing = self.find_missing_translations(target_file)
|
||||
untranslated = self.find_untranslated_entries(target_file)
|
||||
extra = self.find_extra_translations(target_file)
|
||||
|
||||
target_data = self._load_json(target_file)
|
||||
golden_flat = self._flatten_dict(self.golden_truth)
|
||||
target_flat = self._flatten_dict(target_data)
|
||||
|
||||
# Calculate completion rate excluding ignored keys
|
||||
lang_code = target_file.parent.name.replace('-', '_')
|
||||
ignore_set = self.ignore_patterns.get(lang_code, set())
|
||||
|
||||
relevant_keys = set(golden_flat.keys()) - ignore_set
|
||||
total_keys = len(relevant_keys)
|
||||
|
||||
# Count keys that exist and are properly translated (not [UNTRANSLATED])
|
||||
properly_translated = 0
|
||||
for key in relevant_keys:
|
||||
if key in target_flat:
|
||||
value = target_flat[key]
|
||||
if not (isinstance(value, str) and value.startswith("[UNTRANSLATED]")):
|
||||
if key not in untranslated: # Not identical to en-GB (unless expected)
|
||||
properly_translated += 1
|
||||
|
||||
completion_rate = (properly_translated / total_keys) * 100 if total_keys > 0 else 0
|
||||
|
||||
return {
|
||||
'language': lang_code,
|
||||
'file': target_file,
|
||||
'missing_count': len(missing),
|
||||
'missing_keys': sorted(missing),
|
||||
'untranslated_count': len(untranslated),
|
||||
'untranslated_keys': sorted(untranslated),
|
||||
'extra_count': len(extra),
|
||||
'extra_keys': sorted(extra),
|
||||
'total_keys': total_keys,
|
||||
'completion_rate': completion_rate
|
||||
}
|
||||
|
||||
def analyze_all_files(self) -> List[Dict]:
|
||||
"""Analyze all translation files."""
|
||||
results = []
|
||||
for file_path in self.get_all_language_files():
|
||||
results.append(self.analyze_file(file_path))
|
||||
return sorted(results, key=lambda x: x['language'])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Analyze translation files against en-GB golden truth')
|
||||
parser.add_argument('--locales-dir', default='frontend/public/locales',
|
||||
help='Path to locales directory')
|
||||
parser.add_argument('--ignore-file', default='scripts/ignore_translation.toml',
|
||||
help='Path to ignore patterns TOML file')
|
||||
parser.add_argument('--language', help='Analyze specific language only')
|
||||
parser.add_argument('--missing-only', action='store_true',
|
||||
help='Show only missing translations')
|
||||
parser.add_argument('--untranslated-only', action='store_true',
|
||||
help='Show only untranslated entries')
|
||||
parser.add_argument('--summary', action='store_true',
|
||||
help='Show summary statistics only')
|
||||
parser.add_argument('--format', choices=['text', 'json'], default='text',
|
||||
help='Output format')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
analyzer = TranslationAnalyzer(args.locales_dir, args.ignore_file)
|
||||
|
||||
if args.language:
|
||||
target_file = Path(args.locales_dir) / args.language / "translation.json"
|
||||
if not target_file.exists():
|
||||
print(f"Error: Translation file not found for language: {args.language}")
|
||||
sys.exit(1)
|
||||
results = [analyzer.analyze_file(target_file)]
|
||||
else:
|
||||
results = analyzer.analyze_all_files()
|
||||
|
||||
if args.format == 'json':
|
||||
print(json.dumps(results, indent=2, default=str))
|
||||
return
|
||||
|
||||
# Text format output
|
||||
for result in results:
|
||||
lang = result['language']
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Language: {lang}")
|
||||
print(f"File: {result['file']}")
|
||||
print(f"Completion Rate: {result['completion_rate']:.1f}%")
|
||||
print(f"Total Keys in en-GB: {result['total_keys']}")
|
||||
|
||||
if not args.summary:
|
||||
if not args.untranslated_only:
|
||||
print(f"\nMissing Translations ({result['missing_count']}):")
|
||||
for key in result['missing_keys'][:10]: # Show first 10
|
||||
print(f" - {key}")
|
||||
if len(result['missing_keys']) > 10:
|
||||
print(f" ... and {len(result['missing_keys']) - 10} more")
|
||||
|
||||
if not args.missing_only:
|
||||
print(f"\nUntranslated Entries ({result['untranslated_count']}):")
|
||||
for key in result['untranslated_keys'][:10]: # Show first 10
|
||||
print(f" - {key}")
|
||||
if len(result['untranslated_keys']) > 10:
|
||||
print(f" ... and {len(result['untranslated_keys']) - 10} more")
|
||||
|
||||
if result['extra_count'] > 0:
|
||||
print(f"\nExtra Keys Not in en-GB ({result['extra_count']}):")
|
||||
for key in result['extra_keys'][:5]:
|
||||
print(f" - {key}")
|
||||
if len(result['extra_keys']) > 5:
|
||||
print(f" ... and {len(result['extra_keys']) - 5} more")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("SUMMARY")
|
||||
print(f"{'='*60}")
|
||||
avg_completion = sum(r['completion_rate'] for r in results) / len(results) if results else 0
|
||||
print(f"Average Completion Rate: {avg_completion:.1f}%")
|
||||
print(f"Languages Analyzed: {len(results)}")
|
||||
|
||||
# Top languages by completion
|
||||
sorted_by_completion = sorted(results, key=lambda x: x['completion_rate'], reverse=True)
|
||||
print(f"\nTop 5 Most Complete Languages:")
|
||||
for result in sorted_by_completion[:5]:
|
||||
print(f" {result['language']}: {result['completion_rate']:.1f}%")
|
||||
|
||||
print(f"\nBottom 5 Languages Needing Attention:")
|
||||
for result in sorted_by_completion[-5:]:
|
||||
print(f" {result['language']}: {result['completion_rate']:.1f}% ({result['missing_count']} missing, {result['untranslated_count']} untranslated)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Translation Merger for Stirling PDF Frontend
|
||||
Merges missing translations from en-GB into target language files.
|
||||
Useful for AI-assisted translation workflows.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set, Tuple, Any
|
||||
import argparse
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ImportError:
|
||||
try:
|
||||
import toml as tomllib_fallback
|
||||
tomllib = None
|
||||
except ImportError:
|
||||
tomllib = None
|
||||
tomllib_fallback = None
|
||||
|
||||
|
||||
class TranslationMerger:
|
||||
def __init__(self, locales_dir: str = "frontend/public/locales", ignore_file: str = "scripts/ignore_translation.toml"):
|
||||
self.locales_dir = Path(locales_dir)
|
||||
self.golden_truth_file = self.locales_dir / "en-GB" / "translation.json"
|
||||
self.golden_truth = self._load_json(self.golden_truth_file)
|
||||
self.ignore_file = Path(ignore_file)
|
||||
self.ignore_patterns = self._load_ignore_patterns()
|
||||
|
||||
def _load_json(self, file_path: Path) -> Dict:
|
||||
"""Load JSON file with error handling."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {file_path}")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON in {file_path}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def _save_json(self, data: Dict, file_path: Path, backup: bool = True) -> None:
|
||||
"""Save JSON file with backup option."""
|
||||
if backup and file_path.exists():
|
||||
backup_path = file_path.with_suffix(f'.backup.{datetime.now().strftime("%Y%m%d_%H%M%S")}.json')
|
||||
shutil.copy2(file_path, backup_path)
|
||||
print(f"Backup created: {backup_path}")
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def _load_ignore_patterns(self) -> Dict[str, Set[str]]:
|
||||
"""Load ignore patterns from TOML file."""
|
||||
if not self.ignore_file.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
# Simple parser for ignore patterns
|
||||
ignore_data = {}
|
||||
current_section = None
|
||||
|
||||
with open(self.ignore_file, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
if line.startswith('[') and line.endswith(']'):
|
||||
current_section = line[1:-1]
|
||||
ignore_data[current_section] = set()
|
||||
elif line.strip().startswith("'") and current_section:
|
||||
# Extract quoted items
|
||||
item = line.strip().strip("',")
|
||||
if item:
|
||||
ignore_data[current_section].add(item)
|
||||
|
||||
return ignore_data
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load ignore file {self.ignore_file}: {e}")
|
||||
return {}
|
||||
|
||||
def _get_nested_value(self, data: Dict, key_path: str) -> Any:
|
||||
"""Get value from nested dict using dot notation."""
|
||||
keys = key_path.split('.')
|
||||
current = data
|
||||
for key in keys:
|
||||
if isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
def _set_nested_value(self, data: Dict, key_path: str, value: Any) -> None:
|
||||
"""Set value in nested dict using dot notation."""
|
||||
keys = key_path.split('.')
|
||||
current = data
|
||||
for key in keys[:-1]:
|
||||
if key not in current:
|
||||
current[key] = {}
|
||||
elif not isinstance(current[key], dict):
|
||||
# If the current value is not a dict, we can't nest into it
|
||||
# This handles cases where a key exists as a string but we need to make it a dict
|
||||
print(f"Warning: Converting non-dict value at '{key}' to dict to allow nesting")
|
||||
current[key] = {}
|
||||
current = current[key]
|
||||
current[keys[-1]] = value
|
||||
|
||||
def _flatten_dict(self, d: Dict, parent_key: str = '', separator: str = '.') -> Dict[str, Any]:
|
||||
"""Flatten nested dictionary into dot-notation keys."""
|
||||
items = []
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{separator}{k}" if parent_key else k
|
||||
if isinstance(v, dict):
|
||||
items.extend(self._flatten_dict(v, new_key, separator).items())
|
||||
else:
|
||||
items.append((new_key, v))
|
||||
return dict(items)
|
||||
|
||||
def get_missing_keys(self, target_file: Path) -> List[str]:
|
||||
"""Get list of missing keys in target file."""
|
||||
lang_code = target_file.parent.name.replace('-', '_')
|
||||
ignore_set = self.ignore_patterns.get(lang_code, set())
|
||||
|
||||
if not target_file.exists():
|
||||
golden_keys = set(self._flatten_dict(self.golden_truth).keys())
|
||||
return sorted(golden_keys - ignore_set)
|
||||
|
||||
target_data = self._load_json(target_file)
|
||||
golden_flat = self._flatten_dict(self.golden_truth)
|
||||
target_flat = self._flatten_dict(target_data)
|
||||
|
||||
missing = set(golden_flat.keys()) - set(target_flat.keys())
|
||||
return sorted(missing - ignore_set)
|
||||
|
||||
def add_missing_translations(self, target_file: Path, keys_to_add: List[str] = None,
|
||||
mark_untranslated: bool = True) -> Dict:
|
||||
"""Add missing translations from en-GB to target file."""
|
||||
if not target_file.exists():
|
||||
target_data = {}
|
||||
else:
|
||||
target_data = self._load_json(target_file)
|
||||
|
||||
golden_flat = self._flatten_dict(self.golden_truth)
|
||||
missing_keys = keys_to_add or self.get_missing_keys(target_file)
|
||||
|
||||
added_count = 0
|
||||
for key in missing_keys:
|
||||
if key in golden_flat:
|
||||
value = golden_flat[key]
|
||||
if mark_untranslated and isinstance(value, str):
|
||||
# Mark as untranslated for AI to translate later
|
||||
value = f"[UNTRANSLATED] {value}"
|
||||
|
||||
self._set_nested_value(target_data, key, value)
|
||||
added_count += 1
|
||||
|
||||
return {
|
||||
'added_count': added_count,
|
||||
'missing_keys': missing_keys,
|
||||
'data': target_data
|
||||
}
|
||||
|
||||
def extract_untranslated_entries(self, target_file: Path, output_file: Path = None) -> Dict:
|
||||
"""Extract entries marked as untranslated or identical to en-GB for AI translation."""
|
||||
if not target_file.exists():
|
||||
print(f"Error: Target file does not exist: {target_file}")
|
||||
return {}
|
||||
|
||||
target_data = self._load_json(target_file)
|
||||
golden_flat = self._flatten_dict(self.golden_truth)
|
||||
target_flat = self._flatten_dict(target_data)
|
||||
|
||||
untranslated_entries = {}
|
||||
|
||||
for key, value in target_flat.items():
|
||||
if key in golden_flat:
|
||||
golden_value = golden_flat[key]
|
||||
|
||||
# Check if marked as untranslated
|
||||
if isinstance(value, str) and value.startswith("[UNTRANSLATED]"):
|
||||
untranslated_entries[key] = {
|
||||
'original': golden_value,
|
||||
'current': value,
|
||||
'reason': 'marked_untranslated'
|
||||
}
|
||||
# Check if identical to golden (and should be translated)
|
||||
elif value == golden_value and not self._is_expected_identical(key, value):
|
||||
untranslated_entries[key] = {
|
||||
'original': golden_value,
|
||||
'current': value,
|
||||
'reason': 'identical_to_english'
|
||||
}
|
||||
|
||||
if output_file:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(untranslated_entries, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return untranslated_entries
|
||||
|
||||
def _is_expected_identical(self, key: str, value: str) -> bool:
|
||||
"""Check if a key-value pair is expected to be identical across languages."""
|
||||
identical_patterns = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
if str(value).strip() in ['ltr', 'rtl', 'True', 'False', 'true', 'false']:
|
||||
return True
|
||||
|
||||
for pattern in identical_patterns:
|
||||
if pattern in key.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def apply_translations(self, target_file: Path, translations: Dict[str, str],
|
||||
backup: bool = True) -> Dict:
|
||||
"""Apply provided translations to target file."""
|
||||
if not target_file.exists():
|
||||
print(f"Error: Target file does not exist: {target_file}")
|
||||
return {'success': False, 'error': 'File not found'}
|
||||
|
||||
target_data = self._load_json(target_file)
|
||||
applied_count = 0
|
||||
errors = []
|
||||
|
||||
for key, translation in translations.items():
|
||||
try:
|
||||
# Remove [UNTRANSLATED] marker if present
|
||||
if translation.startswith("[UNTRANSLATED]"):
|
||||
translation = translation.replace("[UNTRANSLATED]", "").strip()
|
||||
|
||||
self._set_nested_value(target_data, key, translation)
|
||||
applied_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Error setting {key}: {e}")
|
||||
|
||||
if applied_count > 0:
|
||||
self._save_json(target_data, target_file, backup)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'applied_count': applied_count,
|
||||
'errors': errors,
|
||||
'data': target_data
|
||||
}
|
||||
|
||||
def create_translation_template(self, target_file: Path, output_file: Path) -> None:
|
||||
"""Create a template file for AI translation with context."""
|
||||
untranslated = self.extract_untranslated_entries(target_file)
|
||||
|
||||
template = {
|
||||
'metadata': {
|
||||
'source_language': 'en-GB',
|
||||
'target_language': target_file.parent.name,
|
||||
'total_entries': len(untranslated),
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'instructions': 'Translate the "original" values to the target language. Keep the same keys.'
|
||||
},
|
||||
'translations': {}
|
||||
}
|
||||
|
||||
for key, entry in untranslated.items():
|
||||
template['translations'][key] = {
|
||||
'original': entry['original'],
|
||||
'translated': '', # AI should fill this
|
||||
'context': self._get_context_for_key(key),
|
||||
'reason': entry['reason']
|
||||
}
|
||||
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"Translation template created: {output_file}")
|
||||
print(f"Contains {len(untranslated)} entries to translate")
|
||||
|
||||
def _get_context_for_key(self, key: str) -> str:
|
||||
"""Get context information for a translation key."""
|
||||
parts = key.split('.')
|
||||
if len(parts) >= 2:
|
||||
return f"Section: {parts[0]}, Property: {parts[-1]}"
|
||||
return f"Property: {parts[-1]}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Merge and manage translation files')
|
||||
parser.add_argument('--locales-dir', default='frontend/public/locales',
|
||||
help='Path to locales directory')
|
||||
parser.add_argument('--ignore-file', default='scripts/ignore_translation.toml',
|
||||
help='Path to ignore patterns TOML file')
|
||||
parser.add_argument('language', help='Target language code (e.g., fr-FR)')
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Add missing command
|
||||
add_parser = subparsers.add_parser('add-missing', help='Add missing translations from en-GB')
|
||||
add_parser.add_argument('--no-backup', action='store_true', help='Skip backup creation')
|
||||
add_parser.add_argument('--mark-untranslated', action='store_true', default=True,
|
||||
help='Mark added translations as [UNTRANSLATED]')
|
||||
|
||||
# Extract untranslated command
|
||||
extract_parser = subparsers.add_parser('extract-untranslated', help='Extract untranslated entries')
|
||||
extract_parser.add_argument('--output', help='Output file path')
|
||||
|
||||
# Create template command
|
||||
template_parser = subparsers.add_parser('create-template', help='Create AI translation template')
|
||||
template_parser.add_argument('--output', required=True, help='Output template file path')
|
||||
|
||||
# Apply translations command
|
||||
apply_parser = subparsers.add_parser('apply-translations', help='Apply translations from JSON file')
|
||||
apply_parser.add_argument('--translations-file', required=True, help='JSON file with translations')
|
||||
apply_parser.add_argument('--no-backup', action='store_true', help='Skip backup creation')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
merger = TranslationMerger(args.locales_dir, args.ignore_file)
|
||||
target_file = Path(args.locales_dir) / args.language / "translation.json"
|
||||
|
||||
if args.command == 'add-missing':
|
||||
print(f"Adding missing translations to {args.language}...")
|
||||
result = merger.add_missing_translations(
|
||||
target_file,
|
||||
mark_untranslated=args.mark_untranslated
|
||||
)
|
||||
|
||||
merger._save_json(result['data'], target_file, backup=not args.no_backup)
|
||||
print(f"Added {result['added_count']} missing translations")
|
||||
|
||||
elif args.command == 'extract-untranslated':
|
||||
output_file = Path(args.output) if args.output else target_file.with_suffix('.untranslated.json')
|
||||
untranslated = merger.extract_untranslated_entries(target_file, output_file)
|
||||
print(f"Extracted {len(untranslated)} untranslated entries to {output_file}")
|
||||
|
||||
elif args.command == 'create-template':
|
||||
output_file = Path(args.output)
|
||||
merger.create_translation_template(target_file, output_file)
|
||||
|
||||
elif args.command == 'apply-translations':
|
||||
with open(args.translations_file, 'r', encoding='utf-8') as f:
|
||||
translations_data = json.load(f)
|
||||
|
||||
# Extract translations from template format or simple dict
|
||||
if 'translations' in translations_data:
|
||||
translations = {k: v['translated'] for k, v in translations_data['translations'].items()
|
||||
if v.get('translated')}
|
||||
else:
|
||||
translations = translations_data
|
||||
|
||||
result = merger.apply_translations(target_file, translations, backup=not args.no_backup)
|
||||
|
||||
if result['success']:
|
||||
print(f"Applied {result['applied_count']} translations")
|
||||
if result['errors']:
|
||||
print(f"Errors: {len(result['errors'])}")
|
||||
for error in result['errors'][:5]:
|
||||
print(f" - {error}")
|
||||
else:
|
||||
print(f"Failed: {result.get('error', 'Unknown error')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user