Compare commits

...
Author SHA1 Message Date
Anthony Stirling 5d3739e1d3 urls 2025-09-26 14:48:15 +01:00
Anthony Stirling 8aa52a4fa6 remove tools 2025-09-26 14:43:38 +01:00
EthanHealy01 0bdc6466ca add the reorganize pages tool (#4506) 2025-09-26 12:49:18 +01:00
EthanHealy01 f2a6e95fcf Feature/remove images (#4503) 2025-09-26 12:46:02 +01:00
EthanHealy01 0c08764669 add attatchments tool (#4502) 2025-09-26 12:45:31 +01:00
Anthony Stirling 9758e871d4 feat: Add React-based extract-images tool (#4501) 2025-09-26 12:45:15 +01:00
Anthony Stirling 18fa16f08e Invert colors (#4498) 2025-09-26 12:44:25 +01:00
Anthony StirlingandClaude 233b710b78 Convert extract-image-scans to React component (#4505)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-26 12:38:10 +01:00
Reece Browne d613a4659e Feature/v2/exportpdf (#4487)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-09-26 10:28:09 +01:00
Reece Browne 03f484e0c0 Fix (#4495)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-09-26 10:03:35 +01:00
EthanHealy01 fd52dc0226 Feature/toasts and error handling (#4496)
# Description of Changes

- Added error handling and toast notifications

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-09-25 21:03:53 +01:00
EthanHealy01 21b1428ab5 Feature/v2/fuzzy tool search (#4482)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [x] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-09-25 16:32:30 +01:00
Anthony Stirling 1219cebd07 Language stuff (#4490)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-09-25 12:50:19 +01:00
90 changed files with 11895 additions and 1328 deletions
+7
View File
@@ -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;
}
+13
View File
@@ -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;
+141 -110
View File
@@ -10,21 +10,22 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.2.1",
"@embedpdf/core": "^1.3.0",
"@embedpdf/engines": "^1.2.1",
"@embedpdf/plugin-interaction-manager": "^1.2.1",
"@embedpdf/plugin-loader": "^1.2.1",
"@embedpdf/plugin-pan": "^1.2.1",
"@embedpdf/plugin-render": "^1.2.1",
"@embedpdf/plugin-rotate": "^1.2.1",
"@embedpdf/plugin-scroll": "^1.2.1",
"@embedpdf/plugin-search": "^1.2.1",
"@embedpdf/plugin-selection": "^1.2.1",
"@embedpdf/plugin-spread": "^1.2.1",
"@embedpdf/plugin-thumbnail": "^1.2.1",
"@embedpdf/plugin-tiling": "^1.2.1",
"@embedpdf/plugin-viewport": "^1.2.1",
"@embedpdf/plugin-zoom": "^1.2.1",
"@embedpdf/plugin-export": "^1.3.0",
"@embedpdf/plugin-interaction-manager": "^1.3.0",
"@embedpdf/plugin-loader": "^1.3.0",
"@embedpdf/plugin-pan": "^1.3.0",
"@embedpdf/plugin-render": "^1.3.0",
"@embedpdf/plugin-rotate": "^1.3.0",
"@embedpdf/plugin-scroll": "^1.3.0",
"@embedpdf/plugin-search": "^1.3.0",
"@embedpdf/plugin-selection": "^1.3.0",
"@embedpdf/plugin-spread": "^1.3.0",
"@embedpdf/plugin-thumbnail": "^1.3.0",
"@embedpdf/plugin-tiling": "^1.3.0",
"@embedpdf/plugin-viewport": "^1.3.0",
"@embedpdf/plugin-zoom": "^1.3.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -488,12 +489,13 @@
}
},
"node_modules/@embedpdf/core": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.2.1.tgz",
"integrity": "sha512-2VwRPsN3+LmaBrD8TCN1t1ni/Vc9CxAfl/SApDjZYwE7zOieQT4ZHt+nkgF0F4I3xSgvvyHDjmOonhjBIrT6xA==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.0.tgz",
"integrity": "sha512-KEic1NA9JrtNRoTq3O3m93YTglRKweR6uqjzX3sLGCmy+LsUjiH5WOCJAztlSlmZEXysAlZlyzG/09gz4tpBAg==",
"license": "MIT",
"dependencies": {
"@embedpdf/engines": "1.2.1",
"@embedpdf/models": "1.2.1"
"@embedpdf/engines": "1.3.0",
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -503,13 +505,13 @@
}
},
"node_modules/@embedpdf/engines": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.2.1.tgz",
"integrity": "sha512-nhycZ7Buq2B34dcpo6n7RdFwdhwTvKzvnRy7QX+uU00Dz5vftkCG4OK+pBVzxE4y7vAu+Yb4wNpdc7HmIj3B6w==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.0.tgz",
"integrity": "sha512-6WbYwxtCCjOazEMGKbhKRkos6S1VkzI4R2u6dUuIsUw9G2HLP4bwJCBKj9A0FuMAJkKQ3VL5eVCSGfqaCaRoyQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1",
"@embedpdf/pdfium": "1.2.1"
"@embedpdf/models": "1.3.0",
"@embedpdf/pdfium": "1.3.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -519,26 +521,43 @@
}
},
"node_modules/@embedpdf/models": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.2.1.tgz",
"integrity": "sha512-FzJU51jsqihfgt50B00FEpgyym87/Dn2iGmMq4++Vu/oO6qBx/y69m4/cCAh4p4KkTJsvKNWC7T7dwSKa0FjHA==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.0.tgz",
"integrity": "sha512-LIY6T+nQoc1hi6nq1NlH6sR43J3PYOg9Bux8ouEnKjEGiZMgyd1cMxhBfrrY+Ft6DsSkqqujFOVEwjeYQYy3dg==",
"license": "MIT"
},
"node_modules/@embedpdf/pdfium": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.2.1.tgz",
"integrity": "sha512-QWf1jg7EqUlku2q6KYhlXCNfk5IAykFerPuzKJepHTeAEaRcAfu84fJgEsoUTCK4D6dfzVNp2Iuxw6Kv7MpSeg==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.0.tgz",
"integrity": "sha512-rSBFYjxwQ58L/HcqR0l5Vv4G5t+CCOKlFYrDReTZYNN7fhzKPUWbXUn4ARahZWCNmF8svHumV2P4ArakJJviuw==",
"license": "MIT"
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.2.1.tgz",
"integrity": "sha512-HhEBuDjDNMH6wu76Eo3yHwjG01U1lNZShkOsFoib/rtx8HByTgZS8iVpovaOprr6gfS04ZLqWcsN1nt5qAH90w==",
"node_modules/@embedpdf/plugin-export": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.0.tgz",
"integrity": "sha512-R6VItLmXmXbb0/4AsH1YGUZd0c64K/8kxQd0XAvgUJwcL7Z4s8bLsqRx4sVQqwVllaPEJbAdFn1CC/ymkGB+fg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/core": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.0.tgz",
"integrity": "sha512-iMG7mW+4YpNjBeSAcC5kK9VnjwmNu71HTxVtKnN73t3EBfukbMH4y7Tp2ds+4I97H6vc18RK5xuUCSesEOBgww==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -546,14 +565,15 @@
}
},
"node_modules/@embedpdf/plugin-loader": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.2.1.tgz",
"integrity": "sha512-VblKErfEiHcVao18TfCmc0UJlKAkqxE29DaLJrXQHGUw/qc+pC9HlvMVpDz3+Eb13UafYS6ZUZuEng2/fQ+JJw==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.0.tgz",
"integrity": "sha512-tkOa1UwFOimueSxxm2hRAAh64K75itDvUO6wHjb5X5s0Hx4DccfrJ7KusDhxBkeQLFXtZknPG0Q2/9T+joAqeQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/core": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -561,16 +581,17 @@
}
},
"node_modules/@embedpdf/plugin-pan": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.2.1.tgz",
"integrity": "sha512-/BTOyRl31tvnCmoLs4qNPROMRLaG34jGYNyMQquB0uPUXZjwdMloikriwos91qCOLUrhvs4SaDpC3Ghv2BO5kA==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.0.tgz",
"integrity": "sha512-tZxUpX9dvd/VDHCTqM9Yjss4M8pkJWFUA5GDNmPkExRXIASuB98wEP8fh0rQt13TEZ30rV77cEsNXngju56kjg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-interaction-manager": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"@embedpdf/core": "1.3.0",
"@embedpdf/plugin-interaction-manager": "1.3.0",
"@embedpdf/plugin-viewport": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -578,14 +599,15 @@
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.2.1.tgz",
"integrity": "sha512-iMfuVJqttJmm7Zb8oOaqNVNrC3NS57bDNNAc4MIc2f2TxIFSznvBPlwWN+PN45qNcTQiGzFc1ZMqIQDOG4qFnQ==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.0.tgz",
"integrity": "sha512-ZyxoGIIUa2HBLt1IB64EdWqBxHh01AX/1HJ7/cnoQK1h/oKXRbMAX6Mb23JCh2PGa4sGeyV3psoFMt037Eew3g==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/core": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -593,14 +615,15 @@
}
},
"node_modules/@embedpdf/plugin-rotate": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.2.1.tgz",
"integrity": "sha512-UhHds5donLDXm3i9nKrhSmo3yawVtjb6gID0MDrhj3+Lci/YQ3wDvGUhk7dNmgLcOt7G8pMa0wesnnpVWirUXA==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.0.tgz",
"integrity": "sha512-EyLLwf9VKQCsMRTe0KwGe+ZAaFqmcYS5WW/qqPBNfvSuBaybNpdI+C72IQFr41X7cYQV58OgEL3bfDb1MBPGHQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/core": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -608,15 +631,16 @@
}
},
"node_modules/@embedpdf/plugin-scroll": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.2.1.tgz",
"integrity": "sha512-I1haDXIOzs59uhOWEP6UvP5jzjcQHMLQuQbfRVJM0zdWU6t3jwSfcwPUI7iv4CAAepbuyJKL328yc8736r/FYw==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.0.tgz",
"integrity": "sha512-o1n6Mkoc92BHAkoCX0mSLXgOj4uAkokNbvP+2QMijShzTsl95gU5UzK6siZ5o6WgZBznJcceYmLuPR5ODqZDWg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"@embedpdf/core": "1.3.0",
"@embedpdf/plugin-viewport": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -624,15 +648,16 @@
}
},
"node_modules/@embedpdf/plugin-search": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.2.1.tgz",
"integrity": "sha512-sl9FBQzbOBtdmPpf6UI0bnWCTPWDkj47rTxyK07bpnGfGuFof4zhcxmMaFdyP7zqBh4Y9XqGu4A0uMTO2d/t7g==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.0.tgz",
"integrity": "sha512-DilSRfPQR38picjx7eyyuXNeduD7hcW/PjT9DZrjXxfLrAQtd17CXJs7HtJevl1wErh/CCSvZlHhjp1++O6GAg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-loader": "1.2.1",
"@embedpdf/core": "1.3.0",
"@embedpdf/plugin-loader": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -640,16 +665,17 @@
}
},
"node_modules/@embedpdf/plugin-selection": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.2.1.tgz",
"integrity": "sha512-wgG1X1sl6sed3pv7WLIO74SX0x3389/ax+/OLMty/LFbDNYMRO+n8ZQss8aUM700HARIqkPJy7UoSQt91o4nwA==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.0.tgz",
"integrity": "sha512-1PEtreNofysaLxZvgO2CSNCxXhevjYnBdu4IHTFeJKXoq3ckKwkX8fJjyyN4D6+6uXZsnFkHhewl1yKCfKWAWw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-interaction-manager": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"@embedpdf/core": "1.3.0",
"@embedpdf/plugin-interaction-manager": "1.3.0",
"@embedpdf/plugin-viewport": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -657,15 +683,16 @@
}
},
"node_modules/@embedpdf/plugin-spread": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.2.1.tgz",
"integrity": "sha512-rpadnutT1wSdBQV7RQz40zYdKgCRgmJde/tamgB8oHQypcnZGQcAG6/ZfX5j12s9pG18hKwwL+KmMoBnXD9IjQ==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.0.tgz",
"integrity": "sha512-oRLimcod8RhdknN94CQeG+0QndQeiZKIhFUCXDIGxN1Z/qvspZCUty2TC+1kc3G318nZi55pWWphq9sB7ZpqEw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-loader": "1.2.1",
"@embedpdf/core": "1.3.0",
"@embedpdf/plugin-loader": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -673,32 +700,34 @@
}
},
"node_modules/@embedpdf/plugin-thumbnail": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.2.1.tgz",
"integrity": "sha512-TjHPkK8p3+FDMLcUdb3/4VREjm+liVooufLPVZ3FCXHbiC0PeUkqnwAxpCS2Jw1n+EtkY8pefRdRJeZhO6plOQ==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.0.tgz",
"integrity": "sha512-w2wzL7m6/sUF54sMVEi8Y8+7VE3BcZqI8THDqobkEkno4Dgmb77FHNPFD6YtAhaRmIoyPnlZf05RDd6Z8ohhkQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-render": "1.2.1",
"@embedpdf/core": "1.3.0",
"@embedpdf/plugin-render": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@embedpdf/plugin-tiling": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.2.1.tgz",
"integrity": "sha512-C9uOGVIsoxUw+uQMXfJFZ8ibRLQeNOnaKC2izjx967iGu0ZoecAv+mKtH/Ge0vMEKYM1109AlF5T2EGwKQW2YA==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.0.tgz",
"integrity": "sha512-huYi4BJa9KSfqC424bEHw72KBLCR2rfApMeKnpUzAFSdWA6MSYmVBSk8ghnU7XbcLuL6fFBarNsziNrSSnVWTw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-render": "1.2.1",
"@embedpdf/plugin-scroll": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"@embedpdf/core": "1.3.0",
"@embedpdf/plugin-render": "1.3.0",
"@embedpdf/plugin-scroll": "1.3.0",
"@embedpdf/plugin-viewport": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -706,14 +735,15 @@
}
},
"node_modules/@embedpdf/plugin-viewport": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.2.1.tgz",
"integrity": "sha512-yvftOis7FLBjM3w2VYO5LXVKXoHkmFV/SPy7U6SbuLJTX126F4ohSij9euMHJjaqOgr5tBNvrf4xemVRglxM9w==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.0.tgz",
"integrity": "sha512-AZ7U8DEgEQ8nK5kdrqtukLl5au9NE3mIlFmloyo6Ddrt2rN/Jw1Lt9dsl6wU20GcFQX+hWsg9uAJboLq6AdOCA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.0"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/core": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -721,18 +751,19 @@
}
},
"node_modules/@embedpdf/plugin-zoom": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.2.1.tgz",
"integrity": "sha512-hsp/nM4C8q0FM9P6FkpQLbU8IYawUgmiYgD3HXqHWBVRk30OIaXs4N0KC9vsHwn8ZAiyLl7jhlAXpgoacH5xEQ==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.0.tgz",
"integrity": "sha512-1VA9aFxoP+BoEpwlR0//jtlD9ESS8nhU8OGGHBRu7IgoWzIx4GqOHgpgXVxzFl9IaLOv69E9DVmwe/yaC6F+0g==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1",
"@embedpdf/models": "1.3.0",
"hammerjs": "^2.0.8"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-interaction-manager": "1.2.1",
"@embedpdf/plugin-scroll": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"@embedpdf/core": "1.3.0",
"@embedpdf/plugin-interaction-manager": "1.3.0",
"@embedpdf/plugin-scroll": "1.3.0",
"@embedpdf/plugin-viewport": "1.3.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
+15 -14
View File
@@ -6,21 +6,22 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.2.1",
"@embedpdf/core": "^1.3.0",
"@embedpdf/engines": "^1.2.1",
"@embedpdf/plugin-interaction-manager": "^1.2.1",
"@embedpdf/plugin-loader": "^1.2.1",
"@embedpdf/plugin-pan": "^1.2.1",
"@embedpdf/plugin-render": "^1.2.1",
"@embedpdf/plugin-rotate": "^1.2.1",
"@embedpdf/plugin-scroll": "^1.2.1",
"@embedpdf/plugin-search": "^1.2.1",
"@embedpdf/plugin-selection": "^1.2.1",
"@embedpdf/plugin-spread": "^1.2.1",
"@embedpdf/plugin-thumbnail": "^1.2.1",
"@embedpdf/plugin-tiling": "^1.2.1",
"@embedpdf/plugin-viewport": "^1.2.1",
"@embedpdf/plugin-zoom": "^1.2.1",
"@embedpdf/plugin-export": "^1.3.0",
"@embedpdf/plugin-interaction-manager": "^1.3.0",
"@embedpdf/plugin-loader": "^1.3.0",
"@embedpdf/plugin-pan": "^1.3.0",
"@embedpdf/plugin-render": "^1.3.0",
"@embedpdf/plugin-rotate": "^1.3.0",
"@embedpdf/plugin-scroll": "^1.3.0",
"@embedpdf/plugin-search": "^1.3.0",
"@embedpdf/plugin-selection": "^1.3.0",
"@embedpdf/plugin-spread": "^1.3.0",
"@embedpdf/plugin-thumbnail": "^1.3.0",
"@embedpdf/plugin-tiling": "^1.3.0",
"@embedpdf/plugin-viewport": "^1.3.0",
"@embedpdf/plugin-zoom": "^1.3.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
File diff suppressed because it is too large Load Diff
+173 -38
View File
@@ -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,276 @@
"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": {
"title": "Detect/Split Scanned photos",
"desc": "Splits multiple photos from within a photo/PDF"
"tags": "detect,split,photos",
"title": "Detect & Split Scanned Photos",
"desc": "Detect and split scanned photos into separate pages"
},
"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": {
"title": "Fake Scan",
"scannerEffect": {
"tags": "scan,simulate,create",
"title": "Scanner Effect",
"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": {
"replaceColor": {
"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 +650,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 +717,6 @@
}
},
"split": {
"tags": "Page operations,divide,Multi Page,cut,server side",
"title": "Split PDF",
"header": "Split PDF",
"desc": {
@@ -785,7 +842,6 @@
}
},
"rotate": {
"tags": "server side",
"title": "Rotate PDF",
"submit": "Apply Rotation",
"error": {
@@ -902,7 +958,7 @@
"header": "PDF Page Organiser",
"submit": "Rearrange Pages",
"mode": {
"_value": "Mode",
"_value": "Organization mode",
"1": "Custom Page Order",
"2": "Reverse Order",
"3": "Duplex Sort",
@@ -915,6 +971,19 @@
"10": "Odd-Even Merge",
"11": "Duplicate all pages"
},
"desc": {
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for sidestitch booklet printing (optimised for binding on the side).",
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
"REMOVE_FIRST": "Remove the first page from the document.",
"REMOVE_LAST": "Remove the last page from the document.",
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
},
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
},
"addImage": {
@@ -1298,7 +1367,6 @@
}
},
"changeMetadata": {
"tags": "Title,author,date,creation,time,publisher,producer,stats",
"header": "Change Metadata",
"submit": "Change",
"filenamePrefix": "metadata",
@@ -1540,7 +1608,13 @@
"header": "Extract Images",
"selectText": "Select image format to convert extracted images to",
"allowDuplicates": "Save duplicate images",
"submit": "Extract"
"submit": "Extract",
"settings": {
"title": "Settings"
},
"error": {
"failed": "An error occurred while extracting images from the PDF."
}
},
"pdfToPDFA": {
"tags": "archive,long-term,standard,conversion,storage,preservation",
@@ -1600,20 +1674,49 @@
"tags": "separate,auto-detect,scans,multi-photo,organize",
"selectText": {
"1": "Angle Threshold:",
"2": "Sets the minimum absolute angle required for the image to be rotated (default: 10).",
"2": "Tilt (in degrees) needed before we auto-straighten a photo.",
"3": "Tolerance:",
"4": "Determines the range of colour variation around the estimated background colour (default: 30).",
"4": "How closely a colour must match the page background to count as background. Higher = looser, lower = stricter.",
"5": "Minimum Area:",
"6": "Sets the minimum area threshold for a photo (default: 10000).",
"6": "Smallest photo size (in pixels²) we'll keep to avoid tiny fragments.",
"7": "Minimum Contour Area:",
"8": "Sets the minimum contour area threshold for a photo",
"8": "Smallest edge/shape we consider when finding photos (filters dust and specks).",
"9": "Border Size:",
"10": "Sets the size of the border added and removed to prevent white borders in the output (default: 1)."
"10": "Extra padding (in pixels) around each saved photo so edges aren't cut."
},
"info": "Python is not installed. It is required to run."
},
"scannerImageSplit": {
"title": "Extracted Images",
"submit": "Extract Image Scans",
"error": {
"failed": "An error occurred while extracting image scans."
},
"tooltip": {
"title": "Photo Splitter",
"whatThisDoes": "What this does",
"whatThisDoesDesc": "Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.",
"whenToUse": "When to use",
"useCase1": "Scan whole album pages in one go",
"useCase2": "Split flatbed batches into separate files",
"useCase3": "Break collages into individual photos",
"useCase4": "Pull photos from documents",
"quickFixes": "Quick fixes",
"problem1": "Photos not detected → increase Tolerance to 30-50",
"problem2": "Too many false detections → increase Minimum Area to 15,000-20,000",
"problem3": "Crops are too tight → increase Border Size to 5-10",
"problem4": "Tilted photos not straightened → lower Angle Threshold to ~5°",
"problem5": "Dust/noise boxes → increase Minimum Contour Area to 1000-2000",
"setupTips": "Setup tips",
"tip1": "Use a plain, light background",
"tip2": "Leave a small gap (≈1 cm) between photos",
"tip3": "Scan at 300-600 DPI",
"tip4": "Clean the scanner glass",
"headsUp": "Heads-up",
"headsUpDesc": "Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy-try a lighter or darker background and leave more space."
}
},
"sign": {
"tags": "authorize,initials,drawn-signature,text-sign,image-signature",
"title": "Sign",
"header": "Sign PDFs",
"upload": "Upload Image",
@@ -1637,7 +1740,6 @@
"redo": "Redo"
},
"flatten": {
"tags": "static,deactivate,non-interactive,streamline",
"title": "Flatten",
"header": "Flatten PDF",
"flattenOnlyForms": "Flatten only forms",
@@ -1702,7 +1804,6 @@
}
},
"removeBlanks": {
"tags": "cleanup,streamline,non-content,organize",
"title": "Remove Blanks",
"header": "Remove Blank Pages",
"settings": {
@@ -2099,7 +2200,6 @@
"tags": "color-correction,tune,modify,enhance,colour-correction"
},
"crop": {
"tags": "trim,shrink,edit,shape",
"title": "Crop",
"header": "Crop PDF",
"submit": "Apply Crop",
@@ -2451,25 +2551,48 @@
},
"selectCustomCert": "Custom Certificate File X.509 (Optional)"
},
"replace-color": {
"title": "Advanced Colour options",
"header": "Replace-Invert Colour PDF",
"selectText": {
"1": "Replace or Invert colour Options",
"2": "Default(Default high contrast colours)",
"3": "Custom(Customised colours)",
"4": "Full-Invert(Invert all colours)",
"5": "High contrast colour options",
"6": "white text on black background",
"7": "Black text on white background",
"8": "Yellow text on black background",
"9": "Green text on black background",
"10": "Choose text Colour",
"11": "Choose background Colour"
"replaceColor": {
"labels": {
"settings": "Settings",
"colourOperation": "Colour operation"
},
"submit": "Replace"
"options": {
"highContrast": "High contrast",
"invertAll": "Invert all colours",
"custom": "Custom"
},
"tooltip": {
"header": {
"title": "Replace & Invert Colour Settings Overview"
},
"description": {
"title": "Description",
"text": "Transform PDF colours to improve readability and accessibility. Choose from high contrast presets, invert all colours, or create custom colour schemes."
},
"highContrast": {
"title": "High Contrast",
"text": "Apply predefined high contrast colour combinations designed for better readability and accessibility compliance.",
"bullet1": "White text on black background - Classic dark mode",
"bullet2": "Black text on white background - Standard high contrast",
"bullet3": "Yellow text on black background - High visibility option",
"bullet4": "Green text on black background - Alternative high contrast"
},
"invertAll": {
"title": "Invert All Colours",
"text": "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
},
"custom": {
"title": "Custom Colours",
"text": "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements.",
"bullet1": "Text colour - Choose the colour for text elements",
"bullet2": "Background colour - Set the background colour for the document"
}
},
"error": {
"failed": "An error occurred while processing the colour replacement."
}
},
"replaceColorPdf": {
"replaceColor": {
"tags": "Replace Colour,Page operations,Back end,server side"
},
"login": {
@@ -3302,6 +3425,18 @@
"generateError": "We couldn't generate your API key."
}
},
"AddAttachmentsRequest": {
"attachments": "Select Attachments",
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
"selectFiles": "Select Files to Attach",
"placeholder": "Choose files...",
"addMoreFiles": "Add more files...",
"selectedFiles": "Selected Files",
"submit": "Add Attachments",
"results": {
"title": "Attachment Results"
}
},
"termsAndConditions": "Terms & Conditions",
"logOut": "Log out"
}
+115 -9
View File
@@ -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"
}
@@ -704,7 +769,7 @@
"header": "PDF Page Organizer",
"submit": "Rearrange Pages",
"mode": {
"_value": "Mode",
"_value": "Organization mode",
"1": "Custom Page Order",
"2": "Reverse Order",
"3": "Duplex Sort",
@@ -717,6 +782,19 @@
"10": "Odd-Even Merge",
"11": "Duplicate all pages"
},
"desc": {
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for sidestitch booklet printing (optimized for binding on the side).",
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
"REMOVE_FIRST": "Remove the first page from the document.",
"REMOVE_LAST": "Remove the last page from the document.",
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
},
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
},
"addImage": {
@@ -991,7 +1069,13 @@
"header": "Extract Images",
"selectText": "Select image format to convert extracted images to",
"allowDuplicates": "Save duplicate images",
"submit": "Extract"
"submit": "Extract",
"settings": {
"title": "Settings"
},
"error": {
"failed": "An error occurred while extracting images from the PDF."
}
},
"pdfToPDFA": {
"tags": "archive,long-term,standard,conversion,storage,preservation",
@@ -1064,7 +1148,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",
@@ -1740,10 +1823,16 @@
}
},
"removeImage": {
"title": "Remove image",
"header": "Remove image",
"removeImage": "Remove image",
"submit": "Remove image"
"title": "Remove Images",
"header": "Remove Images",
"removeImage": "Remove Images",
"submit": "Remove Images",
"results": {
"title": "Remove Images Results"
},
"error": {
"failed": "Failed to remove images from the PDF."
}
},
"splitByChapters": {
"title": "Split PDF by Chapters",
@@ -1788,7 +1877,7 @@
"title": "How we use Cookies",
"description": {
"1": "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.",
"2": "If youd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
"2": "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
},
"acceptAllBtn": "Okay",
"acceptNecessaryBtn": "No Thanks",
@@ -1812,7 +1901,7 @@
"1": "Strictly Necessary Cookies",
"2": "Always Enabled"
},
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they cant be turned off."
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off."
},
"analytics": {
"title": "Analytics",
@@ -2292,5 +2381,22 @@
},
"automate": {
"copyToSaved": "Copy to Saved"
},
"AddAttachmentsRequest": {
"attachments": "Select Attachments",
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
"selectFiles": "Select Files to Attach",
"placeholder": "Choose files...",
"addMoreFiles": "Add more files...",
"selectedFiles": "Selected Files",
"submit": "Add Attachments",
"results": {
"title": "Attachment Results"
}
},
"addAttachments": {
"error": {
"failed": "An error occurred while adding attachments to the PDF."
}
}
}
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>
+17 -4
View File
@@ -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();
@@ -65,11 +66,16 @@ export default function RightRail() {
const { totalItems, selectedCount } = getSelectionState();
// Get export state for viewer mode
const exportState = viewerContext?.getExportState?.();
const handleSelectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
// 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;
}
@@ -82,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') {
@@ -91,7 +99,10 @@ export default function RightRail() {
}, [currentView, setSelectedFiles, pageEditorFunctions]);
const handleExportAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
if (currentView === 'viewer') {
// Use EmbedPDF export functionality for viewer mode
viewerContext?.exportActions?.download();
} else if (currentView === 'fileEditor') {
// Download selected files (or all if none selected)
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
@@ -108,7 +119,7 @@ export default function RightRail() {
// Export all pages (not just selected)
pageEditorFunctions?.onExportAll?.();
}
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext]);
const handleCloseSelected = useCallback(() => {
if (currentView !== 'fileEditor') return;
@@ -440,7 +451,9 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={handleExportAll}
disabled={currentView === 'viewer' || totalItems === 0}
disabled={
currentView === 'viewer' ? !exportState?.canExport : totalItems === 0
}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -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>
))}
</>
);
}
+61
View File
@@ -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();
}
+50
View File
@@ -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;
}
+33 -12
View File
@@ -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' ? (
+1 -1
View File
@@ -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;
@@ -0,0 +1,46 @@
import { useTranslation } from 'react-i18next';
import { Stack, Select, Checkbox } from '@mantine/core';
import { ExtractImagesParameters } from '../../../hooks/tools/extractImages/useExtractImagesParameters';
interface ExtractImagesSettingsProps {
parameters: ExtractImagesParameters;
onParameterChange: <K extends keyof ExtractImagesParameters>(key: K, value: ExtractImagesParameters[K]) => void;
disabled?: boolean;
}
const ExtractImagesSettings = ({
parameters,
onParameterChange,
disabled = false
}: ExtractImagesSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Select
label={t('extractImages.selectText', 'Output Format')}
value={parameters.format}
onChange={(value) => {
const allowedFormats = ['png', 'jpg', 'gif'] as const;
const format = allowedFormats.includes(value as any) ? (value as typeof allowedFormats[number]) : 'png';
onParameterChange('format', format);
}}
data={[
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPG' },
{ value: 'gif', label: 'GIF' },
]}
disabled={disabled}
/>
<Checkbox
label={t('extractImages.allowDuplicates', 'Allow Duplicate Images')}
checked={parameters.allowDuplicates}
onChange={(event) => onParameterChange('allowDuplicates', event.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
};
export default ExtractImagesSettings;
@@ -0,0 +1,65 @@
import React from 'react';
import { Divider, Select, Stack, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ReorganizePagesParameters } from '../../../hooks/tools/reorganizePages/useReorganizePagesParameters';
import { getReorganizePagesModeData } from './constants';
export default function ReorganizePagesSettings({
parameters,
onParameterChange,
disabled,
}: {
parameters: ReorganizePagesParameters;
onParameterChange: <K extends keyof ReorganizePagesParameters>(
key: K,
value: ReorganizePagesParameters[K]
) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const modeData = getReorganizePagesModeData(t);
const requiresOrder = parameters.customMode === '' || parameters.customMode === 'DUPLICATE';
const selectedMode = modeData.find(mode => mode.value === parameters.customMode) || modeData[0];
return (
<Stack gap="sm">
<Select
label={t('pdfOrganiser.mode._value', 'Organization mode')}
data={modeData}
value={parameters.customMode}
onChange={(v) => onParameterChange('customMode', v ?? '')}
disabled={disabled}
/>
{selectedMode && (
<div
style={{
backgroundColor: 'var(--information-text-bg)',
color: 'var(--information-text-color)',
padding: '8px 12px',
borderRadius: '8px',
marginTop: '4px',
fontSize: '0.75rem',
textAlign: 'center'
}}
>
{selectedMode.description}
</div>
)}
{requiresOrder && (
<>
<Divider/>
<TextInput
label={t('pageOrderPrompt', 'Page order / ranges')}
placeholder={t('pdfOrganiser.placeholder', 'e.g. 1,3,2,4-6')}
value={parameters.pageNumbers}
onChange={(e) => onParameterChange('pageNumbers', e.currentTarget.value)}
disabled={disabled}
/>
</>
)}
</Stack>
);
}
@@ -0,0 +1,59 @@
import { TFunction } from 'i18next';
export const getReorganizePagesModeData = (t: TFunction) => [
{
value: '',
label: t('pdfOrganiser.mode.1', 'Custom Page Order'),
description: t('pdfOrganiser.mode.desc.CUSTOM', 'Use a custom sequence of page numbers or expressions to define a new order.')
},
{
value: 'REVERSE_ORDER',
label: t('pdfOrganiser.mode.2', 'Reverse Order'),
description: t('pdfOrganiser.mode.desc.REVERSE_ORDER', 'Flip the document so the last page becomes first and so on.')
},
{
value: 'DUPLEX_SORT',
label: t('pdfOrganiser.mode.3', 'Duplex Sort'),
description: t('pdfOrganiser.mode.desc.DUPLEX_SORT', 'Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).')
},
{
value: 'BOOKLET_SORT',
label: t('pdfOrganiser.mode.4', 'Booklet Sort'),
description: t('pdfOrganiser.mode.desc.BOOKLET_SORT', 'Arrange pages for booklet printing (last, first, second, second last, …).')
},
{
value: 'SIDE_STITCH_BOOKLET_SORT',
label: t('pdfOrganiser.mode.5', 'Side Stitch Booklet Sort'),
description: t('pdfOrganiser.mode.desc.SIDE_STITCH_BOOKLET_SORT', 'Arrange pages for sidestitch booklet printing (optimized for binding on the side).')
},
{
value: 'ODD_EVEN_SPLIT',
label: t('pdfOrganiser.mode.6', 'Odd-Even Split'),
description: t('pdfOrganiser.mode.desc.ODD_EVEN_SPLIT', 'Split the document into two outputs: all odd pages and all even pages.')
},
{
value: 'ODD_EVEN_MERGE',
label: t('pdfOrganiser.mode.10', 'Odd-Even Merge'),
description: t('pdfOrganiser.mode.desc.ODD_EVEN_MERGE', 'Merge two PDFs by alternating pages: odd from the first, even from the second.')
},
{
value: 'DUPLICATE',
label: t('pdfOrganiser.mode.11', 'Duplicate all pages'),
description: t('pdfOrganiser.mode.desc.DUPLICATE', 'Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).')
},
{
value: 'REMOVE_FIRST',
label: t('pdfOrganiser.mode.7', 'Remove First'),
description: t('pdfOrganiser.mode.desc.REMOVE_FIRST', 'Remove the first page from the document.')
},
{
value: 'REMOVE_LAST',
label: t('pdfOrganiser.mode.8', 'Remove Last'),
description: t('pdfOrganiser.mode.desc.REMOVE_LAST', 'Remove the last page from the document.')
},
{
value: 'REMOVE_FIRST_AND_LAST',
label: t('pdfOrganiser.mode.9', 'Remove First and Last'),
description: t('pdfOrganiser.mode.desc.REMOVE_FIRST_AND_LAST', 'Remove both the first and last pages from the document.')
},
];
@@ -0,0 +1,108 @@
import React from "react";
import { Stack, Text, Select, ColorInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ReplaceColorParameters } from "../../../hooks/tools/replaceColor/useReplaceColorParameters";
interface ReplaceColorSettingsProps {
parameters: ReplaceColorParameters;
onParameterChange: <K extends keyof ReplaceColorParameters>(key: K, value: ReplaceColorParameters[K]) => void;
disabled?: boolean;
}
const ReplaceColorSettings = ({ parameters, onParameterChange, disabled = false }: ReplaceColorSettingsProps) => {
const { t } = useTranslation();
const replaceAndInvertOptions = [
{
value: 'HIGH_CONTRAST_COLOR',
label: t('replaceColor.options.highContrast', 'High contrast')
},
{
value: 'FULL_INVERSION',
label: t('replaceColor.options.invertAll', 'Invert all colours')
},
{
value: 'CUSTOM_COLOR',
label: t('replaceColor.options.custom', 'Custom')
}
];
const highContrastOptions = [
{
value: 'WHITE_TEXT_ON_BLACK',
label: t('replace-color.selectText.6', 'White text on black background')
},
{
value: 'BLACK_TEXT_ON_WHITE',
label: t('replace-color.selectText.7', 'Black text on white background')
},
{
value: 'YELLOW_TEXT_ON_BLACK',
label: t('replace-color.selectText.8', 'Yellow text on black background')
},
{
value: 'GREEN_TEXT_ON_BLACK',
label: t('replace-color.selectText.9', 'Green text on black background')
}
];
return (
<Stack gap="md">
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('replaceColor.labels.colourOperation', 'Colour operation')}
</Text>
<Select
value={parameters.replaceAndInvertOption}
onChange={(value) => value && onParameterChange('replaceAndInvertOption', value as ReplaceColorParameters['replaceAndInvertOption'])}
data={replaceAndInvertOptions}
disabled={disabled}
/>
</Stack>
{parameters.replaceAndInvertOption === 'HIGH_CONTRAST_COLOR' && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('replace-color.selectText.5', 'High contrast color options')}
</Text>
<Select
value={parameters.highContrastColorCombination}
onChange={(value) => value && onParameterChange('highContrastColorCombination', value as ReplaceColorParameters['highContrastColorCombination'])}
data={highContrastOptions}
disabled={disabled}
/>
</Stack>
)}
{parameters.replaceAndInvertOption === 'CUSTOM_COLOR' && (
<>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('replace-color.selectText.10', 'Choose text Color')}
</Text>
<ColorInput
value={parameters.textColor}
onChange={(value) => onParameterChange('textColor', value)}
format="hex"
disabled={disabled}
/>
</Stack>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('replace-color.selectText.11', 'Choose background Color')}
</Text>
<ColorInput
value={parameters.backGroundColor}
onChange={(value) => onParameterChange('backGroundColor', value)}
format="hex"
disabled={disabled}
/>
</Stack>
</>
)}
</Stack>
);
};
export default ReplaceColorSettings;
@@ -0,0 +1,74 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NumberInput, Stack } from '@mantine/core';
import { ScannerImageSplitParameters } from '../../../hooks/tools/scannerImageSplit/useScannerImageSplitParameters';
interface ScannerImageSplitSettingsProps {
parameters: ScannerImageSplitParameters;
onParameterChange: <K extends keyof ScannerImageSplitParameters>(key: K, value: ScannerImageSplitParameters[K]) => void;
disabled?: boolean;
}
const ScannerImageSplitSettings: React.FC<ScannerImageSplitSettingsProps> = ({
parameters,
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<NumberInput
label={t('ScannerImageSplit.selectText.1', 'Angle Threshold:')}
description={t('ScannerImageSplit.selectText.2', 'Sets the minimum absolute angle required for the image to be rotated (default: 10).')}
value={parameters.angle_threshold}
onChange={(value) => onParameterChange('angle_threshold', Number(value) || 10)}
min={0}
step={1}
disabled={disabled}
/>
<NumberInput
label={t('ScannerImageSplit.selectText.3', 'Tolerance:')}
description={t('ScannerImageSplit.selectText.4', 'Determines the range of colour variation around the estimated background colour (default: 30).')}
value={parameters.tolerance}
onChange={(value) => onParameterChange('tolerance', Number(value) || 30)}
min={0}
step={1}
disabled={disabled}
/>
<NumberInput
label={t('ScannerImageSplit.selectText.5', 'Minimum Area:')}
description={t('ScannerImageSplit.selectText.6', 'Sets the minimum area threshold for a photo (default: 10000).')}
value={parameters.min_area}
onChange={(value) => onParameterChange('min_area', Number(value) || 10000)}
min={0}
step={100}
disabled={disabled}
/>
<NumberInput
label={t('ScannerImageSplit.selectText.7', 'Minimum Contour Area:')}
description={t('ScannerImageSplit.selectText.8', 'Sets the minimum contour area threshold for a photo.')}
value={parameters.min_contour_area}
onChange={(value) => onParameterChange('min_contour_area', Number(value) || 500)}
min={0}
step={10}
disabled={disabled}
/>
<NumberInput
label={t('ScannerImageSplit.selectText.9', 'Border Size:')}
description={t('ScannerImageSplit.selectText.10', 'Sets the size of the border added and removed to prevent white borders in the output (default: 1).')}
value={parameters.border_size}
onChange={(value) => onParameterChange('border_size', Number(value) || 1)}
min={0}
step={1}
disabled={disabled}
/>
</Stack>
);
};
export default ScannerImageSplitSettings;
@@ -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) => {
@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useReplaceColorTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("replaceColor.tooltip.header.title", "Replace & Invert Colour Settings Overview")
},
tips: [
{
title: t("replaceColor.tooltip.description.title", "Description"),
description: t("replaceColor.tooltip.description.text", "Transform PDF colours to improve readability and accessibility. Choose from high contrast presets, invert all colours, or create custom colour schemes.")
},
{
title: t("replaceColor.tooltip.highContrast.title", "High Contrast"),
description: t("replaceColor.tooltip.highContrast.text", "Apply predefined high contrast colour combinations designed for better readability and accessibility compliance."),
bullets: [
t("replaceColor.tooltip.highContrast.bullet1", "White text on black background - Classic dark mode"),
t("replaceColor.tooltip.highContrast.bullet2", "Black text on white background - Standard high contrast"),
t("replaceColor.tooltip.highContrast.bullet3", "Yellow text on black background - High visibility option"),
t("replaceColor.tooltip.highContrast.bullet4", "Green text on black background - Alternative high contrast")
]
},
{
title: t("replaceColor.tooltip.invertAll.title", "Invert All Colours"),
description: t("replaceColor.tooltip.invertAll.text", "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions.")
},
{
title: t("replaceColor.tooltip.custom.title", "Custom Colours"),
description: t("replaceColor.tooltip.custom.text", "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements."),
bullets: [
t("replaceColor.tooltip.custom.bullet1", "Text colour - Choose the colour for text elements"),
t("replaceColor.tooltip.custom.bullet2", "Background colour - Set the background colour for the document")
]
}
]
};
};
@@ -0,0 +1,54 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useScannerImageSplitTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t('scannerImageSplit.tooltip.title', 'Photo Splitter')
},
tips: [
{
title: t('scannerImageSplit.tooltip.whatThisDoes', 'What this does'),
description: t('scannerImageSplit.tooltip.whatThisDoesDesc',
'Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.'
)
},
{
title: t('scannerImageSplit.tooltip.whenToUse', 'When to use'),
bullets: [
t('scannerImageSplit.tooltip.useCase1', 'Scan whole album pages in one go'),
t('scannerImageSplit.tooltip.useCase2', 'Split flatbed batches into separate files'),
t('scannerImageSplit.tooltip.useCase3', 'Break collages into individual photos'),
t('scannerImageSplit.tooltip.useCase4', 'Pull photos from documents')
]
},
{
title: t('scannerImageSplit.tooltip.quickFixes', 'Quick fixes'),
bullets: [
t('scannerImageSplit.tooltip.problem1', 'Photos not detected → increase Tolerance to 3050'),
t('scannerImageSplit.tooltip.problem2', 'Too many false detections → increase Minimum Area to 15,00020,000'),
t('scannerImageSplit.tooltip.problem3', 'Crops are too tight → increase Border Size to 510'),
t('scannerImageSplit.tooltip.problem4', 'Tilted photos not straightened → lower Angle Threshold to ~5°'),
t('scannerImageSplit.tooltip.problem5', 'Dust/noise boxes → increase Minimum Contour Area to 10002000')
]
},
{
title: t('scannerImageSplit.tooltip.setupTips', 'Setup tips'),
bullets: [
t('scannerImageSplit.tooltip.tip1', 'Use a plain, light background'),
t('scannerImageSplit.tooltip.tip2', 'Leave a small gap (≈1 cm) between photos'),
t('scannerImageSplit.tooltip.tip3', 'Scan at 300600 DPI'),
t('scannerImageSplit.tooltip.tip4', 'Clean the scanner glass')
]
},
{
title: t('scannerImageSplit.tooltip.headsUp', 'Heads-up'),
description: t('scannerImageSplit.tooltip.headsUpDesc',
'Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy—try a lighter or darker background and leave more space.'
)
}
]
};
};
@@ -0,0 +1,25 @@
import { useEffect } from 'react';
import { useExportCapability } from '@embedpdf/plugin-export/react';
import { useViewer } from '../../contexts/ViewerContext';
/**
* Component that runs inside EmbedPDF context and provides export functionality
*/
export function ExportAPIBridge() {
const { provides: exportApi } = useExportCapability();
const { registerBridge } = useViewer();
useEffect(() => {
if (exportApi) {
// Register this bridge with ViewerContext
registerBridge('export', {
state: {
canExport: true,
},
api: exportApi
});
}
}, [exportApi, registerBridge]);
return null;
}
@@ -17,6 +17,7 @@ import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react';
import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
import { Rotation } from '@embedpdf/models';
import { CustomSearchLayer } from './CustomSearchLayer';
import { ZoomAPIBridge } from './ZoomAPIBridge';
@@ -29,6 +30,7 @@ import { SpreadAPIBridge } from './SpreadAPIBridge';
import { SearchAPIBridge } from './SearchAPIBridge';
import { ThumbnailAPIBridge } from './ThumbnailAPIBridge';
import { RotateAPIBridge } from './RotateAPIBridge';
import { ExportAPIBridge } from './ExportAPIBridge';
interface LocalEmbedPDFProps {
file?: File | Blob;
@@ -112,6 +114,11 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
createPluginRegistration(RotatePluginPackage, {
defaultRotation: Rotation.Degree0, // Start with no rotation
}),
// Register export plugin for downloading PDFs
createPluginRegistration(ExportPluginPackage, {
defaultFileName: 'document.pdf',
}),
];
}, [pdfUrl]);
@@ -170,6 +177,7 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
<SearchAPIBridge />
<ThumbnailAPIBridge />
<RotateAPIBridge />
<ExportAPIBridge />
<GlobalPointerProvider>
<Viewport
style={{
@@ -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(() =>
+47
View File
@@ -51,6 +51,11 @@ interface ThumbnailAPIWrapper {
renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise<Blob> };
}
interface ExportAPIWrapper {
download: () => void;
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
}
// State interfaces - represent the shape of data from each bridge
interface ScrollState {
@@ -93,6 +98,10 @@ interface SearchState {
activeIndex: number;
}
interface ExportState {
canExport: boolean;
}
// Bridge registration interface - bridges register with state and API
interface BridgeRef<TState = unknown, TApi = unknown> {
state: TState;
@@ -122,6 +131,7 @@ interface ViewerContextType {
getRotationState: () => RotationState;
getSearchState: () => SearchState;
getThumbnailAPI: () => ThumbnailAPIWrapper | null;
getExportState: () => ExportState;
// Immediate update callbacks
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
@@ -179,6 +189,11 @@ interface ViewerContextType {
clear: () => void;
};
exportActions: {
download: () => void;
saveAsCopy: () => Promise<ArrayBuffer | null>;
};
// Bridge registration - internal use by bridges
registerBridge: (type: string, ref: BridgeRef) => void;
}
@@ -203,6 +218,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
spread: null as BridgeRef<SpreadState, SpreadAPIWrapper> | null,
rotation: null as BridgeRef<RotationState, RotationAPIWrapper> | null,
thumbnail: null as BridgeRef<unknown, ThumbnailAPIWrapper> | null,
export: null as BridgeRef<ExportState, ExportAPIWrapper> | null,
});
// Immediate zoom callback for responsive display updates
@@ -238,6 +254,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
case 'thumbnail':
bridgeRefs.current.thumbnail = ref as BridgeRef<unknown, ThumbnailAPIWrapper>;
break;
case 'export':
bridgeRefs.current.export = ref as BridgeRef<ExportState, ExportAPIWrapper>;
break;
}
};
@@ -278,6 +297,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
return bridgeRefs.current.thumbnail?.api || null;
};
const getExportState = (): ExportState => {
return bridgeRefs.current.export?.state || { canExport: false };
};
// Action handlers - call APIs directly
const scrollActions = {
scrollToPage: (page: number) => {
@@ -473,6 +496,28 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
}
};
const exportActions = {
download: () => {
const api = bridgeRefs.current.export?.api;
if (api?.download) {
api.download();
}
},
saveAsCopy: async () => {
const api = bridgeRefs.current.export?.api;
if (api?.saveAsCopy) {
try {
const result = api.saveAsCopy();
return await result.toPromise();
} catch (error) {
console.error('Failed to save PDF copy:', error);
return null;
}
}
return null;
}
};
const registerImmediateZoomUpdate = (callback: (percent: number) => void) => {
immediateZoomUpdateCallback.current = callback;
};
@@ -507,6 +552,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
getRotationState,
getSearchState,
getThumbnailAPI,
getExportState,
// Immediate updates
registerImmediateZoomUpdate,
@@ -522,6 +568,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
spreadActions,
rotationActions,
searchActions,
exportActions,
// Bridge registration
registerBridge,
+26 -1
View File
@@ -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);
+4 -1
View File
@@ -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' })
});
+2
View File
@@ -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>;
+104 -47
View File
@@ -10,16 +10,21 @@ import AddPassword from "../tools/AddPassword";
import ChangePermissions from "../tools/ChangePermissions";
import RemoveBlanks from "../tools/RemoveBlanks";
import RemovePages from "../tools/RemovePages";
import ReorganizePages from "../tools/ReorganizePages";
import { reorganizePagesOperationConfig } from "../hooks/tools/reorganizePages/useReorganizePagesOperation";
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 AddAttachments from "../tools/AddAttachments";
import Merge from '../tools/Merge';
import Repair from "../tools/Repair";
import AutoRename from "../tools/AutoRename";
import SingleLargePage from "../tools/SingleLargePage";
import UnlockPdfForms from "../tools/UnlockPdfForms";
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
import RemoveImage from "../tools/RemoveImage";
import CertSign from "../tools/CertSign";
import BookletImposition from "../tools/BookletImposition";
import Flatten from "../tools/Flatten";
@@ -34,6 +39,7 @@ import { sanitizeOperationConfig } from "../hooks/tools/sanitize/useSanitizeOper
import { repairOperationConfig } from "../hooks/tools/repair/useRepairOperation";
import { addWatermarkOperationConfig } from "../hooks/tools/addWatermark/useAddWatermarkOperation";
import { addStampOperationConfig } from "../components/tools/addStamp/useAddStampOperation";
import { addAttachmentsOperationConfig } from "../hooks/tools/addAttachments/useAddAttachmentsOperation";
import { unlockPdfFormsOperationConfig } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
import { singleLargePageOperationConfig } from "../hooks/tools/singleLargePage/useSingleLargePageOperation";
import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
@@ -49,6 +55,8 @@ import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation"
import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation";
import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
import { cropOperationConfig } from "../hooks/tools/crop/useCropOperation";
import { extractImagesOperationConfig } from "../hooks/tools/extractImages/useExtractImagesOperation";
import { replaceColorOperationConfig } from "../hooks/tools/replaceColor/useReplaceColorOperation";
import CompressSettings from "../components/tools/compress/CompressSettings";
import SplitSettings from "../components/tools/split/SplitSettings";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
@@ -67,12 +75,19 @@ import RedactSingleStepSettings from "../components/tools/redact/RedactSingleSte
import RotateSettings from "../components/tools/rotate/RotateSettings";
import Redact from "../tools/Redact";
import AdjustPageScale from "../tools/AdjustPageScale";
import ReplaceColor from "../tools/ReplaceColor";
import ScannerImageSplit from "../tools/ScannerImageSplit";
import { ToolId } from "../types/toolId";
import MergeSettings from '../components/tools/merge/MergeSettings';
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import { scannerImageSplitOperationConfig } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
import CropSettings from "../components/tools/crop/CropSettings";
import ExtractImages from "../tools/ExtractImages";
import ExtractImagesSettings from "../components/tools/extractImages/ExtractImagesSettings";
import ReplaceColorSettings from "../components/tools/replaceColor/ReplaceColorSettings";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@@ -172,6 +187,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 +200,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 +216,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 +229,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 +238,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 +254,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 +267,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,17 +280,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["unlock-pdf-forms"],
operationConfig: unlockPdfFormsOperationConfig,
settingsComponent: UnlockPdfFormsSettings,
},
manageCertificates: {
icon: <LocalIcon icon="license-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.manageCertificates.title", "Manage Certificates"),
component: null,
description: t(
"home.manageCertificates.desc",
"Import, export, or delete digital certificate files used for signing PDFs."
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
synonyms: getSynonyms(t, "unlockPDFForms"),
},
changePermissions: {
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
@@ -281,6 +293,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 +302,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 +311,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 +327,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 +340,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["update-metadata"],
operationConfig: changeMetadataOperationConfig,
settingsComponent: ChangeMetadataSingleStep,
synonyms: getSynonyms(t, "changeMetadata")
},
// Page Formatting
@@ -350,6 +367,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,18 +378,21 @@ 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" />,
name: t("home.reorganizePages.title", "Reorganize Pages"),
component: null,
workbench: "pageEditor",
component: ReorganizePages,
description: t(
"home.reorganizePages.desc",
"Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
endpoints: ["rearrange-pages"],
operationConfig: reorganizePagesOperationConfig,
synonyms: getSynonyms(t, "reorganizePages")
},
scalePages: {
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
@@ -384,6 +405,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 +415,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 +425,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,15 +450,19 @@ 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" />,
name: t("home.addAttachments.title", "Add Attachments"),
component: null,
component: AddAttachments,
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"),
maxFiles: 1,
endpoints: ["add-attachments"],
operationConfig: addAttachmentsOperationConfig,
},
// Extraction
@@ -446,14 +474,20 @@ 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" />,
icon: <LocalIcon icon="photo-library-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.extractImages.title", "Extract Images"),
component: null,
component: ExtractImages,
description: t("home.extractImages.desc", "Extract images from PDF documents"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.EXTRACTION,
maxFiles: -1,
endpoints: ["extract-images"],
operationConfig: extractImagesOperationConfig,
settingsComponent: ExtractImagesSettings,
synonyms: getSynonyms(t, "extractImages")
},
// Removal
@@ -467,6 +501,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 +512,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,14 +521,19 @@ 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" />,
name: t("home.removeImage.title", "Remove Image"),
component: null,
description: t("home.removeImage.desc", "Remove images from PDF documents"),
name: t("home.removeImage.title", "Remove Images"),
component: RemoveImage,
description: t("home.removeImage.desc", "Remove all images from a PDF document"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
endpoints: ["remove-image-pdf"],
operationConfig: undefined,
synonyms: getSynonyms(t, "removeImage"),
},
removePassword: {
icon: <LocalIcon icon="lock-open-right-outline-rounded" width="1.5rem" height="1.5rem" />,
@@ -505,6 +546,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 +558,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["remove-certificate-sign"],
operationConfig: removeCertificateSignOperationConfig,
synonyms: getSynonyms(t, "removeCertSign"),
},
// Automation
@@ -533,6 +576,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,22 +588,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,
},
autoSplitPDF: {
icon: <LocalIcon icon="split-scene-right-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.autoSplitPDF.title", "Auto Split Pages"),
component: null,
description: t("home.autoSplitPDF.desc", "Automatically split PDF pages based on content detection"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION,
},
autoSizeSplitPDF: {
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.autoSizeSplitPDF.title", "Auto Split by Size/Count"),
component: null,
description: t("home.autoSizeSplitPDF.desc", "Automatically split PDFs by file size or page count"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION,
synonyms: getSynonyms(t, "autoRename"),
},
// Advanced Formatting
@@ -571,6 +600,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,14 +613,20 @@ 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" />,
name: t("home.scannerImageSplit.title", "Detect & Split Scanned Photos"),
component: null,
component: ScannerImageSplit,
description: t("home.scannerImageSplit.desc", "Detect and split scanned photos into separate pages"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
maxFiles: -1,
endpoints: ["extract-image-scans"],
operationConfig: scannerImageSplitOperationConfig,
settingsComponent: ScannerImageSplitSettings,
synonyms: getSynonyms(t, "ScannerImageSplit"),
},
overlayPdfs: {
icon: <LocalIcon icon="layers-rounded" width="1.5rem" height="1.5rem" />,
@@ -599,14 +635,20 @@ 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: {
replaceColor: {
icon: <LocalIcon icon="format-color-fill-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.replaceColorPdf.title", "Replace & Invert Color"),
component: null,
description: t("home.replaceColorPdf.desc", "Replace or invert colors in PDF documents"),
name: t("home.replaceColor.title", "Replace & Invert Color"),
component: ReplaceColor,
description: t("home.replaceColor.desc", "Replace or invert colors in PDF documents"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
maxFiles: -1,
endpoints: ["replace-invert-pdf"],
operationConfig: replaceColorOperationConfig,
settingsComponent: ReplaceColorSettings,
synonyms: getSynonyms(t, "replaceColor"),
},
addImage: {
icon: <LocalIcon icon="image-rounded" width="1.5rem" height="1.5rem" />,
@@ -615,6 +657,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,14 +666,16 @@ 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: {
scannerEffect: {
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.fakeScan.title", "Scanner Effect"),
name: t("home.scannerEffect.title", "Scanner Effect"),
component: null,
description: t("home.fakeScan.desc", "Create a PDF that looks like it was scanned"),
description: t("home.scannerEffect.desc", "Create a PDF that looks like it was scanned"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "scannerEffect"),
},
// Developer Tools
@@ -642,6 +687,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 +697,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 +707,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 +717,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 +727,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 +738,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 +750,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 +780,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 +792,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 +804,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 +817,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 +830,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["auto-redact"],
operationConfig: redactOperationConfig,
settingsComponent: RedactSingleStepSettings,
synonyms: getSynonyms(t, "redact")
},
};
@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AddAttachmentsParameters } from './useAddAttachmentsParameters';
const buildFormData = (parameters: AddAttachmentsParameters, file: File): FormData => {
const formData = new FormData();
// Add the main PDF file (single file per request in singleFile mode)
if (file) {
formData.append("fileInput", file);
}
// Add attachment files
(parameters.attachments || []).forEach((attachment) => {
if (attachment) formData.append("attachments", attachment);
});
return formData;
};
// Operation configuration for automation
export const addAttachmentsOperationConfig: ToolOperationConfig<AddAttachmentsParameters> = {
toolType: ToolType.singleFile,
buildFormData,
operationType: 'addAttachments',
endpoint: '/api/v1/misc/add-attachments',
};
export const useAddAttachmentsOperation = () => {
const { t } = useTranslation();
return useToolOperation<AddAttachmentsParameters>({
...addAttachmentsOperationConfig,
getErrorMessage: createStandardErrorHandler(t('addAttachments.error.failed', 'An error occurred while adding attachments to the PDF.'))
});
};
@@ -0,0 +1,35 @@
import { useState } from 'react';
export interface AddAttachmentsParameters {
attachments: File[];
}
const defaultParameters: AddAttachmentsParameters = {
attachments: []
};
export const useAddAttachmentsParameters = () => {
const [parameters, setParameters] = useState<AddAttachmentsParameters>(defaultParameters);
const updateParameter = <K extends keyof AddAttachmentsParameters>(
key: K,
value: AddAttachmentsParameters[K]
) => {
setParameters(prev => ({ ...prev, [key]: value }));
};
const resetParameters = () => {
setParameters(defaultParameters);
};
const validateParameters = (): boolean => {
return parameters.attachments.length > 0;
};
return {
parameters,
updateParameter,
resetParameters,
validateParameters
};
};
@@ -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') ||
@@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { ExtractImagesParameters, defaultParameters } from './useExtractImagesParameters';
import JSZip from 'jszip';
// Static configuration that can be used by both the hook and automation executor
export const buildExtractImagesFormData = (parameters: ExtractImagesParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("format", parameters.format);
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
return formData;
};
// Response handler for extract-images which returns a ZIP file
const extractImagesResponseHandler = async (responseData: Blob, _originalFiles: File[]): Promise<File[]> => {
const zip = new JSZip();
const zipContent = await zip.loadAsync(responseData);
const extractedFiles: File[] = [];
for (const [filename, file] of Object.entries(zipContent.files)) {
if (!file.dir) {
const blob = await file.async('blob');
const extractedFile = new File([blob], filename, { type: blob.type });
extractedFiles.push(extractedFile);
}
}
return extractedFiles;
};
// Static configuration object
export const extractImagesOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildExtractImagesFormData,
operationType: 'extractImages',
endpoint: '/api/v1/misc/extract-images',
defaultParameters,
// Extract-images returns a ZIP file containing multiple image files
responseHandler: extractImagesResponseHandler,
} as const;
export const useExtractImagesOperation = () => {
const { t } = useTranslation();
return useToolOperation<ExtractImagesParameters>({
...extractImagesOperationConfig,
getErrorMessage: createStandardErrorHandler(t('extractImages.error.failed', 'An error occurred while extracting images from the PDF.'))
});
};
@@ -0,0 +1,19 @@
import { useBaseParameters } from '../shared/useBaseParameters';
export interface ExtractImagesParameters {
format: 'png' | 'jpg' | 'gif';
allowDuplicates: boolean;
}
export const defaultParameters: ExtractImagesParameters = {
format: 'png',
allowDuplicates: false,
};
export const useExtractImagesParameters = () => {
return useBaseParameters<ExtractImagesParameters>({
defaultParameters,
endpointName: 'extract-images',
validateFn: () => true, // All parameters have valid defaults
});
};
@@ -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());
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import type { RemoveImageParameters } from './useRemoveImageParameters';
export const buildRemoveImageFormData = (_params: RemoveImageParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
return formData;
};
export const removeImageOperationConfig: ToolOperationConfig<RemoveImageParameters> = {
toolType: ToolType.singleFile,
buildFormData: buildRemoveImageFormData,
operationType: 'removeImage',
endpoint: '/api/v1/general/remove-image-pdf',
};
export const useRemoveImageOperation = () => {
const { t } = useTranslation();
return useToolOperation<RemoveImageParameters>({
...removeImageOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('removeImage.error.failed', 'Failed to remove images from the PDF.')
),
});
};
@@ -0,0 +1,17 @@
import { useBaseParameters } from '../shared/useBaseParameters';
import type { BaseParametersHook } from '../shared/useBaseParameters';
export type RemoveImageParameters = Record<string, never>;
export const defaultParameters: RemoveImageParameters = {};
export type RemoveImageParametersHook = BaseParametersHook<RemoveImageParameters>;
export const useRemoveImageParameters = (): RemoveImageParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'remove-image-pdf',
});
};
@@ -0,0 +1,36 @@
import { useTranslation } from 'react-i18next';
import { ToolOperationConfig, ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { ReorganizePagesParameters } from './useReorganizePagesParameters';
const buildFormData = (parameters: ReorganizePagesParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
if (parameters.customMode) {
formData.append('customMode', parameters.customMode);
}
if (parameters.pageNumbers) {
const cleaned = parameters.pageNumbers.replace(/\s+/g, '');
formData.append('pageNumbers', cleaned);
}
return formData;
};
export const reorganizePagesOperationConfig: ToolOperationConfig<ReorganizePagesParameters> = {
toolType: ToolType.singleFile,
buildFormData,
operationType: 'reorganizePages',
endpoint: '/api/v1/general/rearrange-pages',
};
export const useReorganizePagesOperation = () => {
const { t } = useTranslation();
return useToolOperation<ReorganizePagesParameters>({
...reorganizePagesOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('reorganizePages.error.failed', 'Failed to reorganize pages')
)
});
};
@@ -0,0 +1,39 @@
import { useState } from 'react';
export interface ReorganizePagesParameters {
customMode: string; // empty string means custom order using pageNumbers
pageNumbers: string; // e.g. "1,3,2,4-6"
}
export const defaultReorganizePagesParameters: ReorganizePagesParameters = {
customMode: '',
pageNumbers: '',
};
export const useReorganizePagesParameters = () => {
const [parameters, setParameters] = useState<ReorganizePagesParameters>(defaultReorganizePagesParameters);
const updateParameter = <K extends keyof ReorganizePagesParameters>(
key: K,
value: ReorganizePagesParameters[K]
) => {
setParameters(prev => ({ ...prev, [key]: value }));
};
const resetParameters = () => setParameters(defaultReorganizePagesParameters);
// If customMode is '' (custom) or 'DUPLICATE', a page order is required; otherwise it's optional/ignored
const validateParameters = (): boolean => {
const requiresOrder = parameters.customMode === '' || parameters.customMode === 'DUPLICATE';
return requiresOrder ? parameters.pageNumbers.trim().length > 0 : true;
};
return {
parameters,
updateParameter,
resetParameters,
validateParameters,
};
};
@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { ReplaceColorParameters, defaultParameters } from './useReplaceColorParameters';
export const buildReplaceColorFormData = (parameters: ReplaceColorParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('replaceAndInvertOption', parameters.replaceAndInvertOption);
if (parameters.replaceAndInvertOption === 'HIGH_CONTRAST_COLOR') {
formData.append('highContrastColorCombination', parameters.highContrastColorCombination);
} else if (parameters.replaceAndInvertOption === 'CUSTOM_COLOR') {
formData.append('textColor', parameters.textColor);
formData.append('backGroundColor', parameters.backGroundColor);
}
return formData;
};
export const replaceColorOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildReplaceColorFormData,
operationType: 'replaceColor',
endpoint: '/api/v1/misc/replace-invert-pdf',
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useReplaceColorOperation = () => {
const { t } = useTranslation();
return useToolOperation<ReplaceColorParameters>({
...replaceColorOperationConfig,
getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the color replacement.'))
});
};
@@ -0,0 +1,29 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface ReplaceColorParameters extends BaseParameters {
replaceAndInvertOption: 'HIGH_CONTRAST_COLOR' | 'CUSTOM_COLOR' | 'FULL_INVERSION';
highContrastColorCombination: 'WHITE_TEXT_ON_BLACK' | 'BLACK_TEXT_ON_WHITE' | 'YELLOW_TEXT_ON_BLACK' | 'GREEN_TEXT_ON_BLACK';
textColor: string;
backGroundColor: string;
}
export const defaultParameters: ReplaceColorParameters = {
replaceAndInvertOption: 'HIGH_CONTRAST_COLOR',
highContrastColorCombination: 'WHITE_TEXT_ON_BLACK',
textColor: '#000000',
backGroundColor: '#ffffff',
};
export type ReplaceColorParametersHook = BaseParametersHook<ReplaceColorParameters>;
export const useReplaceColorParameters = (): ReplaceColorParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'replace-invert-pdf',
validateFn: () => {
// All parameters are always valid as they have defaults
return true;
},
});
};
@@ -0,0 +1,54 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { ScannerImageSplitParameters, defaultParameters } from './useScannerImageSplitParameters';
import { zipFileService } from '../../../services/zipFileService';
export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('angle_threshold', parameters.angle_threshold.toString());
formData.append('tolerance', parameters.tolerance.toString());
formData.append('min_area', parameters.min_area.toString());
formData.append('min_contour_area', parameters.min_contour_area.toString());
formData.append('border_size', parameters.border_size.toString());
return formData;
};
// Custom response handler to handle ZIP files that might be misidentified
const scannerImageSplitResponseHandler = async (responseData: Blob, inputFiles: File[]): Promise<File[]> => {
try {
// Always try to extract as ZIP first, regardless of content-type
const extractionResult = await zipFileService.extractAllFiles(responseData);
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
return extractionResult.extractedFiles;
}
} catch (error) {
console.warn('Failed to extract as ZIP, treating as single file:', error);
}
// Fallback: treat as single file (PNG image)
const inputFileName = inputFiles[0]?.name || 'document';
const baseFileName = inputFileName.replace(/\.[^.]+$/, '');
const singleFile = new File([responseData], `${baseFileName}.png`, { type: 'image/png' });
return [singleFile];
};
export const scannerImageSplitOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildScannerImageSplitFormData,
operationType: 'scannerImageSplit',
endpoint: '/api/v1/misc/extract-image-scans',
multiFileEndpoint: false,
responseHandler: scannerImageSplitResponseHandler,
defaultParameters,
} as const;
export const useScannerImageSplitOperation = () => {
const { t } = useTranslation();
return useToolOperation<ScannerImageSplitParameters>({
...scannerImageSplitOperationConfig,
getErrorMessage: createStandardErrorHandler(t('scannerImageSplit.error.failed', 'An error occurred while extracting image scans.'))
});
};
@@ -0,0 +1,31 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface ScannerImageSplitParameters extends BaseParameters {
angle_threshold: number;
tolerance: number;
min_area: number;
min_contour_area: number;
border_size: number;
}
export const defaultParameters: ScannerImageSplitParameters = {
angle_threshold: 10,
tolerance: 30,
min_area: 10000,
min_contour_area: 500,
border_size: 1,
};
export type ScannerImageSplitParametersHook = BaseParametersHook<ScannerImageSplitParameters>;
export const useScannerImageSplitParameters = (): ScannerImageSplitParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'extract-image-scans',
validateFn: () => {
// All parameters are numeric with defaults, validation handled by form
return true;
},
});
};
@@ -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);
}
+37 -5
View File
@@ -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 };
}
+47
View File
@@ -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);
}
+255
View File
@@ -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;
}
+119
View File
@@ -338,6 +338,125 @@ export class ZipFileService {
return errorMessage.includes('password') || errorMessage.includes('encrypted');
}
}
/**
* Extract all files from a ZIP archive (not limited to PDFs)
*/
async extractAllFiles(
file: File | Blob,
onProgress?: (progress: ZipExtractionProgress) => void
): Promise<ZipExtractionResult> {
const result: ZipExtractionResult = {
success: false,
extractedFiles: [],
errors: [],
totalFiles: 0,
extractedCount: 0
};
try {
// Load ZIP contents
const zip = new JSZip();
const zipContents = await zip.loadAsync(file);
// Get all files (not directories)
const allFiles = Object.entries(zipContents.files).filter(([, zipEntry]) =>
!zipEntry.dir
);
result.totalFiles = allFiles.length;
// Extract each file
for (let i = 0; i < allFiles.length; i++) {
const [filename, zipEntry] = allFiles[i];
try {
// Report progress
if (onProgress) {
onProgress({
currentFile: filename,
extractedCount: i,
totalFiles: allFiles.length,
progress: (i / allFiles.length) * 100
});
}
// Extract file content
const content = await zipEntry.async('blob');
// Create File object with appropriate MIME type
const mimeType = this.getMimeTypeFromExtension(filename);
const extractedFile = new File([content], filename, { type: mimeType });
result.extractedFiles.push(extractedFile);
result.extractedCount++;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to extract "${filename}": ${errorMessage}`);
}
}
// Final progress report
if (onProgress) {
onProgress({
currentFile: '',
extractedCount: result.extractedCount,
totalFiles: result.totalFiles,
progress: 100
});
}
result.success = result.extractedFiles.length > 0;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to process ZIP file: ${errorMessage}`);
}
return result;
}
/**
* Get MIME type based on file extension
*/
private getMimeTypeFromExtension(fileName: string): string {
const ext = fileName.toLowerCase().split('.').pop();
const mimeTypes: Record<string, string> = {
// Images
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'bmp': 'image/bmp',
'svg': 'image/svg+xml',
'tiff': 'image/tiff',
'tif': 'image/tiff',
// Documents
'pdf': 'application/pdf',
'txt': 'text/plain',
'html': 'text/html',
'css': 'text/css',
'js': 'application/javascript',
'json': 'application/json',
'xml': 'application/xml',
// Office documents
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
// Archives
'zip': 'application/zip',
'rar': 'application/x-rar-compressed',
};
return mimeTypes[ext || ''] || 'application/octet-stream';
}
}
// Export singleton instance
+52 -1
View File
@@ -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 () => {
+28
View File
@@ -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,
},
+197
View File
@@ -0,0 +1,197 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useFileSelection } from "../contexts/FileContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useAddAttachmentsParameters } from "../hooks/tools/addAttachments/useAddAttachmentsParameters";
import { useAddAttachmentsOperation } from "../hooks/tools/addAttachments/useAddAttachmentsOperation";
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
import LocalIcon from "../components/shared/LocalIcon";
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
// Removed FitText for two-line wrapping with clamping
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const params = useAddAttachmentsParameters();
const operation = useAddAttachmentsOperation();
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-attachments");
useEffect(() => {
operation.resetResults();
onPreviewFile?.(null);
}, [params.parameters]);
const handleExecute = async () => {
try {
await operation.executeOperation(params.parameters, selectedFiles);
if (operation.files && onComplete) {
onComplete(operation.files);
}
} catch (error: any) {
onError?.(error?.message || t("AddAttachmentsRequest.error.failed", "Add attachments operation failed"));
}
};
const hasFiles = selectedFiles.length > 0;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
enum AddAttachmentsStep {
NONE = 'none',
ATTACHMENTS = 'attachments'
}
const accordion = useAccordionSteps<AddAttachmentsStep>({
noneValue: AddAttachmentsStep.NONE,
initialStep: AddAttachmentsStep.ATTACHMENTS,
stateConditions: {
hasFiles,
hasResults: false // Don't collapse when there are results for add attachments
},
afterResults: () => {
operation.resetResults();
onPreviewFile?.(null);
}
});
const getSteps = () => {
const steps: any[] = [];
// Step 1: Attachments Selection
steps.push({
title: t("AddAttachmentsRequest.attachments", "Select Attachments"),
isCollapsed: accordion.getCollapsedState(AddAttachmentsStep.ATTACHMENTS),
onCollapsedClick: () => accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS),
isVisible: true,
content: (
<Stack gap="md">
<Alert color="blue" variant="light">
<Text size="sm">
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
</Text>
</Alert>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
</Text>
<input
type="file"
multiple
onChange={(e) => {
const files = Array.from(e.target.files || []);
// Append to existing attachments instead of replacing
const newAttachments = [...params.parameters.attachments, ...files];
params.updateParameter('attachments', newAttachments);
// Reset the input so the same file can be selected again
e.target.value = '';
}}
disabled={endpointLoading}
style={{ display: 'none' }}
id="attachments-input"
/>
<Button
size="xs"
color="blue"
component="label"
htmlFor="attachments-input"
disabled={endpointLoading}
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
>
{params.parameters.attachments.length > 0
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
: t("AddAttachmentsRequest.placeholder", "Choose files...")
}
</Button>
</Stack>
{params.parameters.attachments && params.parameters.attachments.length > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({params.parameters.attachments.length})
</Text>
<ScrollArea.Autosize mah={300} type="scroll" offsetScrollbars styles={{ viewport: { overflowX: 'hidden' } }}>
<Stack gap="xs">
{params.parameters.attachments.map((file, index) => (
<Group key={index} justify="space-between" p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 'var(--mantine-radius-sm)', alignItems: 'flex-start' }}>
<Group gap="xs" style={{ flex: 1, minWidth: 0, alignItems: 'flex-start' }}>
{/* Filename (two-line clamp, wraps, no icon on the left) */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 'var(--mantine-font-size-sm)',
fontWeight: 400,
lineHeight: 1.2,
display: '-webkit-box',
WebkitLineClamp: 2 as any,
WebkitBoxOrient: 'vertical' as any,
overflow: 'hidden',
whiteSpace: 'normal',
wordBreak: 'break-word',
}}
title={file.name}
>
{file.name}
</div>
</div>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
({(file.size / 1024).toFixed(1)} KB)
</Text>
</Group>
<ActionIcon
size="sm"
variant="subtle"
color="red"
style={{ flexShrink: 0 }}
onClick={() => {
const newAttachments = params.parameters.attachments.filter((_, i) => i !== index);
params.updateParameter('attachments', newAttachments);
}}
>
<LocalIcon icon="close-rounded" width="14" height="14" />
</ActionIcon>
</Group>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
)}
</Stack>
),
});
return steps;
};
return createToolFlow({
files: {
selectedFiles,
isCollapsed: hasResults,
},
steps: getSteps(),
executeButton: {
text: t('AddAttachmentsRequest.submit', 'Add Attachments'),
isVisible: !hasResults,
loadingText: t('loading'),
onClick: handleExecute,
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: operation,
title: t('AddAttachmentsRequest.results.title', 'Attachment Results'),
onFileClick: (file) => onPreviewFile?.(file),
onUndo: async () => {
await operation.undoOperation();
onPreviewFile?.(null);
},
},
});
};
AddAttachments.tool = () => useAddAttachmentsOperation;
export default AddAttachments as ToolComponent;
+55
View File
@@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import ExtractImagesSettings from "../components/tools/extractImages/ExtractImagesSettings";
import { useExtractImagesParameters } from "../hooks/tools/extractImages/useExtractImagesParameters";
import { useExtractImagesOperation } from "../hooks/tools/extractImages/useExtractImagesOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
const ExtractImages = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
'extractImages',
useExtractImagesParameters,
useExtractImagesOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t("extractImages.settings.title", "Settings"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
content: (
<ExtractImagesSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t("extractImages.submit", "Extract Images"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("extractImages.title", "Extracted Images"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default ExtractImages as ToolComponent;
+45
View File
@@ -0,0 +1,45 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { useRemoveImageParameters } from "../hooks/tools/removeImage/useRemoveImageParameters";
import { useRemoveImageOperation } from "../hooks/tools/removeImage/useRemoveImageOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
const RemoveImage = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
'removeImage',
useRemoveImageParameters,
useRemoveImageOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [],
executeButton: {
text: t("removeImage.submit", "Remove Images"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("removeImage.results.title", "Remove Images Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
RemoveImage.tool = () => useRemoveImageOperation;
export default RemoveImage as ToolComponent;
+104
View File
@@ -0,0 +1,104 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileSelection } from "../contexts/FileContext";
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
import ReorganizePagesSettings from "../components/tools/reorganizePages/ReorganizePagesSettings";
import { useReorganizePagesParameters } from "../hooks/tools/reorganizePages/useReorganizePagesParameters";
import { useReorganizePagesOperation } from "../hooks/tools/reorganizePages/useReorganizePagesOperation";
const ReorganizePages = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const params = useReorganizePagesParameters();
const operation = useReorganizePagesOperation();
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("rearrange-pages");
useEffect(() => {
operation.resetResults();
onPreviewFile?.(null);
}, [params.parameters]);
const handleExecute = async () => {
try {
await operation.executeOperation(params.parameters, selectedFiles);
if (operation.files && onComplete) {
onComplete(operation.files);
}
} catch (error: any) {
onError?.(error?.message || t("reorganizePages.error.failed", "Failed to reorganize pages"));
}
};
const hasFiles = selectedFiles.length > 0;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
enum Step {
NONE = 'none',
SETTINGS = 'settings'
}
const accordion = useAccordionSteps<Step>({
noneValue: Step.NONE,
initialStep: Step.SETTINGS,
stateConditions: {
hasFiles,
hasResults
},
afterResults: () => {
operation.resetResults();
onPreviewFile?.(null);
}
});
const steps = [
{
title: t("reorganizePages.settings.title", "Settings"),
isCollapsed: accordion.getCollapsedState(Step.SETTINGS),
onCollapsedClick: () => accordion.handleStepToggle(Step.SETTINGS),
isVisible: true,
content: (
<ReorganizePagesSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
/>
),
}
];
return createToolFlow({
files: {
selectedFiles,
isCollapsed: hasResults,
},
steps,
executeButton: {
text: t('reorganizePages.submit', 'Reorganize Pages'),
isVisible: !hasResults,
loadingText: t('loading'),
onClick: handleExecute,
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: operation,
title: t('reorganizePages.results.title', 'Pages Reorganized'),
onFileClick: (file) => onPreviewFile?.(file),
onUndo: async () => {
await operation.undoOperation();
onPreviewFile?.(null);
},
},
});
};
(ReorganizePages as any).tool = () => useReorganizePagesOperation;
export default ReorganizePages as ToolComponent;
+58
View File
@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import ReplaceColorSettings from "../components/tools/replaceColor/ReplaceColorSettings";
import { useReplaceColorParameters } from "../hooks/tools/replaceColor/useReplaceColorParameters";
import { useReplaceColorOperation } from "../hooks/tools/replaceColor/useReplaceColorOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useReplaceColorTips } from "../components/tooltips/useReplaceColorTips";
const ReplaceColor = (props: BaseToolProps) => {
const { t } = useTranslation();
const replaceColorTips = useReplaceColorTips();
const base = useBaseTool(
'replaceColor',
useReplaceColorParameters,
useReplaceColorOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t("replaceColor.labels.settings", "Settings"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: replaceColorTips,
content: (
<ReplaceColorSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t("replace-color.submit", "Replace"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("replace-color.title", "Replace-Invert-Color"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default ReplaceColor as ToolComponent;
+58
View File
@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
import { useScannerImageSplitParameters } from "../hooks/tools/scannerImageSplit/useScannerImageSplitParameters";
import { useScannerImageSplitOperation } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useScannerImageSplitTips } from "../components/tooltips/useScannerImageSplitTips";
const ScannerImageSplit = (props: BaseToolProps) => {
const { t } = useTranslation();
const scannerImageSplitTips = useScannerImageSplitTips();
const base = useBaseTool(
'scannerImageSplit',
useScannerImageSplitParameters,
useScannerImageSplitOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: "Settings",
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: scannerImageSplitTips,
content: (
<ScannerImageSplitSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t("scannerImageSplit.submit", "Extract Image Scans"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("scannerImageSplit.title", "Extracted Images"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default ScannerImageSplit as ToolComponent;
+7
View File
@@ -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;
+2 -5
View File
@@ -11,8 +11,6 @@ export const TOOL_IDS = [
'changePermissions',
'watermark',
'sanitize',
'autoSplitPDF',
'autoSizeSplitPDF',
'split',
'merge',
'convert',
@@ -21,7 +19,7 @@ export const TOOL_IDS = [
'rotate',
'scannerImageSplit',
'editTableOfContents',
'fakeScan',
'scannerEffect',
'autoRename',
'pageLayout',
'scalePages',
@@ -44,12 +42,11 @@ export const TOOL_IDS = [
'addAttachments',
'changeMetadata',
'overlayPdfs',
'manageCertificates',
'getPdfInfo',
'validateSignature',
'read',
'automate',
'replaceColorPdf',
'replaceColor',
'showJS',
'devApi',
'devFolderScanning',
+121
View File
@@ -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);
}
+1 -1
View File
@@ -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.';
};
/**
+99
View File
@@ -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] }));
}
+24
View File
@@ -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 [];
}};
+9 -10
View File
@@ -39,7 +39,6 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/remove-cert-sign': 'removeCertSign',
'/unlock-pdf-forms': 'unlockPDFForms',
'/validate-signature': 'validateSignature',
'/manage-certificates': 'manageCertificates',
// Content manipulation
'/sanitize': 'sanitize',
@@ -64,8 +63,8 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/booklet-imposition': 'bookletImposition',
// Splitting tools
'/auto-split-pdf': 'autoSplitPDF',
'/auto-size-split-pdf': 'autoSizeSplitPDF',
'/auto-split-pdf': 'split',
'/auto-size-split-pdf': 'split',
'/scanner-image-split': 'scannerImageSplit',
// Annotation and content removal
@@ -75,8 +74,8 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
// Image and visual tools
'/extract-images': 'extractImages',
'/adjust-contrast': 'adjustContrast',
'/fake-scan': 'fakeScan',
'/replace-color-pdf': 'replaceColorPdf',
'/fake-scan': 'scannerEffect',
'/replace-color-pdf': 'replaceColor',
// Metadata and info
'/change-metadata': 'changeMetadata',
@@ -116,13 +115,13 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/view-pdf': 'read',
'/get-info-on-pdf': 'getPdfInfo',
'/remove-image-pdf': 'removeImage',
'/replace-and-invert-color-pdf': 'replaceColorPdf',
'/replace-and-invert-color-pdf': 'replaceColor',
'/pipeline': 'automate',
'/extract-image-scans': 'scannerImageSplit',
'/show-javascript': 'showJS',
'/scanner-effect': 'fakeScan',
'/split-by-size-or-count': 'autoSizeSplitPDF',
'/scanner-effect': 'scannerEffect',
'/split-by-size-or-count': 'split',
'/overlay-pdf': 'overlayPdfs',
'/split-pdf-by-sections': 'autoSplitPDF',
'/split-pdf-by-chapters': 'autoSplitPDF',
'/split-pdf-by-sections': 'split',
'/split-pdf-by-chapters': 'split',
};
+36
View File
@@ -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>)',
+403
View File
@@ -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()
+177
View File
@@ -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()
+262
View File
@@ -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()
+371
View File
@@ -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()