Compare commits

..
93 changed files with 91929 additions and 93611 deletions
@@ -1,49 +0,0 @@
package stirling.software.SPDF.config;
import java.io.IOException;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
/**
* Filter to track browser IDs for Weekly Active Users (WAU) counting.
* Only active when security is disabled (no-login mode).
*/
@Component
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
@RequiredArgsConstructor
@Slf4j
public class WAUTrackingFilter implements Filter {
private final WeeklyActiveUsersService wauService;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (request instanceof HttpServletRequest httpRequest) {
// Extract browser ID from header
String browserId = httpRequest.getHeader("X-Browser-Id");
if (browserId != null && !browserId.trim().isEmpty()) {
// Record browser access
wauService.recordBrowserAccess(browserId);
}
}
// Continue the filter chain
chain.doFilter(request, response);
}
}
@@ -46,24 +46,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
} else if (hasConfiguredOrigins) {
@@ -79,53 +63,13 @@ public class WebMvcConfig implements WebMvcConfigurer {
.toArray(new String[0]);
registry.addMapping("/**")
.allowedOriginPatterns(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
} else {
// Default to allowing all origins when nothing is configured
logger.info(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
// If no origins are configured and not in Tauri mode, CORS is not enabled (secure by
// default)
}
}
@@ -29,6 +29,7 @@ import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.JsonDataResponse;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
@@ -48,12 +49,13 @@ public class EditTableOfContentsController {
@AutoJobPostMapping(
value = "/extract-bookmarks",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@ResponseBody
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(
@RequestParam("file") MultipartFile file) throws Exception {
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
throws Exception {
PDDocument document = null;
try {
document = pdfDocumentFactory.load(file);
@@ -61,10 +63,10 @@ public class EditTableOfContentsController {
if (outline == null) {
log.info("No outline/bookmarks found in PDF");
return ResponseEntity.ok(new ArrayList<>());
return new ArrayList<>();
}
return ResponseEntity.ok(extractBookmarkItems(document, outline));
return extractBookmarkItems(document, outline);
} finally {
if (document != null) {
document.close();
@@ -154,25 +154,6 @@ public class ConfigController {
// EE features not available, continue without them
}
// Add version and machine info for update checking
try {
if (applicationContext.containsBean("appVersion")) {
configData.put(
"appVersion", applicationContext.getBean("appVersion", String.class));
}
if (applicationContext.containsBean("machineType")) {
configData.put(
"machineType", applicationContext.getBean("machineType", String.class));
}
if (applicationContext.containsBean("activeSecurity")) {
configData.put(
"activeSecurity",
applicationContext.getBean("activeSecurity", Boolean.class));
}
} catch (Exception e) {
// Version/machine info not available
}
return ResponseEntity.ok(configData);
} catch (Exception e) {
@@ -23,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointInspector;
import stirling.software.SPDF.config.StartupApplicationListener;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
import stirling.software.common.annotations.api.InfoApi;
import stirling.software.common.model.ApplicationProperties;
@@ -35,7 +34,6 @@ public class MetricsController {
private final ApplicationProperties applicationProperties;
private final MeterRegistry meterRegistry;
private final EndpointInspector endpointInspector;
private final Optional<WeeklyActiveUsersService> wauService;
private boolean metricsEnabled;
@PostConstruct
@@ -354,35 +352,6 @@ public class MetricsController {
return ResponseEntity.ok(formatDuration(uptime));
}
@GetMapping("/wau")
@Operation(
summary = "Weekly Active Users statistics",
description =
"Returns WAU (Weekly Active Users) count and total unique browsers. "
+ "Only available when security is disabled (no-login mode). "
+ "Tracks unique browsers via client-generated UUID in localStorage.")
public ResponseEntity<?> getWeeklyActiveUsers() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
// Check if WAU service is available (only when security.enableLogin=false)
if (wauService.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("WAU tracking is only available when security is disabled (no-login mode)");
}
WeeklyActiveUsersService service = wauService.get();
Map<String, Object> wauStats = new HashMap<>();
wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers());
wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers());
wauStats.put("daysOnline", service.getDaysOnline());
wauStats.put("trackingSince", service.getStartTime().toString());
return ResponseEntity.ok(wauStats);
}
private String formatDuration(Duration duration) {
long days = duration.toDays();
long hours = duration.toHoursPart();
@@ -1,100 +0,0 @@
package stirling.software.SPDF.service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Service for tracking Weekly Active Users (WAU) in no-login mode.
* Uses in-memory storage with automatic cleanup of old entries.
*/
@Service
@Slf4j
public class WeeklyActiveUsersService {
// Map of browser ID -> last seen timestamp
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
// Track total unique browsers seen (overall)
private long totalUniqueBrowsers = 0;
// Application start time
private final Instant startTime = Instant.now();
/**
* Records a browser access with the current timestamp
* @param browserId Unique browser identifier from X-Browser-Id header
*/
public void recordBrowserAccess(String browserId) {
if (browserId == null || browserId.trim().isEmpty()) {
return;
}
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
activeBrowsers.put(browserId, Instant.now());
if (isNewBrowser) {
totalUniqueBrowsers++;
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
}
}
/**
* Gets the count of unique browsers seen in the last 7 days
* @return Weekly Active Users count
*/
public long getWeeklyActiveUsers() {
cleanupOldEntries();
return activeBrowsers.size();
}
/**
* Gets the total count of unique browsers ever seen
* @return Total unique browsers count
*/
public long getTotalUniqueBrowsers() {
return totalUniqueBrowsers;
}
/**
* Gets the number of days the service has been running
* @return Days online
*/
public long getDaysOnline() {
return ChronoUnit.DAYS.between(startTime, Instant.now());
}
/**
* Gets the timestamp when tracking started
* @return Start time
*/
public Instant getStartTime() {
return startTime;
}
/**
* Removes entries older than 7 days
*/
private void cleanupOldEntries() {
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
}
/**
* Manual cleanup trigger (can be called by scheduled task if needed)
*/
public void performCleanup() {
int sizeBefore = activeBrowsers.size();
cleanupOldEntries();
int sizeAfter = activeBrowsers.size();
if (sizeBefore != sizeAfter) {
log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter);
}
}
}
@@ -24,7 +24,6 @@ import org.mockito.ArgumentMatchers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
@@ -87,13 +86,9 @@ class EditTableOfContentsControllerTest {
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@@ -113,13 +108,9 @@ class EditTableOfContentsControllerTest {
when(mockCatalog.getDocumentOutline()).thenReturn(null);
// When
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertTrue(result.isEmpty());
verify(mockDocument).close();
@@ -151,13 +142,9 @@ class EditTableOfContentsControllerTest {
when(childItem.getNextSibling()).thenReturn(null);
// When
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@@ -191,13 +178,9 @@ class EditTableOfContentsControllerTest {
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -127
View File
@@ -353,6 +353,8 @@
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.",
"defaultToolPickerMode": "Default tool picker mode",
"defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
"rememberInputs": "Remember tool inputs",
"rememberInputsDescription": "Store the last settings you used for each tool on this device",
"mode": {
"fullscreen": "Fullscreen",
"sidebar": "Sidebar"
@@ -362,15 +364,7 @@
"defaultPdfEditorInactive": "Another application is set as default",
"defaultPdfEditorChecking": "Checking...",
"defaultPdfEditorSet": "Already Default",
"setAsDefault": "Set as Default",
"updates": {
"title": "Software Updates",
"description": "Check for updates and view version information",
"currentVersion": "Current Version",
"latestVersion": "Latest Version",
"checkForUpdates": "Check for Updates",
"viewDetails": "View Details"
}
"setAsDefault": "Set as Default"
},
"hotkeys": {
"title": "Keyboard Shortcuts",
@@ -391,37 +385,6 @@
"searchPlaceholder": "Search tools..."
}
},
"update": {
"modalTitle": "Update Available",
"current": "Current Version",
"latest": "Latest Version",
"latestStable": "Latest Stable",
"priorityLabel": "Priority",
"recommendedAction": "Recommended Action",
"breakingChangesDetected": "Breaking Changes Detected",
"breakingChangesMessage": "Some versions contain breaking changes. Please review the migration guides below before updating.",
"migrationGuides": "Migration Guides",
"viewGuide": "View Guide",
"loadingDetailedInfo": "Loading detailed information...",
"close": "Close",
"viewAllReleases": "View All Releases",
"downloadLatest": "Download Latest",
"availableUpdates": "Available Updates",
"unableToLoadDetails": "Unable to load detailed information.",
"version": "Version",
"urgentUpdateAvailable": "Urgent Update",
"updateAvailable": "Update Available",
"releaseNotes": "Release Notes",
"priority": {
"urgent": "Urgent",
"normal": "Normal",
"minor": "Minor",
"low": "Low"
},
"breakingChanges": "Breaking Changes",
"breakingChangesDefault": "This version contains breaking changes.",
"migrationGuide": "Migration Guide"
},
"changeCreds": {
"title": "Change Credentials",
"header": "Update Your Account Details",
@@ -1466,93 +1429,6 @@
},
"submit": "Change"
},
"editTableOfContents": {
"settings": {
"title": "Bookmarks & outline",
"replaceExisting": "Replace existing bookmarks (uncheck to append)",
"replaceExistingHint": "When disabled, the new outline is appended after the current bookmarks."
},
"actions": {
"source": "Load bookmarks",
"selectedFile": "Loaded from {{file}}",
"noFile": "Select a PDF to extract existing bookmarks.",
"loadFromPdf": "Load from selected PDF",
"importJson": "Import JSON",
"importClipboard": "Paste JSON from clipboard",
"export": "Export bookmarks",
"exportJson": "Download JSON",
"exportClipboard": "Copy JSON to clipboard",
"clipboardUnavailable": "Clipboard access is not available in this browser."
},
"info": {
"line1": "Each bookmark needs a descriptive title and the page it should open.",
"line2": "Use child bookmarks to build a hierarchy for chapters, sections, or subsections.",
"line3": "Import bookmarks from the selected PDF or from a JSON file to save time."
},
"workbench": {
"empty": {
"title": "Open the tool to start editing",
"description": "Select the Edit Table of Contents tool to load its workspace."
},
"tabTitle": "Outline workspace",
"subtitle": "Import bookmarks, build hierarchies, and apply the outline without cramped side panels.",
"noFile": "No PDF selected",
"fileLabel": "Changes will be applied to the currently selected PDF.",
"filePrompt": "Select a PDF from your library or upload a new one to begin.",
"changeFile": "Change PDF",
"selectFile": "Select PDF"
},
"editor": {
"heading": "Bookmark editor",
"description": "Add, nest, and reorder bookmarks to craft your PDF outline.",
"addTopLevel": "Add top-level bookmark",
"empty": {
"title": "No bookmarks yet",
"description": "Import existing bookmarks or start by adding your first entry.",
"action": "Add first bookmark"
},
"defaultTitle": "New bookmark",
"defaultChildTitle": "Child bookmark",
"defaultSiblingTitle": "New bookmark",
"untitled": "Untitled bookmark",
"childBadge": "Child",
"pagePreview": "Page {{page}}",
"field": {
"title": "Bookmark title",
"page": "Target page number"
},
"actions": {
"toggle": "Toggle children",
"addChild": "Add child bookmark",
"addSibling": "Add sibling bookmark",
"remove": "Remove bookmark"
},
"confirmRemove": "Remove this bookmark and all of its children?"
},
"messages": {
"loadedTitle": "Bookmarks extracted",
"loadedBody": "Existing bookmarks from the PDF were loaded into the editor.",
"noBookmarks": "No bookmarks were found in the selected PDF.",
"loadFailed": "Unable to extract bookmarks from the selected PDF.",
"imported": "Bookmarks imported",
"importedBody": "Your JSON outline replaced the current editor contents.",
"importedClipboard": "Clipboard data replaced the current bookmark list.",
"invalidJson": "Invalid JSON structure",
"invalidJsonBody": "Please provide a valid bookmark JSON file and try again.",
"exported": "JSON download ready",
"copied": "Copied to clipboard",
"copiedBody": "Bookmark JSON copied successfully.",
"copyFailed": "Copy failed"
},
"error": {
"failed": "Failed to update the table of contents"
},
"submit": "Apply table of contents",
"results": {
"title": "Updated PDF with bookmarks",
"subtitle": "Download the processed file or undo the operation below."
}
},
"removePages": {
"tags": "Remove pages,delete pages",
"title": "Remove Pages",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import React, { useMemo } from "react";
import React from "react";
import { TourProvider, useTour, type StepType } from '@reactour/tour';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useTranslation } from 'react-i18next';
@@ -10,7 +10,6 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import CheckIcon from '@mui/icons-material/Check';
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
import '@app/components/onboarding/OnboardingTour.css';
import i18n from "@app/i18n";
// Enum case order defines order steps will appear
enum TourStep {
@@ -121,7 +120,7 @@ export default function OnboardingTour() {
} = useAdminTourOrchestration();
// Define steps as object keyed by enum - TypeScript ensures all keys are present
const stepsConfig: Record<TourStep, StepType> = useMemo(() => ({
const stepsConfig: Record<TourStep, StepType> = {
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
@@ -249,10 +248,10 @@ export default function OnboardingTour() {
position: 'right',
padding: 10,
},
}), [t]);
};
// Define admin tour steps
const adminStepsConfig: Record<AdminTourStep, StepType> = useMemo(() => ({
const adminStepsConfig: Record<AdminTourStep, StepType> = {
[AdminTourStep.WELCOME]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
@@ -364,7 +363,7 @@ export default function OnboardingTour() {
removeAllGlows();
},
},
}), [t]);
};
// Select steps based on tour type
const steps = tourType === 'admin'
@@ -417,7 +416,7 @@ export default function OnboardingTour() {
}}
/>
<TourProvider
key={`${tourType}-${i18n.language}`}
key={tourType}
steps={steps}
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
onClickClose={handleCloseTour}
@@ -272,13 +272,8 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => {
const enabledLanguages = [
'en-GB', 'zh-CN', 'zh-TW', 'ar-AR', 'fa-IR', 'tr-TR', 'uk-UA', 'zh-BO', 'sl-SI',
'ru-RU', 'ja-JP', 'ko-KR', 'hu-HU', 'ga-IE', 'bg-BG', 'es-ES', 'hi-IN', 'hr-HR',
'el-GR', 'ml-ML', 'pt-BR', 'pl-PL', 'pt-PT', 'sk-SK', 'sr-LATN-RS', 'no-NB',
'th-TH', 'vi-VN', 'az-AZ', 'eu-ES', 'de-DE', 'sv-SE', 'it-IT', 'ca-CA', 'id-ID',
'ro-RO', 'fr-FR', 'nl-NL', 'da-DK', 'cs-CZ'
];
// Enable languages with >90% translation completion
const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
const isDisabled = !enabledLanguages.includes(option.value);
return (
@@ -1,415 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Modal, Stack, Text, Badge, Button, Group, Loader, Center, Divider, Box, Collapse } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { updateService, UpdateSummary, FullUpdateInfo, MachineInfo } from '@app/services/updateService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import DownloadIcon from '@mui/icons-material/Download';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
interface UpdateModalProps {
opened: boolean;
onClose: () => void;
currentVersion: string;
updateSummary: UpdateSummary;
machineInfo: MachineInfo;
}
const UpdateModal: React.FC<UpdateModalProps> = ({
opened,
onClose,
currentVersion,
updateSummary,
machineInfo,
}) => {
const { t } = useTranslation();
const [fullUpdateInfo, setFullUpdateInfo] = useState<FullUpdateInfo | null>(null);
const [loading, setLoading] = useState(true);
const [expandedVersions, setExpandedVersions] = useState<Set<number>>(new Set([0]));
useEffect(() => {
if (opened) {
setLoading(true);
setExpandedVersions(new Set([0]));
updateService.getFullUpdateInfo(currentVersion, machineInfo).then((info) => {
setFullUpdateInfo(info);
setLoading(false);
});
}
}, [opened, currentVersion, machineInfo]);
const toggleVersion = (index: number) => {
setExpandedVersions((prev) => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
};
const getPriorityColor = (priority: string): string => {
switch (priority?.toLowerCase()) {
case 'urgent':
return 'red';
case 'normal':
return 'blue';
case 'minor':
return 'cyan';
case 'low':
return 'gray';
default:
return 'gray';
}
};
const getPriorityLabel = (priority: string): string => {
const key = priority?.toLowerCase();
return t(`update.priority.${key}`, priority || 'Normal');
};
const downloadUrl = updateService.getDownloadUrl(machineInfo);
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Text fw={600} size="lg">
{t('update.modalTitle', 'Update Available')}
</Text>
}
centered
size="xl"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
styles={{
body: {
maxHeight: '75vh',
overflowY: 'auto',
},
}}
>
<Stack gap="lg" pt="md">
{/* Version Summary Section */}
<Box>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
<Stack gap={4} style={{ flex: 1 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
{t('update.current', 'Current Version')}
</Text>
<Text fw={600} size="xl">
{currentVersion}
</Text>
</Stack>
<Stack gap={4} style={{ flex: 1 }} ta="center">
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
{t('update.priorityLabel', 'Priority')}
</Text>
<Badge
color={getPriorityColor(updateSummary.max_priority)}
size="lg"
variant="filled"
style={{ alignSelf: 'center' }}
>
{getPriorityLabel(updateSummary.max_priority)}
</Badge>
</Stack>
<Stack gap={4} style={{ flex: 1 }} ta="right">
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
{t('update.latest', 'Latest Version')}
</Text>
<Text fw={600} size="xl" c="blue">
{updateSummary.latest_version}
</Text>
</Stack>
</Group>
{updateSummary.latest_stable_version && (
<Box
style={{
background: 'var(--mantine-color-green-0)',
padding: '10px 16px',
borderRadius: '8px',
border: '1px solid var(--mantine-color-green-2)',
}}
>
<Group gap="xs" justify="center">
<Text size="sm" fw={500}>
{t('update.latestStable', 'Latest Stable')}:
</Text>
<Text size="sm" fw={600} c="green">
{updateSummary.latest_stable_version}
</Text>
</Group>
</Box>
)}
</Box>
{/* Recommended action */}
{updateSummary.recommended_action && (
<Box
style={{
background: 'var(--mantine-color-blue-light)',
padding: '12px 16px',
borderRadius: '8px',
border: '1px solid var(--mantine-color-blue-outline)',
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<InfoOutlinedIcon style={{ fontSize: 18, color: 'var(--mantine-color-blue-filled)', marginTop: 2 }} />
<Box style={{ flex: 1 }}>
<Text size="xs" fw={600} mb={4} tt="uppercase">
{t('update.recommendedAction', 'Recommended Action')}
</Text>
<Text size="sm">
{updateSummary.recommended_action}
</Text>
</Box>
</Group>
</Box>
)}
{/* Breaking changes warning */}
{updateSummary.any_breaking && (
<Box
style={{
background: 'var(--mantine-color-orange-light)',
padding: '12px 16px',
borderRadius: '8px',
border: '1px solid var(--mantine-color-orange-outline)',
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<WarningAmberIcon style={{ fontSize: 18, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
<Box style={{ flex: 1 }}>
<Text size="xs" fw={600} mb={4} tt="uppercase">
{t('update.breakingChangesDetected', 'Breaking Changes Detected')}
</Text>
<Text size="sm">
{t(
'update.breakingChangesMessage',
'Some versions contain breaking changes. Please review the migration guides below before updating.'
)}
</Text>
</Box>
</Group>
</Box>
)}
{/* Migration guides */}
{updateSummary.migration_guides && updateSummary.migration_guides.length > 0 && (
<>
<Divider />
<Stack gap="xs">
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
{t('update.migrationGuides', 'Migration Guides')}
</Text>
{updateSummary.migration_guides.map((guide, idx) => (
<Box
key={idx}
style={{
border: '1px solid var(--mantine-color-gray-3)',
padding: '12px 16px',
borderRadius: '8px',
background: 'var(--mantine-color-gray-0)',
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Box style={{ flex: 1 }}>
<Text fw={600} size="sm">
{t('update.version', 'Version')} {guide.version}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{guide.notes}
</Text>
</Box>
<Button
component="a"
href={guide.url}
target="_blank"
variant="light"
size="xs"
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t('update.viewGuide', 'View Guide')}
</Button>
</Group>
</Box>
))}
</Stack>
</>
)}
{/* Version details */}
<Divider />
{loading ? (
<Center py="xl">
<Stack align="center" gap="sm">
<Loader size="md" />
<Text size="sm" c="dimmed">
{t('update.loadingDetailedInfo', 'Loading detailed information...')}
</Text>
</Stack>
</Center>
) : fullUpdateInfo && fullUpdateInfo.new_versions && fullUpdateInfo.new_versions.length > 0 ? (
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
{t('update.availableUpdates', 'Available Updates')}
</Text>
<Badge variant="light" color="gray">
{fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? 'version' : 'versions'}
</Badge>
</Group>
<Stack gap="xs">
{fullUpdateInfo.new_versions.map((version, index) => {
const isExpanded = expandedVersions.has(index);
return (
<Box
key={index}
style={{
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<Group
justify="space-between"
align="center"
p="md"
style={{
cursor: 'pointer',
background: isExpanded ? 'var(--mantine-color-gray-0)' : 'transparent',
transition: 'background 0.15s ease',
}}
onClick={() => toggleVersion(index)}
>
<Group gap="md" style={{ flex: 1 }}>
<Box>
<Text fw={600} size="sm" c="dimmed" mb={2}>
{t('update.version', 'Version')}
</Text>
<Text fw={700} size="lg">
{version.version}
</Text>
</Box>
<Badge color={getPriorityColor(version.priority)} size="md">
{getPriorityLabel(version.priority)}
</Badge>
</Group>
<Group gap="xs">
<Button
component="a"
href={`https://github.com/Stirling-Tools/Stirling-PDF/releases/tag/v${version.version}`}
target="_blank"
variant="light"
size="xs"
onClick={(e) => e.stopPropagation()}
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t('update.releaseNotes', 'Release Notes')}
</Button>
{isExpanded ? (
<ExpandLessIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
) : (
<ExpandMoreIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
)}
</Group>
</Group>
<Collapse in={isExpanded}>
<Box p="md" pt={0} style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}>
<Stack gap="md" mt="md">
<Box>
<Text fw={600} size="sm" mb={6}>
{version.announcement.title}
</Text>
<Text size="sm" c="dimmed" style={{ lineHeight: 1.6 }}>
{version.announcement.message}
</Text>
</Box>
{version.compatibility.breaking_changes && (
<Box
style={{
background: 'var(--mantine-color-orange-light)',
padding: '12px',
borderRadius: '6px',
border: '1px solid var(--mantine-color-orange-outline)',
}}
>
<Group gap="xs" align="flex-start" wrap="nowrap" mb="xs">
<WarningAmberIcon style={{ fontSize: 16, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
<Text size="xs" fw={600} tt="uppercase">
{t('update.breakingChanges', 'Breaking Changes')}
</Text>
</Group>
<Text size="sm" mb="xs">
{version.compatibility.breaking_description ||
t('update.breakingChangesDefault', 'This version contains breaking changes.')}
</Text>
{version.compatibility.migration_guide_url && (
<Button
component="a"
href={version.compatibility.migration_guide_url}
target="_blank"
variant="light"
color="orange"
size="xs"
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t('update.migrationGuide', 'Migration Guide')}
</Button>
)}
</Box>
)}
</Stack>
</Box>
</Collapse>
</Box>
);
})}
</Stack>
</Stack>
) : null}
{/* Action buttons */}
<Divider />
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
{t('update.close', 'Close')}
</Button>
<Button
variant="light"
component="a"
href="https://github.com/Stirling-Tools/Stirling-PDF/releases"
target="_blank"
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
>
{t('update.viewAllReleases', 'View All Releases')}
</Button>
{downloadUrl && (
<Button
component="a"
href={downloadUrl}
target="_blank"
color="green"
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
>
{t('update.downloadLatest', 'Download Latest')}
</Button>
)}
</Group>
</Stack>
</Modal>
);
};
export default UpdateModal;
@@ -1,12 +1,10 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon, Button, Badge, Alert } from '@mantine/core';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import type { ToolPanelMode } from '@app/constants/toolPanel';
import LocalIcon from '@app/components/shared/LocalIcon';
import { updateService, UpdateSummary } from '@app/services/updateService';
import UpdateModal from '@app/components/shared/UpdateModal';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
const BANNER_DISMISSED_KEY = 'stirlingpdf_features_banner_dismissed';
@@ -24,44 +22,12 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
// Check localStorage on mount
return localStorage.getItem(BANNER_DISMISSED_KEY) === 'true';
});
const [updateSummary, setUpdateSummary] = useState<UpdateSummary | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [checkingUpdate, setCheckingUpdate] = useState(false);
// Sync local state with preference changes
useEffect(() => {
setFileLimitInput(preferences.autoUnzipFileLimit);
}, [preferences.autoUnzipFileLimit]);
// Check for updates on mount
useEffect(() => {
if (config?.appVersion && config?.machineType) {
checkForUpdate();
}
}, [config?.appVersion, config?.machineType]);
const checkForUpdate = async () => {
if (!config?.appVersion || !config?.machineType) {
return;
}
setCheckingUpdate(true);
const machineInfo = {
machineType: config.machineType,
activeSecurity: config.activeSecurity ?? false,
licenseType: config.license ?? 'NORMAL',
};
const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo);
if (summary) {
const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0;
if (isNewerVersion) {
setUpdateSummary(summary);
}
}
setCheckingUpdate(false);
};
// Check if login is disabled
const loginDisabled = !config?.enableLogin;
@@ -148,6 +114,23 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
]}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.rememberInputs', 'Remember tool inputs')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
'settings.general.rememberInputsDescription',
'Store the last settings you used for each tool on this device'
)}
</Text>
</div>
<Switch
checked={preferences.rememberFormInputs}
onChange={(event) => updatePreference('rememberFormInputs', event.currentTarget.checked)}
/>
</div>
<Tooltip
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
multiline
@@ -204,108 +187,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
</Tooltip>
</Stack>
</Paper>
{/* Update Check Section */}
{config?.appVersion && (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="sm">
{t('settings.general.updates.title', 'Software Updates')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.updates.description', 'Check for updates and view version information')}
</Text>
</div>
{updateSummary && (
<Badge
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
variant="filled"
>
{updateSummary.max_priority === 'urgent'
? t('update.urgentUpdateAvailable', 'Urgent Update')
: t('update.updateAvailable', 'Update Available')}
</Badge>
)}
</Group>
</div>
<Group justify="space-between" align="center">
<div>
<Text size="sm" c="dimmed">
{t('settings.general.updates.currentVersion', 'Current Version')}:{' '}
<Text component="span" fw={500}>
{config.appVersion}
</Text>
</Text>
{updateSummary && (
<Text size="sm" c="dimmed" mt={4}>
{t('settings.general.updates.latestVersion', 'Latest Version')}:{' '}
<Text component="span" fw={500} c="blue">
{updateSummary.latest_version}
</Text>
</Text>
)}
</div>
<Group gap="sm">
<Button
size="sm"
variant="default"
onClick={checkForUpdate}
loading={checkingUpdate}
leftSection={<LocalIcon icon="refresh-rounded" width="1rem" height="1rem" />}
>
{t('settings.general.updates.checkForUpdates', 'Check for Updates')}
</Button>
{updateSummary && (
<Button
size="sm"
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
onClick={() => setUpdateModalOpened(true)}
leftSection={<LocalIcon icon="system-update-rounded" width="1rem" height="1rem" />}
>
{t('settings.general.updates.viewDetails', 'View Details')}
</Button>
)}
</Group>
</Group>
{updateSummary?.any_breaking && (
<Alert
color="orange"
title={t('update.breakingChangesDetected', 'Breaking Changes Detected')}
styles={{
title: { fontWeight: 600 }
}}
>
<Text size="sm">
{t(
'update.breakingChangesMessage',
'Some versions contain breaking changes. Please review the migration guides before updating.'
)}
</Text>
</Alert>
)}
</Stack>
</Paper>
)}
{/* Update Modal */}
{updateSummary && config?.appVersion && config?.machineType && (
<UpdateModal
opened={updateModalOpened}
onClose={() => setUpdateModalOpened(false)}
currentVersion={config.appVersion}
updateSummary={updateSummary}
machineInfo={{
machineType: config.machineType,
activeSecurity: config.activeSecurity ?? false,
licenseType: config.license ?? 'NORMAL',
}}
/>
)}
</Stack>
);
};
@@ -1,313 +0,0 @@
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActionIcon,
Badge,
Button,
Flex,
Group,
NumberInput,
Paper,
Stack,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { BookmarkNode, createBookmarkNode } from '@app/utils/editTableOfContents';
interface BookmarkEditorProps {
bookmarks: BookmarkNode[];
onChange: (bookmarks: BookmarkNode[]) => void;
disabled?: boolean;
}
const updateTree = (
nodes: BookmarkNode[],
targetId: string,
updater: (bookmark: BookmarkNode) => BookmarkNode,
): BookmarkNode[] => {
return nodes.map(node => {
if (node.id === targetId) {
return updater(node);
}
if (node.children.length === 0) {
return node;
}
const updatedChildren = updateTree(node.children, targetId, updater);
if (updatedChildren !== node.children) {
return { ...node, children: updatedChildren };
}
return node;
});
};
const removeFromTree = (nodes: BookmarkNode[], targetId: string): BookmarkNode[] => {
return nodes
.filter(node => node.id !== targetId)
.map(node => ({
...node,
children: removeFromTree(node.children, targetId),
}));
};
const addChildToTree = (
nodes: BookmarkNode[],
parentId: string,
child: BookmarkNode,
): { nodes: BookmarkNode[]; added: boolean } => {
let added = false;
const next = nodes.map(node => {
if (node.id === parentId) {
added = true;
return { ...node, expanded: true, children: [...node.children, child] };
}
if (node.children.length === 0) {
return node;
}
const result = addChildToTree(node.children, parentId, child);
if (result.added) {
added = true;
return { ...node, children: result.nodes };
}
return node;
});
return { nodes: added ? next : nodes, added };
};
const addSiblingInTree = (
nodes: BookmarkNode[],
targetId: string,
sibling: BookmarkNode,
): { nodes: BookmarkNode[]; added: boolean } => {
let added = false;
const result: BookmarkNode[] = [];
nodes.forEach(node => {
let currentNode = node;
if (!added && node.children.length > 0) {
const childResult = addSiblingInTree(node.children, targetId, sibling);
if (childResult.added) {
added = true;
currentNode = { ...node, children: childResult.nodes };
}
}
result.push(currentNode);
if (!added && node.id === targetId) {
result.push(sibling);
added = true;
}
});
return { nodes: added ? result : nodes, added };
};
export default function BookmarkEditor({ bookmarks, onChange, disabled }: BookmarkEditorProps) {
const { t } = useTranslation();
const handleAddTopLevel = () => {
const newBookmark = createBookmarkNode({ title: t('editTableOfContents.editor.defaultTitle', 'New bookmark') });
onChange([...bookmarks, newBookmark]);
};
const handleTitleChange = (id: string, value: string) => {
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, title: value })));
};
const handlePageChange = (id: string, value: number | string) => {
const page = typeof value === 'number' ? value : parseInt(value, 10);
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, pageNumber: Number.isFinite(page) && page > 0 ? page : 1 })));
};
const handleToggle = (id: string) => {
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, expanded: !bookmark.expanded })));
};
const handleRemove = (id: string) => {
const confirmation = t(
'editTableOfContents.editor.confirmRemove',
'Remove this bookmark and all of its children?'
);
if (window.confirm(confirmation)) {
onChange(removeFromTree(bookmarks, id));
}
};
const handleAddChild = (parentId: string) => {
const child = createBookmarkNode({ title: t('editTableOfContents.editor.defaultChildTitle', 'Child bookmark') });
const { nodes, added } = addChildToTree(bookmarks, parentId, child);
onChange(added ? nodes : [...bookmarks, child]);
};
const handleAddSibling = (targetId: string) => {
const sibling = createBookmarkNode({ title: t('editTableOfContents.editor.defaultSiblingTitle', 'New bookmark') });
const { nodes, added } = addSiblingInTree(bookmarks, targetId, sibling);
onChange(added ? nodes : [...bookmarks, sibling]);
};
const renderBookmark = (bookmark: BookmarkNode, level = 0) => {
const hasChildren = bookmark.children.length > 0;
const chevronIcon = bookmark.expanded ? 'expand-more-rounded' : 'chevron-right-rounded';
return (
<Paper
key={bookmark.id}
radius="md"
withBorder
p="md"
style={{
borderColor: 'var(--border-default)',
background: level === 0 ? 'var(--bg-surface)' : 'var(--bg-muted)',
}}
>
<Stack gap="sm">
<Flex align="flex-start" justify="space-between" gap="md">
<Group gap="sm" align="flex-start">
<ActionIcon
variant="subtle"
color="gray"
onClick={() => hasChildren && handleToggle(bookmark.id)}
disabled={disabled || !hasChildren}
aria-label={t('editTableOfContents.editor.actions.toggle', 'Toggle children')}
style={{ marginTop: 4 }}
>
<LocalIcon icon={chevronIcon} />
</ActionIcon>
<Stack gap={2}>
<Group gap="xs" align="center">
<Text fw={600}>{bookmark.title || t('editTableOfContents.editor.untitled', 'Untitled bookmark')}</Text>
{level > 0 && (
<Badge size="xs" variant="light" color="blue">
{t('editTableOfContents.editor.childBadge', 'Child')}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t('editTableOfContents.editor.pagePreview', { page: bookmark.pageNumber })}
</Text>
</Stack>
</Group>
<Group gap="xs">
<Tooltip label={t('editTableOfContents.editor.actions.addChild', 'Add child bookmark')}>
<ActionIcon
variant="subtle"
color="green"
onClick={() => handleAddChild(bookmark.id)}
disabled={disabled}
>
<LocalIcon icon="subdirectory-arrow-right-rounded" />
</ActionIcon>
</Tooltip>
<Tooltip label={t('editTableOfContents.editor.actions.addSibling', 'Add sibling bookmark')}>
<ActionIcon
variant="subtle"
color="blue"
onClick={() => handleAddSibling(bookmark.id)}
disabled={disabled}
>
<LocalIcon icon="add-rounded" />
</ActionIcon>
</Tooltip>
<Tooltip label={t('editTableOfContents.editor.actions.remove', 'Remove bookmark')}>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleRemove(bookmark.id)}
disabled={disabled}
>
<LocalIcon icon="delete-rounded" />
</ActionIcon>
</Tooltip>
</Group>
</Flex>
{bookmark.expanded && (
<Stack gap="sm">
<TextInput
size="sm"
label={t('editTableOfContents.editor.field.title', 'Bookmark title')}
value={bookmark.title}
onChange={event => handleTitleChange(bookmark.id, event.currentTarget.value)}
disabled={disabled}
/>
<NumberInput
size="sm"
label={t('editTableOfContents.editor.field.page', 'Target page number')}
min={1}
clampBehavior="strict"
value={bookmark.pageNumber}
onChange={value => handlePageChange(bookmark.id, value ?? 1)}
disabled={disabled}
/>
</Stack>
)}
{bookmark.expanded && hasChildren && (
<Stack gap="sm" pl="lg" style={{ borderLeft: '1px solid var(--border-default)' }}>
{bookmark.children.map(child => (
<Fragment key={child.id}>{renderBookmark(child, level + 1)}</Fragment>
))}
</Stack>
)}
</Stack>
</Paper>
);
};
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600}>{t('editTableOfContents.editor.heading', 'Bookmark editor')}</Text>
<Text size="sm" c="dimmed">
{t('editTableOfContents.editor.description', 'Add, nest, and reorder bookmarks to craft your PDF outline.')}
</Text>
</div>
<Button
variant="default"
color="blue"
leftSection={<LocalIcon icon="bookmark-add-rounded" />}
onClick={handleAddTopLevel}
disabled={disabled}
>
{t('editTableOfContents.editor.addTopLevel', 'Add top-level bookmark')}
</Button>
</Group>
{bookmarks.length === 0 ? (
<Paper withBorder radius="md" ta="center" py="xl">
<Stack gap="xs" align="center" px="lg">
<LocalIcon icon="bookmark-add-rounded" style={{ fontSize: '2.25rem' }} />
<Text fw={600}>{t('editTableOfContents.editor.empty.title', 'No bookmarks yet')}</Text>
<Text size="sm" c="dimmed" maw={420}>
{t('editTableOfContents.editor.empty.description', 'Import existing bookmarks or start by adding your first entry.')}
</Text>
<Button
variant="subtle"
color="blue"
leftSection={<LocalIcon icon="add-rounded" />}
onClick={handleAddTopLevel}
disabled={disabled}
>
{t('editTableOfContents.editor.empty.action', 'Add first bookmark')}
</Button>
</Stack>
</Paper>
) : (
<Stack gap="sm">
{bookmarks.map(bookmark => renderBookmark(bookmark))}
</Stack>
)}
</Stack>
);
}
@@ -1,187 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Divider,
FileButton,
Stack,
Switch,
Text,
Tooltip,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { BookmarkNode } from '@app/utils/editTableOfContents';
interface EditTableOfContentsSettingsProps {
bookmarks: BookmarkNode[];
replaceExisting: boolean;
onReplaceExistingChange: (value: boolean) => void;
onSelectFiles: () => void;
onLoadFromPdf: () => void;
onImportJson: (file: File) => void;
onImportClipboard: () => void;
onExportJson: () => void;
onExportClipboard: () => void;
isLoading: boolean;
loadError?: string | null;
canReadClipboard: boolean;
canWriteClipboard: boolean;
disabled?: boolean;
selectedFileName?: string;
}
export default function EditTableOfContentsSettings({
bookmarks,
replaceExisting,
onReplaceExistingChange,
onSelectFiles,
onLoadFromPdf,
onImportJson,
onImportClipboard,
onExportJson,
onExportClipboard,
isLoading,
loadError,
canReadClipboard,
canWriteClipboard,
disabled,
selectedFileName,
}: EditTableOfContentsSettingsProps) {
const { t } = useTranslation();
const infoLines = useMemo(() => ([
t('editTableOfContents.info.line1', 'Each bookmark needs a descriptive title and the page it should open.'),
t('editTableOfContents.info.line2', 'Use child bookmarks to build a hierarchy for chapters, sections, or subsections.'),
t('editTableOfContents.info.line3', 'Import bookmarks from the selected PDF or from a JSON file to save time.'),
]), [t]);
return (
<Stack gap="md">
<Stack gap="xs">
<Text size="sm" fw={500}>{t('editTableOfContents.actions.source', 'Load bookmarks')}</Text>
<Text size="xs" c="dimmed">
{selectedFileName
? t('editTableOfContents.actions.selectedFile', { file: selectedFileName })
: t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.')}
</Text>
</Stack>
<Stack gap="sm">
<Button
variant="light"
leftSection={<LocalIcon icon="folder-rounded" />}
onClick={onSelectFiles}
fullWidth
>
{selectedFileName
? t('editTableOfContents.workbench.changeFile', 'Change PDF')
: t('editTableOfContents.workbench.selectFile', 'Select PDF')}
</Button>
<Tooltip label={!selectedFileName ? t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.') : ''} disabled={Boolean(selectedFileName)}>
<Button
variant="default"
leftSection={<LocalIcon icon="picture-as-pdf-rounded" />}
onClick={onLoadFromPdf}
loading={isLoading}
disabled={disabled || !selectedFileName}
fullWidth
>
{t('editTableOfContents.actions.loadFromPdf', 'Load from PDF')}
</Button>
</Tooltip>
<FileButton
onChange={file => file && onImportJson(file)}
accept="application/json"
disabled={disabled}
>
{(props) => (
<Button
{...props}
variant="default"
leftSection={<LocalIcon icon="upload-rounded" />}
disabled={disabled}
fullWidth
>
{t('editTableOfContents.actions.importJson', 'Import JSON')}
</Button>
)}
</FileButton>
<Tooltip
label={canReadClipboard ? '' : t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access is not available in this browser.')}
disabled={canReadClipboard}
>
<Button
variant="default"
leftSection={<LocalIcon icon="content-paste-rounded" />}
onClick={onImportClipboard}
disabled={disabled || !canReadClipboard}
fullWidth
>
{t('editTableOfContents.actions.importClipboard', 'Paste from clipboard')}
</Button>
</Tooltip>
</Stack>
{loadError && (
<Alert color="red" radius="md" icon={<LocalIcon icon="error-outline-rounded" />}>
{loadError}
</Alert>
)}
<Divider />
<Stack gap="xs">
<Text size="sm" fw={500}>{t('editTableOfContents.actions.export', 'Export bookmarks')}</Text>
</Stack>
<Stack gap="sm">
<Button
variant="default"
leftSection={<LocalIcon icon="download-rounded" />}
onClick={onExportJson}
disabled={disabled || bookmarks.length === 0}
fullWidth
>
{t('editTableOfContents.actions.exportJson', 'Download JSON')}
</Button>
<Tooltip
label={canWriteClipboard ? '' : t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access is not available in this browser.')}
disabled={canWriteClipboard}
>
<Button
variant="default"
leftSection={<LocalIcon icon="content-copy-rounded" />}
onClick={onExportClipboard}
disabled={disabled || bookmarks.length === 0 || !canWriteClipboard}
fullWidth
>
{t('editTableOfContents.actions.exportClipboard', 'Copy to clipboard')}
</Button>
</Tooltip>
</Stack>
<Divider />
<Switch
checked={replaceExisting}
onChange={(event) => onReplaceExistingChange(event.currentTarget.checked)}
label={t('editTableOfContents.settings.replaceExisting', 'Replace existing bookmarks')}
description={t('editTableOfContents.settings.replaceExistingHint', 'When disabled, the new outline is appended after the current bookmarks.')}
disabled={disabled}
/>
<Stack gap="xs">
{infoLines.map((line, index) => (
<Text key={index} size="sm" c="dimmed">
{line}
</Text>
))}
</Stack>
</Stack>
);
}
@@ -1,203 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Box,
Button,
Card,
Divider,
Group,
Stack,
Text,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { BookmarkNode } from '@app/utils/editTableOfContents';
import ErrorNotification from '@app/components/tools/shared/ErrorNotification';
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
export interface EditTableOfContentsWorkbenchViewData {
bookmarks: BookmarkNode[];
selectedFileName?: string;
disabled: boolean;
files: File[];
thumbnails: (string | undefined)[];
downloadUrl: string | null;
downloadFilename: string | null;
errorMessage: string | null;
isGeneratingThumbnails: boolean;
isExecuteDisabled: boolean;
isExecuting: boolean;
onClearError: () => void;
onBookmarksChange: (bookmarks: BookmarkNode[]) => void;
onExecute: () => void;
onUndo: () => void;
onFileClick: (file: File) => void;
}
interface EditTableOfContentsWorkbenchViewProps {
data: EditTableOfContentsWorkbenchViewData | null;
}
const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbenchViewProps) => {
const { t } = useTranslation();
if (!data) {
return (
<Box p="xl">
<Card withBorder radius="md">
<Stack gap="sm">
<Text fw={600}>{t('editTableOfContents.workbench.empty.title', 'Open the tool to start editing')}</Text>
<Text size="sm" c="dimmed">
{t('editTableOfContents.workbench.empty.description', 'Select the Edit Table of Contents tool to load its workspace.')}
</Text>
</Stack>
</Card>
</Box>
);
}
const {
bookmarks,
selectedFileName,
disabled,
files,
thumbnails,
downloadUrl,
downloadFilename,
errorMessage,
isGeneratingThumbnails,
isExecuteDisabled,
isExecuting,
onClearError,
onBookmarksChange,
onExecute,
onUndo,
onFileClick,
} = data;
const previewFiles = useMemo(
() =>
files?.map((file, index) => ({
file,
thumbnail: thumbnails[index],
})) ?? [],
[files, thumbnails]
);
const showResults = Boolean(
previewFiles.length > 0 || downloadUrl || errorMessage
);
return (
<Box
p="lg"
style={{
width: '100%',
height: '100%',
overflowY: 'auto',
background: 'var(--bg-raised)',
}}
>
<Stack gap="xl" maw={1200} mx="auto">
<Stack gap={4}>
<Text size="xl" fw={700}>
{t('home.editTableOfContents.title', 'Edit Table of Contents')}
</Text>
<Text size="sm" c="dimmed">
{t('editTableOfContents.workbench.subtitle', 'Import bookmarks, build hierarchies, and apply the outline without cramped side panels.')}
</Text>
</Stack>
<Card
withBorder
radius="md"
p="xl"
style={{
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
boxShadow: 'var(--shadow-md)',
}}
>
<Stack gap="md">
<Stack gap={2}>
<Text fw={600}>{t('editTableOfContents.editor.heading', 'Bookmark editor')}</Text>
<Text size="sm" c="dimmed">
{selectedFileName
? t('editTableOfContents.actions.selectedFile', { file: selectedFileName })
: t('editTableOfContents.workbench.filePrompt', 'Select a PDF from your library or upload a new one to begin.')}
</Text>
</Stack>
<BookmarkEditor bookmarks={bookmarks} onChange={onBookmarksChange} disabled={disabled} />
<Divider />
<Group justify="flex-end">
<Button
leftSection={<LocalIcon icon="menu-book-rounded" />}
color="blue"
onClick={onExecute}
disabled={isExecuteDisabled}
loading={isExecuting}
>
{t('editTableOfContents.submit', 'Apply table of contents')}
</Button>
</Group>
</Stack>
</Card>
{showResults && (
<Card
withBorder
radius="md"
p="xl"
style={{
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
boxShadow: 'var(--shadow-md)',
}}
>
<Stack gap="md">
<Stack gap={4}>
<Text fw={600}>{t('editTableOfContents.results.title', 'Updated PDF with bookmarks')}</Text>
<Text size="sm" c="dimmed">
{t('editTableOfContents.results.subtitle', 'Download the processed file or undo the operation below.')}
</Text>
</Stack>
<ErrorNotification error={errorMessage} onClose={onClearError} />
{previewFiles.length > 0 && (
<ResultsPreview
files={previewFiles}
onFileClick={onFileClick}
isGeneratingThumbnails={isGeneratingThumbnails}
/>
)}
<Group justify="flex-end" gap="sm">
{downloadUrl && (
<Button
component="a"
href={downloadUrl}
download={downloadFilename ?? undefined}
leftSection={<LocalIcon icon='download-rounded' />}
>
{t('download', 'Download')}
</Button>
)}
<Button
variant="outline"
leftSection={<LocalIcon icon="rotate-left" />}
onClick={onUndo}
disabled={isExecuting}
>
{t('undo', 'Undo')}
</Button>
</Group>
</Stack>
</Card>
)}
</Stack>
</Box>
);
};
export default EditTableOfContentsWorkbenchView;
@@ -191,6 +191,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
height: '100%',
width: '100%',
position: 'relative',
overflow: 'hidden',
flex: 1,
minHeight: 0,
minWidth: 0,
@@ -286,6 +287,8 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
minHeight: 0,
minWidth: 0,
contain: 'strict',
display: 'flex',
justifyContent: 'center',
}}
>
<Scroller
@@ -7,7 +7,9 @@ import {
determineAutoZoom,
DEFAULT_FALLBACK_ZOOM,
DEFAULT_VISIBILITY_THRESHOLD,
measureRenderedPageRect,
useFitWidthResize,
ZoomViewport,
} from '@app/utils/viewerZoom';
import { getFirstPageAspectRatioFromStub } from '@app/utils/pageMetadata';
@@ -71,6 +73,18 @@ export function ZoomAPIBridge() {
}
}, [spreadMode, zoomState?.zoomLevel, scheduleAutoZoom, requestFitWidth]);
const getViewportSnapshot = useCallback((): ZoomViewport | null => {
if (!zoomState || typeof zoomState !== 'object') {
return null;
}
if ('viewport' in zoomState) {
const candidate = (zoomState as { viewport?: ZoomViewport | null }).viewport;
return candidate ?? null;
}
return null;
}, [zoomState]);
const isManagedZoom =
!!zoom &&
@@ -105,7 +119,7 @@ export function ZoomAPIBridge() {
}
const fitWidthZoom = zoomState.currentZoomLevel;
if (!fitWidthZoom || fitWidthZoom <= 0 || fitWidthZoom === 1) {
if (!fitWidthZoom || fitWidthZoom <= 0) {
return;
}
@@ -123,23 +137,37 @@ export function ZoomAPIBridge() {
const pagesPerSpread = currentSpreadMode !== SpreadMode.None ? 2 : 1;
const metadataAspectRatio = getFirstPageAspectRatioFromStub(firstFileStub);
const viewport = getViewportSnapshot();
if (cancelled) {
return;
}
const viewportWidth = window.innerWidth ?? 0;
const viewportHeight = window.innerHeight ?? 0;
const metrics = viewport ?? {};
const viewportWidth =
metrics.clientWidth ?? metrics.width ?? window.innerWidth ?? 0;
const viewportHeight =
metrics.clientHeight ?? metrics.height ?? window.innerHeight ?? 0;
if (viewportWidth <= 0 || viewportHeight <= 0) {
return;
}
const pageRect = await measureRenderedPageRect({
shouldCancel: () => cancelled,
});
if (cancelled) {
return;
}
const decision = determineAutoZoom({
viewportWidth,
viewportHeight,
fitWidthZoom,
pagesPerSpread,
pageRect: undefined,
pageRect: pageRect
? { width: pageRect.width, height: pageRect.height }
: undefined,
metadataAspectRatio: metadataAspectRatio ?? null,
visibilityThreshold: DEFAULT_VISIBILITY_THRESHOLD,
fallbackZoom: DEFAULT_FALLBACK_ZOOM,
@@ -169,6 +197,7 @@ export function ZoomAPIBridge() {
firstFileId,
firstFileStub,
requestFitWidth,
getViewportSnapshot,
autoZoomTick,
spreadMode,
triggerImmediateZoomUpdate,
@@ -39,9 +39,6 @@ export interface AppConfig {
license?: string;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
appVersion?: string;
machineType?: string;
activeSecurity?: boolean;
error?: string;
}
@@ -31,7 +31,6 @@ import AddWatermark from "@app/tools/AddWatermark";
import AddStamp from "@app/tools/AddStamp";
import AddAttachments from "@app/tools/AddAttachments";
import Merge from '@app/tools/Merge';
import EditTableOfContents from '@app/tools/EditTableOfContents';
import Repair from "@app/tools/Repair";
import AutoRename from "@app/tools/AutoRename";
import SingleLargePage from "@app/tools/SingleLargePage";
@@ -64,7 +63,6 @@ import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermiss
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
import { mergeOperationConfig } from '@app/hooks/tools/merge/useMergeOperation';
import { editTableOfContentsOperationConfig } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation';
import { autoRenameOperationConfig } from "@app/hooks/tools/autoRename/useAutoRenameOperation";
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
@@ -347,23 +345,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: ChangeMetadataSingleStep,
synonyms: getSynonyms(t, "changeMetadata")
},
editTableOfContents: {
icon: <LocalIcon icon="toc-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.editTableOfContents.title", "Edit Table of Contents"),
component: EditTableOfContents,
description: t(
"home.editTableOfContents.desc",
"Add or edit bookmarks and table of contents in PDF documents"
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
maxFiles: 1,
endpoints: ["edit-table-of-contents"],
operationConfig: editTableOfContentsOperationConfig,
automationSettings: null,
supportsAutomate: false,
synonyms: getSynonyms(t, "editTableOfContents"),
},
// Page Formatting
crop: {
@@ -708,6 +689,16 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "addImage"),
automationSettings: null
},
editTableOfContents: {
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.editTableOfContents.title", "Edit Table of Contents"),
component: null,
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"),
automationSettings: null
},
scannerEffect: {
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.scannerEffect.title", "Scanner Effect"),
@@ -2,10 +2,13 @@ import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAddPasswordParameters, defaultParameters, AddPasswordParametersHook } from '@app/hooks/tools/addPassword/useAddPasswordParameters';
import { defaultParameters as defaultChangePermissionsParameters, ChangePermissionsParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderAddPasswordHook = () => renderHook(() => useAddPasswordParameters(), { wrapper: PreferencesTestWrapper });
describe('useAddPasswordParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
@@ -15,7 +18,7 @@ describe('useAddPasswordParameters', () => {
{ paramName: 'ownerPassword' as const, value: 'owner-password' },
{ paramName: 'keyLength' as const, value: 256 }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
act(() => {
result.current.updateParameter(paramName, value);
@@ -28,7 +31,7 @@ describe('useAddPasswordParameters', () => {
{ paramName: 'preventAssembly' as const },
{ paramName: 'preventPrinting' as const }
])('should update boolean permission parameter $paramName', ({ paramName }) => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
act(() => {
result.current.permissions.updateParameter(paramName, true);
@@ -38,7 +41,7 @@ describe('useAddPasswordParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
// First, change some parameters
act(() => {
@@ -60,7 +63,7 @@ describe('useAddPasswordParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
expect(result.current.getEndpointName()).toBe('add-password');
});
@@ -112,7 +115,7 @@ describe('useAddPasswordParameters', () => {
}
}
])('should validate parameters correctly $description', ({ setup }) => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
@@ -126,7 +129,7 @@ describe('useAddPasswordParameters', () => {
});
test.each(Object.keys(defaultChangePermissionsParameters) as Array<keyof ChangePermissionsParameters>)('should handle boolean restriction parameter %s', (param) => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
act(() => {
result.current.resetParameters();
@@ -137,7 +140,7 @@ describe('useAddPasswordParameters', () => {
});
test('should handle mixed parameter types in updateParameter', () => {
const { result } = renderHook(() => useAddPasswordParameters());
const { result } = renderAddPasswordHook();
act(() => {
result.current.updateParameter('password', 'test-string');
@@ -1,10 +1,13 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAdjustPageScaleParameters, defaultParameters, PageSize, AdjustPageScaleParametersHook } from '@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderAdjustPageScaleHook = () => renderHook(() => useAdjustPageScaleParameters(), { wrapper: PreferencesTestWrapper });
describe('useAdjustPageScaleParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
expect(result.current.parameters.scaleFactor).toBe(1.0);
@@ -19,7 +22,7 @@ describe('useAdjustPageScaleParameters', () => {
{ paramName: 'pageSize' as const, value: PageSize.LETTER },
{ paramName: 'pageSize' as const, value: PageSize.LEGAL },
])('should update parameter $paramName to $value', ({ paramName, value }) => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
act(() => {
result.current.updateParameter(paramName, value);
@@ -29,7 +32,7 @@ describe('useAdjustPageScaleParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
// First, change some parameters
act(() => {
@@ -49,7 +52,7 @@ describe('useAdjustPageScaleParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
expect(result.current.getEndpointName()).toBe('scale-pages');
});
@@ -96,7 +99,7 @@ describe('useAdjustPageScaleParameters', () => {
expected: false
}
])('should validate parameters correctly $description', ({ setup, expected }) => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
act(() => {
setup(result.current);
@@ -106,7 +109,7 @@ describe('useAdjustPageScaleParameters', () => {
});
test('should handle all PageSize enum values', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
Object.values(PageSize).forEach(pageSize => {
act(() => {
@@ -119,7 +122,7 @@ describe('useAdjustPageScaleParameters', () => {
});
test('should handle scale factor edge cases', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
const { result } = renderAdjustPageScaleHook();
// Test very small valid scale factor
act(() => {
@@ -2,10 +2,13 @@ import { renderHook, act } from '@testing-library/react';
import { defaultParameters, useChangeMetadataParameters } from '@app/hooks/tools/changeMetadata/useChangeMetadataParameters';
import { TrappedStatus } from '@app/types/metadata';
import { describe, expect, test } from 'vitest';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderChangeMetadataHook = () => renderHook(() => useChangeMetadataParameters(), { wrapper: PreferencesTestWrapper });
describe('useChangeMetadataParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
@@ -23,7 +26,7 @@ describe('useChangeMetadataParameters', () => {
{ paramName: 'trapped', value: TrappedStatus.TRUE },
{ paramName: 'deleteAll', value: true },
] as const)('should update $paramName parameter', ({ paramName, value }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
result.current.updateParameter(paramName, value);
@@ -48,7 +51,7 @@ describe('useChangeMetadataParameters', () => {
{ description: 'no meaningful content', updates: {}, expected: false },
{ description: 'whitespace only', updates: { title: ' ', author: ' ' }, expected: false },
])('should validate correctly when $description', ({ updates, expected }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
Object.entries(updates).forEach(([key, value]) => {
@@ -64,7 +67,7 @@ describe('useChangeMetadataParameters', () => {
{ description: 'valid modification date', updates: { title: 'Test', modificationDate: new Date('2025/01/17 14:30:00') }, expected: true },
{ description: 'empty dates are valid', updates: { title: 'Test', creationDate: null, modificationDate: null }, expected: true },
])('should validate dates correctly with $description', ({ updates, expected }) => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
Object.entries(updates).forEach(([key, value]) => {
@@ -78,7 +81,7 @@ describe('useChangeMetadataParameters', () => {
describe('custom metadata', () => {
test('should add custom metadata with sequential IDs', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
result.current.addCustomMetadata();
@@ -93,7 +96,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should remove custom metadata by ID', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
result.current.addCustomMetadata();
@@ -109,7 +112,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should update custom metadata by ID', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
result.current.addCustomMetadata();
@@ -129,7 +132,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should validate with custom metadata', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
act(() => {
result.current.addCustomMetadata();
@@ -145,7 +148,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should generate unique IDs for multiple custom entries', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
for (let i = 0; i < 3; i++) {
act(() => {
@@ -161,7 +164,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useChangeMetadataParameters());
const { result } = renderChangeMetadataHook();
expect(result.current.getEndpointName()).toBe('update-metadata');
});
@@ -1,16 +1,19 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useChangePermissionsParameters, defaultParameters, ChangePermissionsParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderChangePermissionsHook = () => renderHook(() => useChangePermissionsParameters(), { wrapper: PreferencesTestWrapper });
describe('useChangePermissionsParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const { result } = renderChangePermissionsHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update individual boolean parameters', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const { result } = renderChangePermissionsHook();
act(() => {
result.current.updateParameter('preventAssembly', true);
@@ -28,7 +31,7 @@ describe('useChangePermissionsParameters', () => {
});
test('should update all permission parameters', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const { result } = renderChangePermissionsHook();
const permissionKeys = Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>;
@@ -56,7 +59,7 @@ describe('useChangePermissionsParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const { result } = renderChangePermissionsHook();
// First, change some parameters
act(() => {
@@ -78,13 +81,13 @@ describe('useChangePermissionsParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const { result } = renderChangePermissionsHook();
expect(result.current.getEndpointName()).toBe('add-password');
});
test('should always validate as true', () => {
const { result } = renderHook(() => useChangePermissionsParameters());
const { result } = renderChangePermissionsHook();
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
@@ -6,13 +6,16 @@ import { describe, test, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
import { FIT_OPTIONS } from '@app/constants/convertConstants';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderConvertHook = () => renderHook(() => useConvertParameters(), { wrapper: PreferencesTestWrapper });
describe('useConvertParameters', () => {
describe('Parameter Management', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
expect(result.current.parameters.fromExtension).toBe('');
expect(result.current.parameters.toExtension).toBe('');
@@ -28,7 +31,7 @@ describe('useConvertParameters', () => {
});
test('should update individual parameters', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -39,7 +42,7 @@ describe('useConvertParameters', () => {
});
test('should update nested image options', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('imageOptions', {
@@ -61,7 +64,7 @@ describe('useConvertParameters', () => {
});
test('should update nested HTML options', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('htmlOptions', {
@@ -73,7 +76,7 @@ describe('useConvertParameters', () => {
});
test('should update nested email options', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('emailOptions', {
@@ -91,7 +94,7 @@ describe('useConvertParameters', () => {
});
test('should update nested PDF/A options', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('pdfaOptions', {
@@ -103,7 +106,7 @@ describe('useConvertParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -124,7 +127,7 @@ describe('useConvertParameters', () => {
describe('Parameter Validation', () => {
test('should validate parameters correctly', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
// No parameters - should be invalid
expect(result.current.validateParameters()).toBe(false);
@@ -143,7 +146,7 @@ describe('useConvertParameters', () => {
});
test('should validate unsupported conversions', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -158,7 +161,7 @@ describe('useConvertParameters', () => {
describe('Endpoint Generation', () => {
test('should generate correct endpoint names', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -170,7 +173,7 @@ describe('useConvertParameters', () => {
});
test('should generate correct endpoint URLs', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -182,7 +185,7 @@ describe('useConvertParameters', () => {
});
test('should return empty strings for invalid conversions', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
act(() => {
result.current.updateParameter('fromExtension', 'invalid');
@@ -197,7 +200,7 @@ describe('useConvertParameters', () => {
describe('Available Extensions', () => {
test('should return available extensions for valid source format', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const availableExtensions = result.current.getAvailableToExtensions('pdf');
@@ -207,7 +210,7 @@ describe('useConvertParameters', () => {
});
test('should return empty array for invalid source format', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const availableExtensions = result.current.getAvailableToExtensions('invalid');
@@ -219,7 +222,7 @@ describe('useConvertParameters', () => {
});
test('should return empty array for empty source format', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const availableExtensions = result.current.getAvailableToExtensions('');
@@ -137,6 +137,7 @@ export const useConvertParameters = (): ConvertParametersHook => {
defaultParameters,
endpointName: getEndpointName,
validateFn: validateParameters,
storageKey: 'convert',
}), []);
const baseHook = useBaseParameters(config);
@@ -6,13 +6,16 @@
import { describe, test, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderConvertHook = () => renderHook(() => useConvertParameters(), { wrapper: PreferencesTestWrapper });
describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Single File Detection', () => {
test('should detect single file extension and set auto-target', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const pdfFile = [{ name: 'document.pdf' }];
@@ -27,7 +30,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle unknown file types with file-to-pdf fallback', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const unknownFile = [{ name: 'document.xyz' }, { name: 'image.jpggg' }];
@@ -41,7 +44,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle files without extensions', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const noExtFile = [{ name: 'document' }];
@@ -59,7 +62,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Multiple Identical Files', () => {
test('should detect multiple PDF files and set auto-target', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const pdfFiles = [
{ name: 'doc1.pdf' },
@@ -78,7 +81,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle multiple unknown file types with fallback', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const unknownFiles = [
{ name: 'file1.xyz' },
@@ -98,7 +101,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Smart Detection - All Images', () => {
test('should detect all image files and enable smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const imageFiles = [
{ name: 'photo1.jpg' },
@@ -117,7 +120,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle mixed case image extensions', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const imageFiles = [
{ name: 'photo1.JPG' },
@@ -136,7 +139,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Smart Detection - All Web Files', () => {
test('should detect all web files and enable web smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const webFiles = [
{ name: 'page1.html' },
@@ -154,7 +157,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle mixed case web extensions', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const webFiles = [
{ name: 'page1.HTML' },
@@ -170,7 +173,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should detect multiple web files and enable web smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const zipFiles = [
{ name: 'site1.zip' },
@@ -191,7 +194,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Smart Detection - Mixed File Types', () => {
test('should detect mixed file types and enable smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const mixedFiles = [
{ name: 'document.pdf' },
@@ -210,7 +213,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should detect mixed images and documents as mixed type', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const mixedFiles = [
{ name: 'photo.jpg' },
@@ -227,7 +230,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle mixed with unknown file types', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const mixedFiles = [
{ name: 'document.pdf' },
@@ -247,7 +250,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Smart Detection Endpoint Resolution', () => {
test('should return correct endpoint for image smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const imageFiles = [
{ name: 'photo1.jpg' },
@@ -263,7 +266,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should return correct endpoint for web smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const webFiles = [
{ name: 'page1.html' },
@@ -279,7 +282,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should return correct endpoint for mixed smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const mixedFiles = [
{ name: 'document.pdf' },
@@ -298,7 +301,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Auto-Target Selection Logic', () => {
test('should select single available target automatically', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
// Markdown has only one conversion target (PDF)
const mdFile = [{ name: 'readme.md' }];
@@ -312,7 +315,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should not auto-select when multiple targets available', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
// PDF has multiple conversion targets, so no auto-selection
const pdfFile = [{ name: 'document.pdf' }];
@@ -330,7 +333,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Edge Cases', () => {
test('should handle empty file names', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const emptyFiles = [{ name: '' }];
@@ -343,7 +346,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle malformed file objects', () => {
const { result } = renderHook(() => useConvertParameters());
const { result } = renderConvertHook();
const malformedFiles: Array<{name: string}> = [
{ name: 'valid.pdf' },
@@ -1,31 +0,0 @@
import { useTranslation } from 'react-i18next';
import { ToolType, type ToolOperationConfig, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { EditTableOfContentsParameters } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters';
import { serializeBookmarkNodes } from '@app/utils/editTableOfContents';
const buildFormData = (parameters: EditTableOfContentsParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('replaceExisting', String(parameters.replaceExisting));
formData.append('bookmarkData', JSON.stringify(serializeBookmarkNodes(parameters.bookmarks)));
return formData;
};
export const editTableOfContentsOperationConfig: ToolOperationConfig<EditTableOfContentsParameters> = {
toolType: ToolType.singleFile,
operationType: 'editTableOfContents',
endpoint: '/api/v1/general/edit-table-of-contents',
buildFormData,
};
export const useEditTableOfContentsOperation = () => {
const { t } = useTranslation();
return useToolOperation<EditTableOfContentsParameters>({
...editTableOfContentsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('editTableOfContents.error.failed', 'Failed to update the table of contents')
),
});
};
@@ -1,37 +0,0 @@
import { useCallback } from 'react';
import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { BookmarkNode } from '@app/utils/editTableOfContents';
export interface EditTableOfContentsParameters {
replaceExisting: boolean;
bookmarks: BookmarkNode[];
}
export interface EditTableOfContentsParametersHook extends BaseParametersHook<EditTableOfContentsParameters> {
setBookmarks: (bookmarks: BookmarkNode[]) => void;
}
const defaultParameters: EditTableOfContentsParameters = {
replaceExisting: true,
bookmarks: [],
};
export const useEditTableOfContentsParameters = (): EditTableOfContentsParametersHook => {
const base = useBaseParameters<EditTableOfContentsParameters>({
defaultParameters,
endpointName: 'edit-table-of-contents',
});
const setBookmarks = useCallback((bookmarks: BookmarkNode[]) => {
base.setParameters(prev => ({
...prev,
bookmarks,
}));
}, [base.setParameters]);
return {
...base,
setBookmarks,
};
};
@@ -1,10 +1,13 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMergeParameters, defaultParameters } from '@app/hooks/tools/merge/useMergeParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderMergeHook = () => renderHook(() => useMergeParameters(), { wrapper: PreferencesTestWrapper });
describe('useMergeParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useMergeParameters());
const { result } = renderMergeHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
@@ -15,7 +18,7 @@ describe('useMergeParameters', () => {
{ paramName: 'generateTableOfContents' as const, value: true },
{ paramName: 'generateTableOfContents' as const, value: false }
])('should update parameter $paramName to $value', ({ paramName, value }) => {
const { result } = renderHook(() => useMergeParameters());
const { result } = renderMergeHook();
act(() => {
result.current.updateParameter(paramName, value);
@@ -25,7 +28,7 @@ describe('useMergeParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useMergeParameters());
const { result } = renderMergeHook();
// First, change some parameters
act(() => {
@@ -45,7 +48,7 @@ describe('useMergeParameters', () => {
});
test('should validate parameters correctly - always returns true', () => {
const { result } = renderHook(() => useMergeParameters());
const { result } = renderMergeHook();
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
@@ -1,10 +1,13 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRedactParameters, defaultParameters } from '@app/hooks/tools/redact/useRedactParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderRedactHook = () => renderHook(() => useRedactParameters(), { wrapper: PreferencesTestWrapper });
describe('useRedactParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
@@ -18,7 +21,7 @@ describe('useRedactParameters', () => {
{ paramName: 'customPadding' as const, value: 0.5 },
{ paramName: 'convertPDFToImage' as const, value: false }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter(paramName, value);
@@ -28,7 +31,7 @@ describe('useRedactParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
// Modify some parameters
act(() => {
@@ -52,7 +55,7 @@ describe('useRedactParameters', () => {
{ description: 'not validate when wordsToRedact contains only empty strings in automatic mode', wordsToRedact: ['', ' ', ''], expected: false },
{ description: 'validate when wordsToRedact contains at least one non-empty word in automatic mode', wordsToRedact: ['', 'valid', ' '], expected: true },
])('should $description', ({ wordsToRedact, expected }) => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter('mode', 'automatic');
@@ -63,7 +66,7 @@ describe('useRedactParameters', () => {
});
test('should not validate in manual mode (not implemented)', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter('mode', 'manual');
@@ -75,7 +78,7 @@ describe('useRedactParameters', () => {
describe('endpoint handling', () => {
test('should return correct endpoint for automatic mode', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter('mode', 'automatic');
@@ -85,7 +88,7 @@ describe('useRedactParameters', () => {
});
test('should throw error for manual mode (not implemented)', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter('mode', 'manual');
@@ -96,7 +99,7 @@ describe('useRedactParameters', () => {
});
test('should maintain parameter state across updates', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter('redactColor', '#FF0000');
@@ -117,7 +120,7 @@ describe('useRedactParameters', () => {
});
test('should handle array parameter updates correctly', () => {
const { result } = renderHook(() => useRedactParameters());
const { result } = renderRedactHook();
act(() => {
result.current.updateParameter('wordsToRedact', ['initial']);
@@ -43,6 +43,7 @@ export const useRedactParameters = (): RedactParametersHook => {
}
// Manual mode validation would go here when implemented
return false;
}
},
storageKey: 'redact'
});
};
@@ -1,16 +1,19 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderRemovePasswordHook = () => renderHook(() => useRemovePasswordParameters(), { wrapper: PreferencesTestWrapper });
describe('useRemovePasswordParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRemovePasswordParameters());
const { result } = renderRemovePasswordHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update password parameter', () => {
const { result } = renderHook(() => useRemovePasswordParameters());
const { result } = renderRemovePasswordHook();
act(() => {
result.current.updateParameter('password', 'test-password');
@@ -20,7 +23,7 @@ describe('useRemovePasswordParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useRemovePasswordParameters());
const { result } = renderRemovePasswordHook();
// First, change the password
act(() => {
@@ -38,7 +41,7 @@ describe('useRemovePasswordParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useRemovePasswordParameters());
const { result } = renderRemovePasswordHook();
expect(result.current.getEndpointName()).toBe('remove-password');
});
@@ -70,7 +73,7 @@ describe('useRemovePasswordParameters', () => {
expectedValid: true
}
])('should validate parameters correctly $description', ({ password, expectedValid }) => {
const { result } = renderHook(() => useRemovePasswordParameters());
const { result } = renderRemovePasswordHook();
act(() => {
result.current.updateParameter('password', password);
@@ -1,10 +1,13 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRotateParameters, defaultParameters, normalizeAngle } from '@app/hooks/tools/rotate/useRotateParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderRotateHook = () => renderHook(() => useRotateParameters(), { wrapper: PreferencesTestWrapper });
describe('useRotateParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
expect(result.current.parameters).toEqual(defaultParameters);
expect(result.current.parameters.angle).toBe(0);
@@ -12,7 +15,7 @@ describe('useRotateParameters', () => {
});
test('should validate parameters correctly', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
// Default should be valid
expect(result.current.validateParameters()).toBe(true);
@@ -31,7 +34,7 @@ describe('useRotateParameters', () => {
});
test('should rotate clockwise correctly', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
act(() => {
result.current.rotateClockwise();
@@ -58,7 +61,7 @@ describe('useRotateParameters', () => {
});
test('should rotate anticlockwise correctly', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
act(() => {
result.current.rotateAnticlockwise();
@@ -85,7 +88,7 @@ describe('useRotateParameters', () => {
});
test('should normalize angles correctly', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
expect(result.current.normalizeAngle(360)).toBe(0);
expect(result.current.normalizeAngle(450)).toBe(90);
@@ -94,7 +97,7 @@ describe('useRotateParameters', () => {
});
test('should reset parameters correctly', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
// Set some rotation
act(() => {
@@ -116,7 +119,7 @@ describe('useRotateParameters', () => {
});
test('should update parameters', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
act(() => {
result.current.updateParameter('angle', 450);
@@ -132,13 +135,13 @@ describe('useRotateParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
expect(result.current.getEndpointName()).toBe('rotate-pdf');
});
test('should detect rotation state correctly', () => {
const { result } = renderHook(() => useRotateParameters());
const { result } = renderRotateHook();
// Initially no rotation
expect(result.current.hasRotation).toBe(false);
@@ -1,16 +1,19 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { defaultParameters, useSanitizeParameters } from '@app/hooks/tools/sanitize/useSanitizeParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderSanitizeHook = () => renderHook(() => useSanitizeParameters(), { wrapper: PreferencesTestWrapper });
describe('useSanitizeParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useSanitizeParameters());
const { result } = renderSanitizeHook();
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update individual parameters', () => {
const { result } = renderHook(() => useSanitizeParameters());
const { result } = renderSanitizeHook();
act(() => {
result.current.updateParameter('removeXMPMetadata', true);
@@ -23,7 +26,7 @@ describe('useSanitizeParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useSanitizeParameters());
const { result } = renderSanitizeHook();
// First, change some parameters
act(() => {
@@ -43,13 +46,13 @@ describe('useSanitizeParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useSanitizeParameters());
const { result } = renderSanitizeHook();
expect(result.current.getEndpointName()).toBe('sanitize-pdf');
});
test('should validate parameters correctly', () => {
const { result } = renderHook(() => useSanitizeParameters());
const { result } = renderSanitizeHook();
// Default state should be valid (has removeJavaScript and removeEmbeddedFiles enabled)
expect(result.current.validateParameters()).toBe(true);
@@ -71,7 +74,7 @@ describe('useSanitizeParameters', () => {
});
test('should handle all parameter types correctly', () => {
const { result } = renderHook(() => useSanitizeParameters());
const { result } = renderSanitizeHook();
const allParameters = Object.keys(defaultParameters) as (keyof typeof defaultParameters)[];
@@ -1,4 +1,6 @@
import { useState, useCallback, Dispatch, SetStateAction } from 'react';
import { useState, useCallback, Dispatch, SetStateAction, useRef, useEffect } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { loadToolParameters, saveToolParameters } from '@app/services/toolParameterStorage';
export interface BaseParametersHook<T> {
parameters: T;
@@ -13,10 +15,31 @@ export interface BaseParametersConfig<T> {
defaultParameters: T;
endpointName: string | ((params: T) => string);
validateFn?: (params: T) => boolean;
storageKey?: string;
}
export function useBaseParameters<T>(config: BaseParametersConfig<T>): BaseParametersHook<T> {
const [parameters, setParameters] = useState<T>(config.defaultParameters);
const { preferences } = usePreferences();
const storageKey = config.storageKey ?? (typeof config.endpointName === 'string' ? config.endpointName : undefined);
const shouldPersist = Boolean(storageKey && preferences.rememberFormInputs);
const hasHydratedFromStorage = useRef(false);
const mergeWithDefaults = useCallback((values?: Partial<T>): T => ({
...(config.defaultParameters as T),
...(values ?? {}),
}), [config.defaultParameters]);
const [parameters, setParameters] = useState<T>(() => {
if (shouldPersist && storageKey) {
const stored = loadToolParameters<T>(storageKey);
if (stored) {
hasHydratedFromStorage.current = true;
return mergeWithDefaults(stored);
}
}
return mergeWithDefaults();
});
const updateParameter = useCallback(<K extends keyof T>(parameter: K, value: T[K]) => {
setParameters(prev => ({
@@ -33,6 +56,34 @@ export function useBaseParameters<T>(config: BaseParametersConfig<T>): BaseParam
return config.validateFn ? config.validateFn(parameters) : true;
}, [parameters, config.validateFn]);
useEffect(() => {
if (!storageKey) {
return;
}
if (!preferences.rememberFormInputs) {
hasHydratedFromStorage.current = false;
return;
}
if (hasHydratedFromStorage.current) {
return;
}
const stored = loadToolParameters<T>(storageKey);
if (stored) {
setParameters(mergeWithDefaults(stored));
}
hasHydratedFromStorage.current = true;
}, [mergeWithDefaults, preferences.rememberFormInputs, storageKey]);
useEffect(() => {
if (!storageKey || !preferences.rememberFormInputs) {
return;
}
saveToolParameters(storageKey, parameters);
}, [parameters, preferences.rememberFormInputs, storageKey]);
const endpointName = config.endpointName;
let getEndpointName: () => string;
if (typeof endpointName === "string") {
@@ -57,5 +57,6 @@ export const useSplitParameters = (): SplitParametersHook => {
return false;
}
},
storageKey: 'split',
});
};
+2 -11
View File
@@ -1,14 +1,5 @@
import { AxiosInstance } from 'axios';
import { getBrowserId } from '@app/utils/browserIdentifier';
export function setupApiInterceptors(client: AxiosInstance): void {
// Add browser ID header for WAU tracking
client.interceptors.request.use(
(config) => {
const browserId = getBrowserId();
config.headers['X-Browser-Id'] = browserId;
return config;
},
(error) => Promise.reject(error)
);
export function setupApiInterceptors(_client: AxiosInstance): void {
// Core version: no interceptors to add
}
@@ -9,6 +9,7 @@ export interface UserPreferences {
toolPanelModePromptSeen: boolean;
showLegacyToolDescriptions: boolean;
hasCompletedOnboarding: boolean;
rememberFormInputs: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
@@ -19,6 +20,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
toolPanelModePromptSeen: false,
showLegacyToolDescriptions: false,
hasCompletedOnboarding: false,
rememberFormInputs: false,
};
const STORAGE_KEY = 'stirlingpdf_preferences';
@@ -0,0 +1,114 @@
const STORAGE_KEY = 'stirlingpdf_tool_parameters';
type SerializableValue = string | number | boolean | null | SerializableValue[] | { [key: string]: SerializableValue };
const isFile = (value: unknown): value is File => typeof File !== 'undefined' && value instanceof File;
const isBlob = (value: unknown): value is Blob => typeof Blob !== 'undefined' && value instanceof Blob;
function sanitizeValue(value: unknown): SerializableValue | undefined {
if (value === null) {
return null;
}
const valueType = typeof value;
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') {
if (valueType === 'number' && Number.isNaN(value)) {
return null;
}
return value as SerializableValue;
}
if (valueType === 'undefined' || valueType === 'function' || valueType === 'symbol' || valueType === 'bigint') {
return undefined;
}
if (Array.isArray(value)) {
const sanitizedArray = value
.map((item) => sanitizeValue(item))
.filter((item) => item !== undefined) as SerializableValue[];
return sanitizedArray;
}
if (isFile(value) || isBlob(value)) {
return undefined;
}
if (valueType === 'object' && value !== null) {
const entries = Object.entries(value as Record<string, unknown>);
const sanitizedEntries: Record<string, SerializableValue> = {};
for (const [key, entryValue] of entries) {
const sanitized = sanitizeValue(entryValue);
if (sanitized !== undefined) {
sanitizedEntries[key] = sanitized;
}
}
return sanitizedEntries;
}
return undefined;
}
function readStorage(): Record<string, SerializableValue> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null) {
return parsed as Record<string, SerializableValue>;
}
} catch (error) {
console.error('[toolParameterStorage] Failed to read stored parameters', error);
}
return {};
}
function writeStorage(map: Record<string, SerializableValue>): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
} catch (error) {
console.error('[toolParameterStorage] Failed to write stored parameters', error);
}
}
export function loadToolParameters<T>(toolKey: string): Partial<T> | null {
const storage = readStorage();
const stored = storage[toolKey];
if (!stored || typeof stored !== 'object') {
return null;
}
return stored as Partial<T>;
}
export function saveToolParameters<T>(toolKey: string, parameters: T): void {
const sanitized = sanitizeValue(parameters);
if (sanitized === undefined) {
return;
}
const storage = readStorage();
storage[toolKey] = sanitized;
writeStorage(storage);
}
export function clearToolParameters(toolKey: string): void {
const storage = readStorage();
if (Object.prototype.hasOwnProperty.call(storage, toolKey)) {
delete storage[toolKey];
writeStorage(storage);
}
}
export function clearAllToolParameters(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error('[toolParameterStorage] Failed to clear stored parameters', error);
}
}
-186
View File
@@ -1,186 +0,0 @@
export interface UpdateSummary {
latest_version: string;
latest_stable_version?: string;
max_priority: 'urgent' | 'normal' | 'minor' | 'low';
recommended_action?: string;
any_breaking: boolean;
migration_guides?: Array<{
version: string;
notes: string;
url: string;
}>;
}
export interface VersionUpdate {
version: string;
priority: 'urgent' | 'normal' | 'minor' | 'low';
announcement: {
title: string;
message: string;
};
compatibility: {
breaking_changes: boolean;
breaking_description?: string;
migration_guide_url?: string;
};
}
export interface FullUpdateInfo {
latest_version: string;
latest_stable_version?: string;
new_versions: VersionUpdate[];
}
export interface MachineInfo {
machineType: string;
activeSecurity: boolean;
licenseType: string;
}
export class UpdateService {
private readonly baseUrl = 'https://supabase.stirling.com/functions/v1/updates';
/**
* Compare two version strings
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
*/
compareVersions(version1: string, version2: string): number {
const v1 = version1.split('.');
const v2 = version2.split('.');
for (let i = 0; i < v1.length || i < v2.length; i++) {
const n1 = parseInt(v1[i]) || 0;
const n2 = parseInt(v2[i]) || 0;
if (n1 > n2) {
return 1;
} else if (n1 < n2) {
return -1;
}
}
return 0;
}
/**
* Get download URL based on machine type and security settings
*/
getDownloadUrl(machineInfo: MachineInfo): string | null {
// Only show download for non-Docker installations
if (machineInfo.machineType === 'Docker' || machineInfo.machineType === 'Kubernetes') {
return null;
}
const baseUrl = 'https://files.stirlingpdf.com/';
// Determine file based on machine type and security
if (machineInfo.machineType === 'Server-jar') {
return baseUrl + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
}
// Client installations
if (machineInfo.machineType.startsWith('Client-')) {
const os = machineInfo.machineType.replace('Client-', ''); // win, mac, unix
const type = machineInfo.activeSecurity ? '-server-security' : '-server';
if (os === 'unix') {
return baseUrl + os + type + '.jar';
} else if (os === 'win') {
return baseUrl + os + '-installer.exe';
} else if (os === 'mac') {
return baseUrl + os + '-installer.dmg';
}
}
return null;
}
/**
* Fetch update summary from API
*/
async getUpdateSummary(currentVersion: string, machineInfo: MachineInfo): Promise<UpdateSummary | null> {
// Map Java License enum to API types
let type = 'normal';
if (machineInfo.licenseType === 'PRO') {
type = 'pro';
} else if (machineInfo.licenseType === 'ENTERPRISE') {
type = 'enterprise';
}
const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=true`;
console.log('Fetching update summary from:', url);
try {
const response = await fetch(url);
console.log('Response status:', response.status);
if (response.status === 200) {
const data = await response.json();
return data as UpdateSummary;
} else {
console.error('Failed to fetch update summary from Supabase:', response.status);
return null;
}
} catch (error) {
console.error('Failed to fetch update summary from Supabase:', error);
return null;
}
}
/**
* Fetch full update information with detailed version info
*/
async getFullUpdateInfo(currentVersion: string, machineInfo: MachineInfo): Promise<FullUpdateInfo | null> {
// Map Java License enum to API types
let type = 'normal';
if (machineInfo.licenseType === 'PRO') {
type = 'pro';
} else if (machineInfo.licenseType === 'ENTERPRISE') {
type = 'enterprise';
}
const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=false`;
console.log('Fetching full update info from:', url);
try {
const response = await fetch(url);
console.log('Full update response status:', response.status);
if (response.status === 200) {
const data = await response.json();
return data as FullUpdateInfo;
} else {
console.error('Failed to fetch full update info from Supabase:', response.status);
return null;
}
} catch (error) {
console.error('Failed to fetch full update info from Supabase:', error);
return null;
}
}
/**
* Get current version from GitHub build.gradle as fallback
*/
async getCurrentVersionFromGitHub(): Promise<string> {
const url = 'https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/master/build.gradle';
try {
const response = await fetch(url);
if (response.status === 200) {
const text = await response.text();
const versionRegex = /version\s*=\s*['"](\d+\.\d+\.\d+)['"]/;
const match = versionRegex.exec(text);
if (match) {
return match[1];
}
}
throw new Error('Version number not found');
} catch (error) {
console.error('Failed to fetch latest version from build.gradle:', error);
return '';
}
}
}
export const updateService = new UpdateService();
+1 -1
View File
@@ -15,7 +15,7 @@ export const Z_INDEX_HOVER_ACTION_MENU = 100;
export const Z_INDEX_SELECTION_BOX = 1000;
export const Z_INDEX_DROP_INDICATOR = 1001;
export const Z_INDEX_DRAG_BADGE = 1001;
// Modal that appears on top of config modal (e.g., restart confirmation, update modal)
// Modal that appears on top of config modal (e.g., restart confirmation)
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
// Toast notifications and error displays - Always on top (higher than rainbow theme at 10000)
@@ -1,394 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import MenuBookRoundedIcon from '@mui/icons-material/MenuBookRounded';
import { alert } from '@app/components/toast';
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
import EditTableOfContentsWorkbenchView, { EditTableOfContentsWorkbenchViewData } from '@app/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView';
import EditTableOfContentsSettings from '@app/components/tools/editTableOfContents/EditTableOfContentsSettings';
import { useEditTableOfContentsParameters } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters';
import { useEditTableOfContentsOperation } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation';
import { BaseToolProps, ToolComponent } from '@app/types/tool';
import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
import apiClient from '@app/services/apiClient';
import { BookmarkPayload, BookmarkNode, hydrateBookmarkPayload, serializeBookmarkNodes } from '@app/utils/editTableOfContents';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
import { useFileSelection } from '@app/contexts/FileContext';
const extractBookmarks = async (file: File): Promise<BookmarkPayload[]> => {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post('/api/v1/general/extract-bookmarks', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data as BookmarkPayload[];
};
const useStableCallback = <T extends (...args: any[]) => any>(callback: T): T => {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
return useMemo(() => ((...args: Parameters<T>) => callbackRef.current(...args)) as T, []);
};
const EditTableOfContents = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
'edit-table-of-contents',
useEditTableOfContentsParameters,
useEditTableOfContentsOperation,
props,
{ minFiles: 1 }
);
const {
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
} = useToolWorkflow();
const { openFilesModal } = useFilesModalContext();
const { clearSelections } = useFileSelection();
const navigationState = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const WORKBENCH_VIEW_ID = 'editTableOfContentsWorkbench';
const WORKBENCH_ID = 'custom:editTableOfContents' as const;
const viewIcon = useMemo(() => <MenuBookRoundedIcon fontSize="small" />, []);
const [loadError, setLoadError] = useState<string | null>(null);
const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(false);
const [lastLoadedFileId, setLastLoadedFileId] = useState<string | null>(null);
const hasAutoOpenedWorkbenchRef = useRef(false);
const selectedFile = base.selectedFiles[0];
const { setBookmarks } = base.params;
useEffect(() => {
registerCustomWorkbenchView({
id: WORKBENCH_VIEW_ID,
workbenchId: WORKBENCH_ID,
label: 'Outline workspace',
icon: viewIcon,
component: EditTableOfContentsWorkbenchView,
});
return () => {
clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID);
unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID);
};
// Register once; avoid re-registering which clears data mid-flight
}, []);
const loadBookmarksForFile = useCallback(async (file: File, { showToast }: { showToast?: boolean } = {}) => {
setIsLoadingBookmarks(true);
setLoadError(null);
try {
const payload = await extractBookmarks(file);
const bookmarks = hydrateBookmarkPayload(payload);
setBookmarks(bookmarks);
setLastLoadedFileId((file as any)?.fileId ?? file.name);
if (showToast) {
alert({
title: t('editTableOfContents.messages.loadedTitle', 'Bookmarks extracted'),
body: t('editTableOfContents.messages.loadedBody', 'Existing bookmarks from the PDF were loaded into the editor.'),
alertType: 'success',
});
}
if (bookmarks.length === 0) {
setLoadError(t('editTableOfContents.messages.noBookmarks', 'No bookmarks were found in the selected PDF.'));
}
} catch (error) {
console.error('Failed to load bookmarks', error);
setLoadError(t('editTableOfContents.messages.loadFailed', 'Unable to extract bookmarks from the selected PDF.'));
} finally {
setIsLoadingBookmarks(false);
}
}, [setBookmarks, t]);
useEffect(() => {
// Don't auto-load bookmarks if we have results - user is viewing the output
if (base.hasResults) {
return;
}
if (!selectedFile) {
setBookmarks([]);
setLastLoadedFileId(null);
setLoadError(null);
return;
}
const fileId = (selectedFile as any)?.fileId ?? selectedFile.name;
if (fileId === lastLoadedFileId) {
return;
}
loadBookmarksForFile(selectedFile).catch(() => {
// errors handled in hook
});
}, [selectedFile, lastLoadedFileId, loadBookmarksForFile, setBookmarks, base.hasResults]);
const importJsonCallback = async (file: File) => {
try {
const text = await file.text();
const json = JSON.parse(text) as BookmarkPayload[];
setBookmarks(hydrateBookmarkPayload(json));
alert({
title: t('editTableOfContents.messages.imported', 'Bookmarks imported'),
body: t('editTableOfContents.messages.importedBody', 'Your JSON outline replaced the current editor contents.'),
alertType: 'success',
});
} catch (error) {
console.error('Failed to import JSON bookmarks', error);
alert({
title: t('editTableOfContents.messages.invalidJson', 'Invalid JSON structure'),
body: t('editTableOfContents.messages.invalidJsonBody', 'Please provide a valid bookmark JSON file and try again.'),
alertType: 'error',
});
}
};
const handleImportJson = useStableCallback(importJsonCallback);
const importClipboardCallback = async () => {
if (!navigator.clipboard?.readText) {
alert({
title: t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access unavailable'),
alertType: 'warning',
});
return;
}
try {
const clipboard = await navigator.clipboard.readText();
const json = JSON.parse(clipboard) as BookmarkPayload[];
setBookmarks(hydrateBookmarkPayload(json));
alert({
title: t('editTableOfContents.messages.imported', 'Bookmarks imported'),
body: t('editTableOfContents.messages.importedClipboard', 'Clipboard data replaced the current bookmark list.'),
alertType: 'success',
});
} catch (error) {
console.error('Failed to import bookmarks from clipboard', error);
alert({
title: t('editTableOfContents.messages.invalidJson', 'Invalid JSON structure'),
body: t('editTableOfContents.messages.invalidJsonBody', 'Please provide a valid bookmark JSON file and try again.'),
alertType: 'error',
});
}
};
const handleImportClipboard = useStableCallback(importClipboardCallback);
const exportJsonCallback = () => {
const data = JSON.stringify(serializeBookmarkNodes(base.params.parameters.bookmarks), null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'bookmarks.json';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
alert({
title: t('editTableOfContents.messages.exported', 'JSON download ready'),
alertType: 'success',
});
};
const handleExportJson = useStableCallback(exportJsonCallback);
const exportClipboardCallback = async () => {
if (!navigator.clipboard?.writeText) {
alert({
title: t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access unavailable'),
alertType: 'warning',
});
return;
}
const data = JSON.stringify(serializeBookmarkNodes(base.params.parameters.bookmarks), null, 2);
try {
await navigator.clipboard.writeText(data);
alert({
title: t('editTableOfContents.messages.copied', 'Copied to clipboard'),
body: t('editTableOfContents.messages.copiedBody', 'Bookmark JSON copied successfully.'),
alertType: 'success',
});
} catch (error) {
console.error('Failed to copy bookmarks', error);
alert({
title: t('editTableOfContents.messages.copyFailed', 'Copy failed'),
alertType: 'error',
});
}
};
const handleExportClipboard = useStableCallback(exportClipboardCallback);
const clipboardReadAvailable = typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.readText);
const clipboardWriteAvailable = typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.writeText);
const loadFromSelectedCallback = () => {
if (selectedFile) {
loadBookmarksForFile(selectedFile, { showToast: true });
}
};
const handleLoadFromSelected = useStableCallback(loadFromSelectedCallback);
const replaceExistingCallback = (value: boolean) => {
base.params.updateParameter('replaceExisting', value);
};
const handleReplaceExistingChange = useStableCallback(replaceExistingCallback);
const bookmarksChangeCallback = (bookmarks: BookmarkNode[]) => {
setBookmarks(bookmarks);
};
const handleBookmarksChange = useStableCallback(bookmarksChangeCallback);
const executeCallback = () => {
void base.handleExecute();
};
const handleExecute = useStableCallback(executeCallback);
const undoCallback = () => {
base.handleUndo();
};
const handleUndo = useStableCallback(undoCallback);
const clearErrorCallback = () => {
base.operation.clearError();
};
const handleClearError = useStableCallback(clearErrorCallback);
const fileClickCallback = (file: File) => {
base.handleThumbnailClick(file);
};
const handleFileClick = useStableCallback(fileClickCallback);
const selectFilesCallback = () => {
// Clear existing selection first so the new file replaces instead of adds
clearSelections();
openFilesModal();
};
const handleSelectFiles = useStableCallback(selectFilesCallback);
// Always keep workbench data updated
useEffect(() => {
const data: EditTableOfContentsWorkbenchViewData = {
bookmarks: base.params.parameters.bookmarks,
selectedFileName: selectedFile?.name,
disabled: base.endpointLoading || base.operation.isLoading,
files: base.operation.files ?? [],
thumbnails: base.operation.thumbnails ?? [],
downloadUrl: base.operation.downloadUrl ?? null,
downloadFilename: base.operation.downloadFilename ?? null,
errorMessage: base.operation.errorMessage ?? null,
isGeneratingThumbnails: base.operation.isGeneratingThumbnails,
isExecuteDisabled:
!selectedFile ||
!base.hasFiles ||
base.endpointEnabled === false ||
base.operation.isLoading ||
base.endpointLoading,
isExecuting: base.operation.isLoading,
onClearError: handleClearError,
onBookmarksChange: handleBookmarksChange,
onExecute: handleExecute,
onUndo: handleUndo,
onFileClick: handleFileClick,
};
setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, data);
}, [
WORKBENCH_VIEW_ID,
base.endpointEnabled,
base.endpointLoading,
base.hasFiles,
base.operation.downloadFilename,
base.operation.downloadUrl,
base.operation.errorMessage,
base.operation.files,
base.operation.isGeneratingThumbnails,
base.operation.isLoading,
base.operation.thumbnails,
base.params.parameters.bookmarks,
handleBookmarksChange,
handleClearError,
handleExecute,
handleFileClick,
handleUndo,
selectedFile,
setCustomWorkbenchViewData,
]);
// Auto-navigate to workbench when tool is selected
useEffect(() => {
if (navigationState.selectedTool !== 'editTableOfContents') {
hasAutoOpenedWorkbenchRef.current = false;
return;
}
if (hasAutoOpenedWorkbenchRef.current) {
return;
}
hasAutoOpenedWorkbenchRef.current = true;
// Use timeout to ensure data effect has run first
setTimeout(() => {
navigationActions.setWorkbench(WORKBENCH_ID);
}, 0);
}, [navigationActions, navigationState.selectedTool, WORKBENCH_ID]);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: true,
minFiles: 1,
isVisible: false,
},
steps: [
{
title: t('editTableOfContents.settings.title', 'Bookmarks & outline'),
isCollapsed: false,
content: (
<EditTableOfContentsSettings
bookmarks={base.params.parameters.bookmarks}
replaceExisting={base.params.parameters.replaceExisting}
onReplaceExistingChange={handleReplaceExistingChange}
onSelectFiles={handleSelectFiles}
onLoadFromPdf={handleLoadFromSelected}
onImportJson={handleImportJson}
onImportClipboard={handleImportClipboard}
onExportJson={handleExportJson}
onExportClipboard={handleExportClipboard}
isLoading={isLoadingBookmarks}
loadError={loadError}
canReadClipboard={clipboardReadAvailable}
canWriteClipboard={clipboardWriteAvailable}
disabled={base.endpointLoading}
selectedFileName={selectedFile?.name}
/>
),
},
],
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t('editTableOfContents.results.title', 'Updated PDF with bookmarks'),
onFileClick: base.handleThumbnailClick,
onUndo: handleUndo,
},
});
};
(EditTableOfContents as any).tool = () => useEditTableOfContentsOperation;
export default EditTableOfContents as ToolComponent;
@@ -1,46 +0,0 @@
/**
* Browser identifier utility for anonymous usage tracking
* Generates and persists a unique UUID in localStorage for WAU tracking
*/
const BROWSER_ID_KEY = 'stirling_browser_id';
/**
* Gets or creates a unique browser identifier
* Used for Weekly Active Users (WAU) tracking in no-login mode
*/
export function getBrowserId(): string {
try {
// Try to get existing ID from localStorage
let browserId = localStorage.getItem(BROWSER_ID_KEY);
if (!browserId) {
// Generate new UUID v4
browserId = generateUUID();
localStorage.setItem(BROWSER_ID_KEY, browserId);
}
return browserId;
} catch (error) {
// Fallback to session-based ID if localStorage is unavailable
console.warn('localStorage unavailable, using session-based ID', error);
return `session_${generateUUID()}`;
}
}
/**
* Generates a UUID v4
*/
function generateUUID(): string {
// Use crypto.randomUUID if available (modern browsers)
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback to manual UUID generation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
@@ -1,47 +0,0 @@
export interface BookmarkPayload {
title: string;
pageNumber: number;
children?: BookmarkPayload[];
}
export interface BookmarkNode {
id: string;
title: string;
pageNumber: number;
children: BookmarkNode[];
expanded: boolean;
}
const createBookmarkId = () => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `bookmark-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
};
export const createBookmarkNode = (bookmark?: Partial<BookmarkNode>): BookmarkNode => ({
id: bookmark?.id ?? createBookmarkId(),
title: bookmark?.title ?? '',
pageNumber: bookmark?.pageNumber ?? 1,
children: bookmark?.children ? bookmark.children.map(child => createBookmarkNode(child)) : [],
expanded: bookmark?.expanded ?? true,
});
export const hydrateBookmarkPayload = (payload: BookmarkPayload[] = []): BookmarkNode[] => {
return payload.map(item => ({
id: createBookmarkId(),
title: item.title ?? '',
pageNumber: typeof item.pageNumber === 'number' && item.pageNumber > 0 ? item.pageNumber : 1,
expanded: true,
children: item.children ? hydrateBookmarkPayload(item.children) : [],
}));
};
export const serializeBookmarkNodes = (bookmarks: BookmarkNode[]): BookmarkPayload[] => {
return bookmarks.map(bookmark => ({
title: bookmark.title,
pageNumber: bookmark.pageNumber,
children: serializeBookmarkNodes(bookmark.children),
}));
};
+33 -19
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
export const DEFAULT_VISIBILITY_THRESHOLD = 70; // Require at least 70% of the page height to be visible
export const DEFAULT_VISIBILITY_THRESHOLD = 80; // Require at least 80% of the page height to be visible
export const DEFAULT_FALLBACK_ZOOM = 1.44; // 144% fallback when no reliable metadata is present
export interface ZoomViewport {
@@ -36,33 +36,47 @@ export function determineAutoZoom({
visibilityThreshold = DEFAULT_VISIBILITY_THRESHOLD,
fallbackZoom = DEFAULT_FALLBACK_ZOOM,
}: AutoZoomParams): AutoZoomDecision {
// Get aspect ratio from pageRect or metadata
const rectWidth = pageRect?.width ?? 0;
const rectHeight = pageRect?.height ?? 0;
const aspectRatio: number | null =
rectWidth > 0 ? rectHeight / rectWidth : metadataAspectRatio ?? null;
// Need aspect ratio to proceed
if (!aspectRatio || aspectRatio <= 0) {
return { type: 'fallback', zoom: Math.min(fitWidthZoom, fallbackZoom) };
let renderedHeight: number | null = rectHeight > 0 ? rectHeight : null;
if (!renderedHeight || renderedHeight <= 0) {
if (aspectRatio == null || aspectRatio <= 0) {
return { type: 'fallback', zoom: Math.min(fitWidthZoom, fallbackZoom) };
}
const pageWidth = viewportWidth / (fitWidthZoom * pagesPerSpread);
const pageHeight = pageWidth * aspectRatio;
renderedHeight = pageHeight * fitWidthZoom;
}
// Landscape pages need 100% visibility, portrait need the specified threshold
const isLandscape = aspectRatio < 1;
const targetVisibility = isLandscape ? 100 : visibilityThreshold;
// Calculate zoom level that shows targetVisibility% of page height
const pageHeightAtFitWidth = (viewportWidth / pagesPerSpread) * aspectRatio;
const heightBasedZoom = fitWidthZoom * (viewportHeight / pageHeightAtFitWidth) / (targetVisibility / 100);
// Use whichever zoom is smaller (more zoomed out) to satisfy both width and height constraints
if (heightBasedZoom < fitWidthZoom) {
// Need to zoom out from fitWidth to show enough height
return { type: 'adjust', zoom: heightBasedZoom };
} else {
// fitWidth already shows enough
if (!renderedHeight || renderedHeight <= 0) {
return { type: 'fitWidth' };
}
const isLandscape = aspectRatio !== null && aspectRatio < 1;
const targetVisibility = isLandscape ? 100 : visibilityThreshold;
const visiblePercent = (viewportHeight / renderedHeight) * 100;
if (visiblePercent >= targetVisibility) {
return { type: 'fitWidth' };
}
const allowableHeightRatio = targetVisibility / 100;
const zoomScale =
viewportHeight / (allowableHeightRatio * renderedHeight);
const targetZoom = Math.min(fitWidthZoom, fitWidthZoom * zoomScale);
if (Math.abs(targetZoom - fitWidthZoom) < 0.001) {
return { type: 'fitWidth' };
}
return { type: 'adjust', zoom: targetZoom };
}
export interface MeasurePageRectOptions {
@@ -0,0 +1,6 @@
import React from 'react';
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
export const PreferencesTestWrapper = ({ children }: { children: React.ReactNode }) => (
<PreferencesProvider>{children}</PreferencesProvider>
);
+2 -1
View File
@@ -4,7 +4,8 @@
"paths": {
"@app/*": [
"src/core/*"
]
],
"@testing/*": ["src/testing/*"]
}
},
"exclude": [
+2 -1
View File
@@ -9,7 +9,8 @@
],
"@desktop/*": ["src/desktop/*"],
"@proprietary/*": ["src/proprietary/*"],
"@core/*": ["src/core/*"]
"@core/*": ["src/core/*"],
"@testing/*": ["src/testing/*"]
}
},
"exclude": [
+1
View File
@@ -36,6 +36,7 @@
"src/proprietary/*",
"src/core/*"
],
"@testing/*": ["src/testing/*"],
"@desktop/*": ["src/desktop/*"],
"@proprietary/*": ["src/proprietary/*"],
"@core/*": ["src/core/*"]
+2 -1
View File
@@ -6,7 +6,8 @@
"src/proprietary/*",
"src/core/*"
],
"@core/*": ["src/core/*"]
"@core/*": ["src/core/*"],
"@testing/*": ["src/testing/*"]
}
},
"exclude": [
+1 -128
View File
@@ -2,43 +2,6 @@
This directory contains Python scripts for managing frontend translations in Stirling PDF. These tools help analyze, merge, validate, and manage translations against the en-GB golden truth file.
## Quick Start - Automated Translation (RECOMMENDED)
The **fastest and easiest way** to translate a language is using the automated pipeline:
```bash
# Set your OpenAI API key
export OPENAI_API_KEY=your_openai_api_key_here
# Translate a language automatically (extract → translate → merge → beautify → verify)
python3 scripts/translations/auto_translate.py es-ES
# With custom batch size (default: 500 entries per batch)
python3 scripts/translations/auto_translate.py es-ES --batch-size 600
# Keep temporary files for inspection
python3 scripts/translations/auto_translate.py es-ES --no-cleanup
```
**What it does:**
1. Extracts untranslated entries from the language file
2. Splits into batches (default 500 entries each)
3. Translates each batch using GPT-5 with specialized prompts
4. Validates placeholders are preserved
5. Merges translated batches
6. Applies translations to language file
7. Beautifies structure to match en-GB
8. Cleans up temporary files
9. Reports final completion percentage
**Time:** ~8-10 minutes per language with 1200+ untranslated entries
**Cost:** ~$2-4 per language using GPT-5 (or use `gpt-5-mini` for lower cost)
See [`auto_translate.py`](#auto_translatepy-automated-translation-pipeline) for full details.
---
## Scripts Overview
### 0. Validation Scripts (Run First!)
@@ -228,97 +191,7 @@ python scripts/translations/compact_translator.py it-IT --output to_translate.js
- Batch size control for manageable chunks
- 50-80% fewer characters than other extraction methods
### 5. `auto_translate.py` - Automated Translation Pipeline
**NEW: Fully automated translation workflow using GPT-5.**
Combines all translation steps into a single command that handles everything from extraction to verification.
**Usage:**
```bash
# Basic usage (requires OPENAI_API_KEY environment variable)
export OPENAI_API_KEY=your_api_key
python3 scripts/translations/auto_translate.py es-ES
# With inline API key
python3 scripts/translations/auto_translate.py es-ES --api-key YOUR_KEY
# Custom batch size (default: 500 entries)
python3 scripts/translations/auto_translate.py es-ES --batch-size 600
# Custom timeout per batch (default: 600 seconds / 10 minutes)
python3 scripts/translations/auto_translate.py es-ES --timeout 900
# Keep temporary files for debugging
python3 scripts/translations/auto_translate.py es-ES --no-cleanup
# Skip final verification
python3 scripts/translations/auto_translate.py es-ES --skip-verification
```
**Features:**
- Fully automated end-to-end translation pipeline
- Uses GPT-5 with specialized prompts for Stirling PDF
- Preserves all placeholders ({n}, {{variable}}, etc.)
- Maintains consistent terminology
- Validates translations automatically
- Creates backups before modifying files
- Reports detailed progress and final completion %
**Pipeline Steps:**
1. **Extract**: Finds all untranslated entries
2. **Split**: Divides into manageable batches (default: 500 entries)
3. **Translate**: Uses GPT-5 to translate each batch with specialized prompts
4. **Validate**: Ensures placeholders are preserved
5. **Merge**: Combines all translated batches
6. **Apply**: Updates the language file
7. **Beautify**: Restructures to match en-GB format
8. **Cleanup**: Removes temporary files
9. **Verify**: Reports final completion percentage
**Translation Quality:**
- Preserves ALL placeholders exactly as-is
- Keeps HTML tags intact (<strong>, <br>, etc.)
- Doesn't translate technical terms (PDF, API, OAuth2, etc.)
- Maintains consistent terminology throughout
- Uses appropriate formal/informal tone per language
**Supported Languages:**
All language codes from `frontend/public/locales/` (e.g., es-ES, de-DE, fr-FR, zh-CN, ar-AR, etc.)
### 6. `batch_translator.py` - GPT-5 Translation Engine
Low-level translation script used by `auto_translate.py`. Can be used standalone for manual batch translation.
**Usage:**
```bash
# Translate single batch file
python3 scripts/translations/batch_translator.py my_batch.json --language es-ES --api-key YOUR_KEY
# Translate multiple batches
python3 scripts/translations/batch_translator.py batch_*.json --language de-DE --api-key YOUR_KEY
# Use different GPT model
python3 scripts/translations/batch_translator.py batch.json --language fr-FR --model gpt-5-mini
# Skip validation
python3 scripts/translations/batch_translator.py batch.json --language it-IT --skip-validation
```
**Features:**
- Translates JSON batch files using OpenAI GPT-5
- Specialized system prompts for Stirling PDF translations
- Automatic placeholder validation
- Supports pattern matching for multiple files
- Configurable model selection (gpt-5, gpt-5-mini, gpt-5-nano)
- Rate limiting with configurable delays
**Models:**
- `gpt-5` (default): Best quality, $1.25/1M input, $10/1M output
- `gpt-5-mini`: Balanced quality/cost
- `gpt-5-nano`: Fastest, most economical
### 7. `json_beautifier.py`
### 5. `json_beautifier.py`
Restructures and beautifies translation JSON files to match en-GB structure exactly.
**Usage:**
-324
View File
@@ -1,324 +0,0 @@
#!/usr/bin/env python3
"""
Automated Translation Pipeline
Extracts, translates, merges, and beautifies translations for a language.
"""
import json
import sys
import argparse
import os
import subprocess
from pathlib import Path
import time
def run_command(cmd, description=""):
"""Run a shell command and return success status."""
if description:
print(f"\n{'='*60}")
print(f"Step: {description}")
print(f"{'='*60}")
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.stdout:
print(result.stdout)
if result.stderr:
print(result.stderr, file=sys.stderr)
return result.returncode == 0
def extract_untranslated(language_code, batch_size=500):
"""Extract untranslated entries and split into batches."""
print(f"\n🔍 Extracting untranslated entries for {language_code}...")
# Load files
golden_path = Path(f'frontend/public/locales/en-GB/translation.json')
lang_path = Path(f'frontend/public/locales/{language_code}/translation.json')
if not golden_path.exists():
print(f"Error: Golden truth file not found: {golden_path}")
return None
if not lang_path.exists():
print(f"Error: Language file not found: {lang_path}")
return None
def load_json(path):
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def flatten_dict(d, parent_key='', separator='.'):
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(flatten_dict(v, new_key, separator).items())
else:
items.append((new_key, str(v)))
return dict(items)
golden = load_json(golden_path)
lang_data = load_json(lang_path)
golden_flat = flatten_dict(golden)
lang_flat = flatten_dict(lang_data)
# Find untranslated
untranslated = {}
for key, value in golden_flat.items():
if (key not in lang_flat or
lang_flat.get(key) == value or
(isinstance(lang_flat.get(key), str) and lang_flat.get(key).startswith("[UNTRANSLATED]"))):
untranslated[key] = value
total = len(untranslated)
print(f"Found {total} untranslated entries")
if total == 0:
print("✓ Language is already complete!")
return []
# Split into batches
entries = list(untranslated.items())
num_batches = (total + batch_size - 1) // batch_size
batch_files = []
lang_code_safe = language_code.replace('-', '_')
for i in range(num_batches):
start = i * batch_size
end = min((i + 1) * batch_size, total)
batch = dict(entries[start:end])
filename = f'{lang_code_safe}_batch_{i+1}_of_{num_batches}.json'
with open(filename, 'w', encoding='utf-8') as f:
json.dump(batch, f, ensure_ascii=False, separators=(',', ':'))
batch_files.append(filename)
print(f" Created {filename} with {len(batch)} entries")
return batch_files
def translate_batches(batch_files, language_code, api_key, timeout=600):
"""Translate all batch files using GPT-5."""
if not batch_files:
return []
print(f"\n🤖 Translating {len(batch_files)} batches using GPT-5...")
print(f"Timeout: {timeout}s ({timeout//60} minutes) per batch")
translated_files = []
for i, batch_file in enumerate(batch_files, 1):
print(f"\n[{i}/{len(batch_files)}] Translating {batch_file}...")
# Always pass API key since it's required
cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}"'
# Run with timeout
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
if result.stdout:
print(result.stdout)
if result.stderr:
print(result.stderr, file=sys.stderr)
if result.returncode != 0:
print(f"✗ Failed to translate {batch_file}")
return None
translated_file = batch_file.replace('.json', '_translated.json')
translated_files.append(translated_file)
# Small delay between batches
if i < len(batch_files):
time.sleep(1)
print(f"\n✓ All {len(batch_files)} batches translated successfully")
return translated_files
def merge_translations(translated_files, language_code):
"""Merge all translated batch files."""
if not translated_files:
return None
print(f"\n🔗 Merging {len(translated_files)} translated batches...")
merged = {}
for filename in translated_files:
if not Path(filename).exists():
print(f"Error: Translated file not found: {filename}")
return None
with open(filename, 'r', encoding='utf-8') as f:
merged.update(json.load(f))
lang_code_safe = language_code.replace('-', '_')
merged_file = f'{lang_code_safe}_merged.json'
with open(merged_file, 'w', encoding='utf-8') as f:
json.dump(merged, f, ensure_ascii=False, separators=(',', ':'))
print(f"✓ Merged {len(merged)} translations into {merged_file}")
return merged_file
def apply_translations(merged_file, language_code):
"""Apply merged translations to the language file."""
print(f"\n📝 Applying translations to {language_code}...")
cmd = f'python3 scripts/translations/translation_merger.py {language_code} apply-translations --translations-file {merged_file}'
if not run_command(cmd):
print(f"✗ Failed to apply translations")
return False
print(f"✓ Translations applied successfully")
return True
def beautify_translations(language_code):
"""Beautify translation file to match en-GB structure."""
print(f"\n✨ Beautifying {language_code} translation file...")
cmd = f'python3 scripts/translations/json_beautifier.py --language {language_code}'
if not run_command(cmd):
print(f"✗ Failed to beautify translations")
return False
print(f"✓ Translation file beautified")
return True
def cleanup_temp_files(language_code):
"""Remove temporary batch files."""
print(f"\n🧹 Cleaning up temporary files...")
lang_code_safe = language_code.replace('-', '_')
patterns = [
f'{lang_code_safe}_batch_*.json',
f'{lang_code_safe}_merged.json'
]
import glob
removed = 0
for pattern in patterns:
for file in glob.glob(pattern):
Path(file).unlink()
removed += 1
print(f"✓ Removed {removed} temporary files")
def verify_completion(language_code):
"""Check final completion percentage."""
print(f"\n📊 Verifying completion...")
cmd = f'python3 scripts/translations/translation_analyzer.py --language {language_code} --summary'
run_command(cmd)
def main():
parser = argparse.ArgumentParser(
description='Automated translation pipeline for Stirling PDF',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Translate Spanish with API key in environment
export OPENAI_API_KEY=your_key_here
python3 scripts/translations/auto_translate.py es-ES
# Translate German with inline API key
python3 scripts/translations/auto_translate.py de-DE --api-key YOUR_KEY
# Translate Italian with custom batch size
python3 scripts/translations/auto_translate.py it-IT --batch-size 600
# Skip cleanup (keep temporary files for inspection)
python3 scripts/translations/auto_translate.py fr-FR --no-cleanup
"""
)
parser.add_argument('language', help='Language code (e.g., es-ES, de-DE, zh-CN)')
parser.add_argument('--api-key', help='OpenAI API key (or set OPENAI_API_KEY env var)')
parser.add_argument('--batch-size', type=int, default=500, help='Entries per batch (default: 500)')
parser.add_argument('--no-cleanup', action='store_true', help='Keep temporary batch files')
parser.add_argument('--skip-verification', action='store_true', help='Skip final completion check')
parser.add_argument('--timeout', type=int, default=600, help='Timeout per batch in seconds (default: 600 = 10 minutes)')
args = parser.parse_args()
# Verify API key
api_key = args.api_key or os.environ.get('OPENAI_API_KEY')
if not api_key:
print("Error: OpenAI API key required. Provide via --api-key or OPENAI_API_KEY environment variable")
sys.exit(1)
print("="*60)
print(f"Automated Translation Pipeline")
print(f"Language: {args.language}")
print(f"Batch Size: {args.batch_size} entries")
print("="*60)
start_time = time.time()
try:
# Step 1: Extract and split
batch_files = extract_untranslated(args.language, args.batch_size)
if batch_files is None:
sys.exit(1)
if len(batch_files) == 0:
print("\n✓ Nothing to translate!")
sys.exit(0)
# Step 2: Translate all batches
translated_files = translate_batches(batch_files, args.language, api_key, args.timeout)
if translated_files is None:
sys.exit(1)
# Step 3: Merge translations
merged_file = merge_translations(translated_files, args.language)
if merged_file is None:
sys.exit(1)
# Step 4: Apply translations
if not apply_translations(merged_file, args.language):
sys.exit(1)
# Step 5: Beautify
if not beautify_translations(args.language):
sys.exit(1)
# Step 6: Cleanup
if not args.no_cleanup:
cleanup_temp_files(args.language)
# Step 7: Verify
if not args.skip_verification:
verify_completion(args.language)
elapsed = time.time() - start_time
print("\n" + "="*60)
print(f"✅ Translation pipeline completed successfully!")
print(f"Time elapsed: {elapsed:.1f} seconds")
print("="*60)
except KeyboardInterrupt:
print("\n\n⚠ Translation interrupted by user")
sys.exit(1)
except Exception as e:
print(f"\n\n✗ Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
-321
View File
@@ -1,321 +0,0 @@
#!/usr/bin/env python3
"""
Batch Translation Script using OpenAI API
Automatically translates JSON batch files to target language while preserving:
- Placeholders: {n}, {total}, {filename}, {{variable}}
- HTML tags: <strong>, </strong>, etc.
- Technical terms: PDF, API, OAuth2, SAML2, JWT, etc.
"""
import json
import sys
import argparse
from pathlib import Path
import time
try:
from openai import OpenAI
except ImportError:
print("Error: openai package not installed. Install with: pip install openai")
sys.exit(1)
class BatchTranslator:
def __init__(self, api_key: str, model: str = "gpt-5"):
"""Initialize translator with OpenAI API key."""
self.client = OpenAI(api_key=api_key)
self.model = model
def get_translation_prompt(self, language_name: str, language_code: str) -> str:
"""Generate the system prompt for translation."""
return f"""You are a professional translator for Stirling PDF, an open-source PDF manipulation tool.
Translate the following JSON from English to {language_name} ({language_code}) for the Stirling PDF user interface.
CRITICAL RULES - MUST FOLLOW EXACTLY:
1. PRESERVE ALL PLACEHOLDERS EXACTLY AS-IS:
- Single braces: {{{{n}}}}, {{{{total}}}}, {{{{filename}}}}, {{{{count}}}}, {{{{date}}}}, {{{{planName}}}}, {{{{toolName}}}}, {{{{variable}}}}
- Double braces: {{{{{{{{variable}}}}}}}}
- Never translate, modify, or remove these - they are template variables
2. KEEP ALL HTML TAGS INTACT:
- <strong>, </strong>, <br>, <code>, </code>, etc.
- Do not translate tag names, only text between tags
3. DO NOT TRANSLATE TECHNICAL TERMS:
- File formats: PDF, JSON, CSV, XML, HTML, ZIP, DOCX, XLSX, PNG, JPG
- Protocols: API, OAuth2, SAML2, JWT, SMTP, HTTP, HTTPS, SSL, TLS
- Technologies: Git, GitHub, Google, PostHog, Scarf, LibreOffice, Ghostscript, Tesseract, OCR
- Technical keywords: URL, URI, DPI, RGB, CMYK, QR
- "Stirling PDF" - always keep as-is
4. MAINTAIN CONSISTENT TERMINOLOGY:
- Use the SAME translation for repeated terms throughout
- Do not introduce new terminology or synonyms
- Keep UI action words consistent (e.g., "upload", "download", "compress")
5. PRESERVE SPECIAL KEYWORDS IN CONTEXT:
- Mathematical expressions: "2n", "2n-1", "3n" (in page selection)
- Special keywords: "all", "odd", "even" (in page contexts)
- Code examples and technical patterns
6. JSON STRUCTURE:
- Translate ONLY the values (text after :), NEVER the keys
- Return ONLY valid JSON with exact same structure
- Maintain all quotes, commas, and braces
7. TONE & STYLE:
- Use appropriate formal/informal tone for {language_name} UI
- Keep translations concise and user-friendly
- Maintain the professional but accessible tone of the original
8. DO NOT ADD OR REMOVE TEXT:
- Do not add explanations, comments, or extra text
- Do not remove any part of the original meaning
- Keep the same level of detail
Return ONLY the translated JSON. No markdown, no explanations, just the JSON object."""
def translate_batch(self, batch_data: dict, target_language: str, language_code: str) -> dict:
"""Translate a batch file using OpenAI API."""
# Convert batch to compact JSON for API
input_json = json.dumps(batch_data, ensure_ascii=False, separators=(',', ':'))
print(f"Translating {len(batch_data)} entries to {target_language}...")
print(f"Input size: {len(input_json)} characters")
try:
# GPT-5 only supports temperature=1, so we don't include it
response = self.client.chat.completions.create(
model=self.model,
messages=[
{
"role": "system",
"content": self.get_translation_prompt(target_language, language_code)
},
{
"role": "user",
"content": f"Translate this JSON:\n\n{input_json}"
}
],
)
translated_text = response.choices[0].message.content.strip()
# Remove markdown code blocks if present
if translated_text.startswith("```"):
lines = translated_text.split('\n')
translated_text = '\n'.join(lines[1:-1])
# Parse the translated JSON
translated_data = json.loads(translated_text)
print(f"✓ Translation complete")
return translated_data
except json.JSONDecodeError as e:
print(f"Error: AI returned invalid JSON: {e}")
print(f"Response: {translated_text[:500]}...")
raise
except Exception as e:
print(f"Error during translation: {e}")
raise
def validate_translation(self, original: dict, translated: dict) -> bool:
"""Validate that translation preserved all placeholders and structure."""
issues = []
# Check that all keys are present
if set(original.keys()) != set(translated.keys()):
missing = set(original.keys()) - set(translated.keys())
extra = set(translated.keys()) - set(original.keys())
if missing:
issues.append(f"Missing keys: {missing}")
if extra:
issues.append(f"Extra keys: {extra}")
# Check placeholders in each value
import re
placeholder_pattern = r'\{[^}]+\}|\{\{[^}]+\}\}'
for key in original.keys():
if key not in translated:
continue
orig_value = str(original[key])
trans_value = str(translated[key])
# Find all placeholders in original
orig_placeholders = set(re.findall(placeholder_pattern, orig_value))
trans_placeholders = set(re.findall(placeholder_pattern, trans_value))
if orig_placeholders != trans_placeholders:
issues.append(f"Placeholder mismatch in '{key}': {orig_placeholders} vs {trans_placeholders}")
if issues:
print("\n⚠ Validation warnings:")
for issue in issues[:10]: # Show first 10 issues
print(f" - {issue}")
if len(issues) > 10:
print(f" ... and {len(issues) - 10} more issues")
return False
print("✓ Validation passed")
return True
def get_language_info(language_code: str) -> tuple:
"""Get full language name from code."""
languages = {
'zh-CN': ('Simplified Chinese', 'zh-CN'),
'es-ES': ('Spanish', 'es-ES'),
'it-IT': ('Italian', 'it-IT'),
'de-DE': ('German', 'de-DE'),
'ar-AR': ('Arabic', 'ar-AR'),
'pt-BR': ('Brazilian Portuguese', 'pt-BR'),
'ru-RU': ('Russian', 'ru-RU'),
'fr-FR': ('French', 'fr-FR'),
'ja-JP': ('Japanese', 'ja-JP'),
'ko-KR': ('Korean', 'ko-KR'),
'nl-NL': ('Dutch', 'nl-NL'),
'pl-PL': ('Polish', 'pl-PL'),
'sv-SE': ('Swedish', 'sv-SE'),
'da-DK': ('Danish', 'da-DK'),
'no-NB': ('Norwegian', 'no-NB'),
'fi-FI': ('Finnish', 'fi-FI'),
'tr-TR': ('Turkish', 'tr-TR'),
'vi-VN': ('Vietnamese', 'vi-VN'),
'th-TH': ('Thai', 'th-TH'),
'id-ID': ('Indonesian', 'id-ID'),
'hi-IN': ('Hindi', 'hi-IN'),
'cs-CZ': ('Czech', 'cs-CZ'),
'hu-HU': ('Hungarian', 'hu-HU'),
'ro-RO': ('Romanian', 'ro-RO'),
'uk-UA': ('Ukrainian', 'uk-UA'),
'el-GR': ('Greek', 'el-GR'),
'bg-BG': ('Bulgarian', 'bg-BG'),
'hr-HR': ('Croatian', 'hr-HR'),
'sk-SK': ('Slovak', 'sk-SK'),
'sl-SI': ('Slovenian', 'sl-SI'),
'ca-CA': ('Catalan', 'ca-CA'),
}
return languages.get(language_code, (language_code, language_code))
def main():
parser = argparse.ArgumentParser(
description='Translate JSON batch files using OpenAI API',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Translate single batch file
python batch_translator.py zh_CN_batch_1_of_4.json --api-key YOUR_KEY --language zh-CN
# Translate all batches for a language (with pattern)
python batch_translator.py "zh_CN_batch_*_of_*.json" --api-key YOUR_KEY --language zh-CN
# Use environment variable for API key
export OPENAI_API_KEY=your_key_here
python batch_translator.py zh_CN_batch_1_of_4.json --language zh-CN
# Use different model
python batch_translator.py file.json --api-key KEY --language es-ES --model gpt-4-turbo
"""
)
parser.add_argument('input_files', nargs='+', help='Input batch JSON file(s) or pattern')
parser.add_argument('--api-key', help='OpenAI API key (or set OPENAI_API_KEY env var)')
parser.add_argument('--language', '-l', required=True, help='Target language code (e.g., zh-CN, es-ES)')
parser.add_argument('--model', default='gpt-5', help='OpenAI model to use (default: gpt-5, options: gpt-5-mini, gpt-5-nano)')
parser.add_argument('--output-suffix', default='_translated', help='Suffix for output files (default: _translated)')
parser.add_argument('--skip-validation', action='store_true', help='Skip validation checks')
parser.add_argument('--delay', type=float, default=1.0, help='Delay between API calls in seconds (default: 1.0)')
args = parser.parse_args()
# Get API key from args or environment
import os
api_key = args.api_key or os.environ.get('OPENAI_API_KEY')
if not api_key:
print("Error: OpenAI API key required. Provide via --api-key or OPENAI_API_KEY environment variable")
sys.exit(1)
# Get language info
language_name, language_code = get_language_info(args.language)
# Expand file patterns
import glob
input_files = []
for pattern in args.input_files:
matched = glob.glob(pattern)
if matched:
input_files.extend(matched)
else:
input_files.append(pattern) # Use as literal filename
if not input_files:
print("Error: No input files found")
sys.exit(1)
print(f"Batch Translator")
print(f"Target Language: {language_name} ({language_code})")
print(f"Model: {args.model}")
print(f"Files to translate: {len(input_files)}")
print("=" * 60)
# Initialize translator
translator = BatchTranslator(api_key, args.model)
# Process each file
successful = 0
failed = 0
for i, input_file in enumerate(input_files, 1):
print(f"\n[{i}/{len(input_files)}] Processing: {input_file}")
try:
# Load input file
with open(input_file, 'r', encoding='utf-8') as f:
batch_data = json.load(f)
# Translate
translated_data = translator.translate_batch(batch_data, language_name, language_code)
# Validate
if not args.skip_validation:
translator.validate_translation(batch_data, translated_data)
# Save output
input_path = Path(input_file)
output_file = input_path.stem + args.output_suffix + input_path.suffix
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(translated_data, f, ensure_ascii=False, separators=(',', ':'))
print(f"✓ Saved to: {output_file}")
successful += 1
# Delay between API calls to avoid rate limits
if i < len(input_files):
time.sleep(args.delay)
except Exception as e:
print(f"✗ Failed: {e}")
failed += 1
continue
# Summary
print("\n" + "=" * 60)
print(f"Translation complete!")
print(f"Successful: {successful}/{len(input_files)}")
if failed > 0:
print(f"Failed: {failed}/{len(input_files)}")
sys.exit(0 if failed == 0 else 1)
if __name__ == "__main__":
import os
main()