Merge remote-tracking branch 'origin/V2' into fix/v2/automate_settings_gap_fill

This commit is contained in:
Connor Yoh 2025-10-01 16:53:30 +01:00
commit 7f52d5b378
21 changed files with 11972 additions and 2477 deletions

File diff suppressed because it is too large Load Diff

View File

@ -16,16 +16,34 @@
"selectText": {
"1": "Select PDF file:",
"2": "Margin Size",
"3": "Position",
"3": "Position Selection",
"4": "Starting Number",
"5": "Pages to Number",
"6": "Custom Text"
"6": "Custom Text Format"
},
"customTextDesc": "Custom Text",
"numberPagesDesc": "Which pages to number, default 'all', also accepts 1-5 or 2,5,9 etc",
"customNumberDesc": "Defaults to {n}, also accepts 'Page {n} of {total}', 'Text-{n}', '{filename}-{n}",
"submit": "Add Page Numbers"
"numberPagesDesc": "e.g., 1,3,5-8 or leave blank for all pages",
"customNumberDesc": "e.g., \"Page {n}\" or leave blank for just numbers",
"submit": "Add Page Numbers",
"configuration": "Configuration",
"customize": "Customize Appearance",
"pagesAndStarting": "Pages & Starting Number",
"positionAndPages": "Position & Pages",
"error": {
"failed": "Add page numbers operation failed"
},
"results": {
"title": "Page Number Results"
},
"preview": "Position Selection",
"previewDisclaimer": "Preview is approximate. Final output may vary due to PDF font metrics."
},
"pageSelectionPrompt": "Specify which pages to add numbers to. Examples: \"1,3,5\" for specific pages, \"1-5\" for ranges, \"2n\" for even pages, or leave blank for all pages.",
"startingNumberTooltip": "The first number to display. Subsequent pages will increment from this number.",
"marginTooltip": "Distance between the page number and the edge of the page.",
"fontSizeTooltip": "Size of the page number text in points. Larger numbers create bigger text.",
"fontTypeTooltip": "Font family for the page numbers. Choose based on your document style.",
"customTextTooltip": "Optional custom format for page numbers. Use {n} as placeholder for the number. Example: \"Page {n}\" will show \"Page 1\", \"Page 2\", etc.",
"pdfPrompt": "Select PDF(s)",
"multiPdfPrompt": "Select PDFs (2+)",
"multiPdfDropPrompt": "Select (or drag & drop) all PDFs you require",

View File

@ -38,7 +38,7 @@
"save": "Guardar",
"saveToBrowser": "Guardar en el navegador",
"close": "Cerrar",
"filesSelected": "archivos seleccionados",
"filesSelected": "{{count}} archivos seleccionados",
"noFavourites": "No se agregaron favoritos",
"downloadComplete": "Descarga completada",
"bored": "¿Aburrido de esperar?",

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

View File

@ -269,8 +269,9 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => {
const isEnglishGB = option.value === 'en-GB'; // Currently only English GB has enough translations to use
const isDisabled = !isEnglishGB;
// 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 (
<LanguageItem

View File

@ -8,6 +8,7 @@ import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css';
import { ScrollArea } from '@mantine/core';
import { ToolId } from '../../types/toolId';
import { useMediaQuery } from '@mantine/hooks';
// No props needed - component uses context
@ -15,6 +16,7 @@ export default function ToolPanel() {
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef } = sidebarRefs;
const isMobile = useMediaQuery('(max-width: 1024px)');
// Use context-based hooks to eliminate prop drilling
@ -34,17 +36,17 @@ export default function ToolPanel() {
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
className={`flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ''
}`}
} ${isMobile ? 'h-full border-r-0' : 'h-screen'}`}
style={{
width: isPanelVisible ? '18.5rem' : '0',
width: isMobile ? '100%' : isPanelVisible ? '18.5rem' : '0',
padding: '0'
}}
>
<div
style={{
opacity: isPanelVisible ? 1 : 0,
opacity: isMobile || isPanelVisible ? 1 : 0,
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
height: '100%',
display: 'flex',

View File

@ -0,0 +1,101 @@
/* PageNumberPreview.module.css - EXACT copy from StampPreview */
/* Container styles */
.container {
position: relative;
width: 100%;
overflow: hidden;
}
.containerWithThumbnail {
background-color: transparent;
}
.containerWithoutThumbnail {
background-color: rgba(255, 255, 255, 0.03);
}
.containerBorder {
border: 1px solid var(--border-default, #333);
}
/* Page thumbnail styles */
.pageThumbnail {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
filter: grayscale(10%) contrast(95%) brightness(105%);
}
/* Quick grid overlay styles - EXACT copy from stamp */
.quickGrid {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 8px;
padding: 8px;
pointer-events: auto;
}
.gridTile {
border: 1px dashed rgba(0, 0, 0, 0.15);
background-color: transparent;
border-radius: 10px;
cursor: pointer;
display: flex;
font-size: 20px;
user-select: none;
font-weight: 600;
position: relative;
}
/* Position numbers at edges within each tile with extra top/bottom spacing */
.gridTile:nth-child(1) { align-items: flex-start; justify-content: flex-start; padding-top: 4px; } /* top-left */
.gridTile:nth-child(2) { align-items: flex-start; justify-content: center; padding-top: 4px; } /* top-center */
.gridTile:nth-child(3) { align-items: flex-start; justify-content: flex-end; padding-top: 4px; } /* top-right */
.gridTile:nth-child(4) { align-items: center; justify-content: flex-start; } /* middle-left */
.gridTile:nth-child(5) { align-items: center; justify-content: center; } /* center */
.gridTile:nth-child(6) { align-items: center; justify-content: flex-end; } /* middle-right */
.gridTile:nth-child(7) { align-items: flex-end; justify-content: flex-start; padding-bottom: 4px; } /* bottom-left */
.gridTile:nth-child(8) { align-items: flex-end; justify-content: center; padding-bottom: 4px; } /* bottom-center */
.gridTile:nth-child(9) { align-items: flex-end; justify-content: flex-end; padding-bottom: 4px; } /* bottom-right */
/* Base padding for all tiles */
.gridTile {
padding: 8px;
}
.gridTileSelected,
.gridTileHovered {
border: 2px solid var(--mantine-primary-color-filled, #3b82f6);
background-color: rgba(59, 130, 246, 0.2);
}
/* Preview header */
.previewHeader {
margin-bottom: 12px;
}
.divider {
height: 1px;
background-color: var(--border-default, #333);
margin-bottom: 8px;
}
.previewLabel {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
text-align: center;
}
/* Preview disclaimer */
.previewDisclaimer {
margin-top: 8px;
opacity: 0.7;
font-size: 12px;
}

View File

@ -0,0 +1,241 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AddPageNumbersParameters } from './useAddPageNumbersParameters';
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
import styles from './PageNumberPreview.module.css';
// Simple utilities for page numbers (adapted from stamp)
const A4_ASPECT_RATIO = 0.707;
const getFirstSelectedPage = (input: string): number => {
if (!input) return 1;
const parts = input.split(',').map(s => s.trim()).filter(Boolean);
for (const part of parts) {
if (/^\d+\s*-\s*\d+$/.test(part)) {
const low = parseInt(part.split('-')[0].trim(), 10);
if (Number.isFinite(low) && low > 0) return low;
}
const n = parseInt(part, 10);
if (Number.isFinite(n) && n > 0) return n;
}
return 1;
};
const detectOverallBackgroundColor = async (thumbnailSrc: string | null): Promise<'light' | 'dark'> => {
if (!thumbnailSrc) {
return 'light'; // Default to light background if no thumbnail
}
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve('light');
return;
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// Sample the entire image at reduced resolution for performance
const sampleWidth = Math.min(100, img.width);
const sampleHeight = Math.min(100, img.height);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const data = imageData.data;
let totalBrightness = 0;
let pixelCount = 0;
// Sample every nth pixel for performance
const step = Math.max(1, Math.floor((img.width * img.height) / (sampleWidth * sampleHeight)));
for (let i = 0; i < data.length; i += 4 * step) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// Calculate perceived brightness using luminance formula
const brightness = (0.299 * r + 0.587 * g + 0.114 * b);
totalBrightness += brightness;
pixelCount++;
}
const averageBrightness = totalBrightness / pixelCount;
// Threshold: 128 is middle gray
resolve(averageBrightness > 128 ? 'light' : 'dark');
} catch (error) {
console.warn('Error detecting background color:', error);
resolve('light'); // Default fallback
}
};
img.onerror = () => resolve('light');
img.src = thumbnailSrc;
});
};
type Props = {
parameters: AddPageNumbersParameters;
onParameterChange: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
file?: File | null;
showQuickGrid?: boolean;
};
export default function PageNumberPreview({ parameters, onParameterChange, file, showQuickGrid }: Props) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [, setContainerSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
const [pageSize, setPageSize] = useState<{ widthPts: number; heightPts: number } | null>(null);
const [pageThumbnail, setPageThumbnail] = useState<string | null>(null);
const { requestThumbnail } = useThumbnailGeneration();
const [hoverTile, setHoverTile] = useState<number | null>(null);
const [textColor, setTextColor] = useState<string>('#fff');
// Observe container size for responsive positioning
useEffect(() => {
const node = containerRef.current;
if (!node) return;
const resize = () => {
const aspect = pageSize ? (pageSize.widthPts / pageSize.heightPts) : A4_ASPECT_RATIO;
setContainerSize({ width: node.clientWidth, height: node.clientWidth / aspect });
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(node);
return () => ro.disconnect();
}, [pageSize]);
// Load first PDF page size in points for accurate scaling
useEffect(() => {
let cancelled = false;
const load = async () => {
if (!file || file.type !== 'application/pdf') {
setPageSize(null);
return;
}
try {
const buffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(buffer, { disableAutoFetch: true, disableStream: true });
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1 });
if (!cancelled) {
setPageSize({ widthPts: viewport.width, heightPts: viewport.height });
}
pdfWorkerManager.destroyDocument(pdf);
} catch {
if (!cancelled) setPageSize(null);
}
};
load();
return () => { cancelled = true; };
}, [file]);
// Load first-page thumbnail for background preview
useEffect(() => {
let isActive = true;
const loadThumb = async () => {
if (!file || file.type !== 'application/pdf') {
setPageThumbnail(null);
return;
}
try {
const pageNumber = Math.max(1, getFirstSelectedPage(parameters.pagesToNumber || '1'));
const pageId = `${file.name}:${file.size}:${file.lastModified}:page:${pageNumber}`;
const thumb = await requestThumbnail(pageId, file, pageNumber);
if (isActive) setPageThumbnail(thumb || null);
} catch {
if (isActive) setPageThumbnail(null);
}
};
loadThumb();
return () => { isActive = false; };
}, [file, parameters.pagesToNumber, requestThumbnail]);
// Detect text color based on overall PDF background
useEffect(() => {
if (!pageThumbnail) {
setTextColor('#fff'); // Default to white for no thumbnail
return;
}
const detectColor = async () => {
const backgroundType = await detectOverallBackgroundColor(pageThumbnail);
setTextColor(backgroundType === 'light' ? '#000' : '#fff');
};
detectColor();
}, [pageThumbnail]);
const containerStyle = useMemo(() => ({
position: 'relative' as const,
width: '100%',
aspectRatio: `${(pageSize?.widthPts ?? 595.28) / (pageSize?.heightPts ?? 841.89)} / 1`,
backgroundColor: pageThumbnail ? 'transparent' : 'rgba(255,255,255,0.03)',
border: '1px solid var(--border-default, #333)',
overflow: 'hidden' as const
}), [pageSize, pageThumbnail]);
return (
<div>
<div className={styles.previewHeader}>
<div className={styles.divider} />
<div className={styles.previewLabel}>{t('addPageNumbers.preview', 'Preview Page Numbers')}</div>
</div>
<div
ref={containerRef}
className={`${styles.container} ${styles.containerBorder} ${pageThumbnail ? styles.containerWithThumbnail : styles.containerWithoutThumbnail}`}
style={containerStyle}
>
{pageThumbnail && (
<img
src={pageThumbnail}
alt="page preview"
className={styles.pageThumbnail}
draggable={false}
/>
)}
{/* Quick position overlay grid - EXACT copy from stamp */}
{showQuickGrid && (
<div className={styles.quickGrid}>
{Array.from({ length: 9 }).map((_, i) => {
const idx = (i + 1) as 1|2|3|4|5|6|7|8|9;
const selected = parameters.position === idx;
return (
<button
key={idx}
type="button"
className={`${styles.gridTile} ${selected || hoverTile === idx ? styles.gridTileSelected : ''} ${hoverTile === idx ? styles.gridTileHovered : ''}`}
onClick={() => onParameterChange('position', idx as any)}
onMouseEnter={() => setHoverTile(idx)}
onMouseLeave={() => setHoverTile(null)}
style={{
color: textColor,
textShadow: textColor === '#fff'
? '1px 1px 2px rgba(0, 0, 0, 0.8)'
: '1px 1px 2px rgba(255, 255, 255, 0.8)'
}}
>
{idx}
</button>
);
})}
</div>
)}
</div>
<div className={styles.previewDisclaimer}>
{t('addPageNumbers.previewDisclaimer', 'Preview is approximate. Final output may vary due to PDF font metrics.')}
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../../../hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AddPageNumbersParameters, defaultParameters } from './useAddPageNumbersParameters';
export const buildAddPageNumbersFormData = (parameters: AddPageNumbersParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('customMargin', parameters.customMargin);
formData.append('position', String(parameters.position));
formData.append('fontSize', String(parameters.fontSize));
formData.append('fontType', parameters.fontType);
formData.append('startingNumber', String(parameters.startingNumber));
formData.append('pagesToNumber', parameters.pagesToNumber);
formData.append('customText', parameters.customText);
return formData;
};
export const addPageNumbersOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddPageNumbersFormData,
operationType: 'addPageNumbers',
endpoint: '/api/v1/misc/add-page-numbers',
defaultParameters,
} as const;
export const useAddPageNumbersOperation = () => {
const { t } = useTranslation();
return useToolOperation<AddPageNumbersParameters>({
...addPageNumbersOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('addPageNumbers.error.failed', 'An error occurred while adding page numbers to the PDF.')
),
});
};

View File

@ -0,0 +1,34 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, type BaseParametersHook } from '../../../hooks/tools/shared/useBaseParameters';
export interface AddPageNumbersParameters extends BaseParameters {
customMargin: 'small' | 'medium' | 'large' | 'x-large';
position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
fontSize: number;
fontType: 'Times' | 'Helvetica' | 'Courier';
startingNumber: number;
pagesToNumber: string;
customText: string;
}
export const defaultParameters: AddPageNumbersParameters = {
customMargin: 'medium',
position: 8, // Default to bottom center like the original HTML
fontSize: 12,
fontType: 'Times',
startingNumber: 1,
pagesToNumber: '',
customText: '',
};
export type AddPageNumbersParametersHook = BaseParametersHook<AddPageNumbersParameters>;
export const useAddPageNumbersParameters = (): AddPageNumbersParametersHook => {
return useBaseParameters<AddPageNumbersParameters>({
defaultParameters,
endpointName: 'add-page-numbers',
validateFn: (params): boolean => {
return params.fontSize > 0 && params.startingNumber > 0;
},
});
};

View File

@ -86,6 +86,8 @@ import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustP
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
import SignSettings from "../components/tools/sign/SignSettings";
import AddPageNumbers from "../tools/AddPageNumbers";
import { addPageNumbersOperationConfig } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
import RemoveAnnotations from "../tools/RemoveAnnotations";
import PageLayoutSettings from "../components/tools/pageLayout/PageLayoutSettings";
import ExtractImages from "../tools/ExtractImages";
@ -458,12 +460,14 @@ export function useFlatToolRegistry(): ToolRegistry {
addPageNumbers: {
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.addPageNumbers.title", "Add Page Numbers"),
component: null,
component: AddPageNumbers,
description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
automationSettings: null,
maxFiles: -1,
endpoints: ["add-page-numbers"],
operationConfig: addPageNumbersOperationConfig,
synonyms: getSynonyms(t, "addPageNumbers")
},
pageLayout: {

View File

@ -0,0 +1,168 @@
.mobile-layout {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background-color: var(--bg-background);
}
.mobile-toggle {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-toolbar);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.mobile-brand {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.mobile-brand-icon {
height: 1.5rem;
width: auto;
flex-shrink: 0;
}
.mobile-brand-text {
height: 1.5rem;
width: auto;
max-width: 100%;
}
.mobile-toggle-buttons {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.25rem;
padding: 0.2rem;
border-radius: 9999px;
background: var(--bg-background);
border: 1px solid var(--border-subtle);
}
.mobile-toggle-button {
border: none;
border-radius: 9999px;
padding: 0.4rem 0.9rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
background: transparent;
transition: background 0.2s ease, color 0.2s ease;
}
.mobile-toggle-button:focus-visible {
outline: 2px solid var(--primary-color, #228be6);
outline-offset: 2px;
}
.mobile-toggle-button.active {
background: var(--primary-surface, rgba(34, 139, 230, 0.12));
color: var(--text-primary);
}
.mobile-toggle-hint {
font-size: 0.7rem;
color: var(--text-muted);
text-align: center;
}
.mobile-slider {
display: flex;
flex: 1 1 auto;
width: 100%;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
-ms-overflow-style: none;
touch-action: pan-x pinch-zoom;
}
.mobile-slider::-webkit-scrollbar {
display: none;
}
.mobile-slide {
flex: 0 0 100%;
width: 100%;
height: 100%;
scroll-snap-align: start;
display: flex;
flex-direction: column;
min-height: 0;
}
.mobile-slide-content {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.mobile-slide-content > * {
flex: 1 1 auto;
min-height: 0;
}
.mobile-bottom-bar {
display: flex;
align-items: center;
justify-content: space-around;
padding: 0.5rem;
border-top: 1px solid var(--border-subtle);
background: var(--bg-toolbar);
gap: 0.5rem;
position: relative;
z-index: 10;
touch-action: manipulation;
}
.mobile-bottom-button {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.5rem;
border: none;
background: transparent;
color: var(--text-primary);
cursor: pointer;
border-radius: 0.5rem;
transition: background 0.2s ease;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
min-height: 44px;
}
@media (hover: hover) and (pointer: fine) {
.mobile-bottom-button:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
}
}
.mobile-bottom-button:active {
background: var(--bg-active, rgba(0, 0, 0, 0.1));
}
.mobile-bottom-button-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted);
}

View File

@ -1,15 +1,24 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
import { Group } from "@mantine/core";
import { Group, useMantineColorScheme } from "@mantine/core";
import { useSidebarContext } from "../contexts/SidebarContext";
import { useDocumentMeta } from "../hooks/useDocumentMeta";
import { getBaseUrl } from "../constants/app";
import { BASE_PATH, getBaseUrl } from "../constants/app";
import { useMediaQuery } from "@mantine/hooks";
import AppsIcon from '@mui/icons-material/AppsRounded';
import ToolPanel from "../components/tools/ToolPanel";
import Workbench from "../components/layout/Workbench";
import QuickAccessBar from "../components/shared/QuickAccessBar";
import RightRail from "../components/shared/RightRail";
import FileManager from "../components/FileManager";
import LocalIcon from "../components/shared/LocalIcon";
import { useFilesModalContext } from "../contexts/FilesModalContext";
import "./HomePage.css";
type MobileView = "tools" | "workbench";
export default function HomePage() {
@ -20,7 +29,84 @@ export default function HomePage() {
const { quickAccessRef } = sidebarRefs;
const { selectedTool, selectedToolKey } = useToolWorkflow();
const { selectedTool, selectedToolKey, handleToolSelect, handleBackToTools } = useToolWorkflow();
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const isMobile = useMediaQuery("(max-width: 1024px)");
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
colorScheme === "dark" ? "Dark" : "Light"
}.svg`;
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
colorScheme === "dark" ? "White" : "Black"
}Text.svg`;
const handleSelectMobileView = useCallback((view: MobileView) => {
setActiveMobileView(view);
}, []);
useEffect(() => {
if (isMobile) {
const container = sliderRef.current;
if (container) {
isProgrammaticScroll.current = true;
const offset = activeMobileView === "tools" ? 0 : container.offsetWidth;
container.scrollTo({ left: offset, behavior: "smooth" });
// Re-enable scroll listener after animation completes
setTimeout(() => {
isProgrammaticScroll.current = false;
}, 500);
}
return;
}
setActiveMobileView("tools");
const container = sliderRef.current;
if (container) {
container.scrollTo({ left: 0, behavior: "auto" });
}
}, [activeMobileView, isMobile]);
useEffect(() => {
if (!isMobile) return;
const container = sliderRef.current;
if (!container) return;
let animationFrame = 0;
const handleScroll = () => {
if (isProgrammaticScroll.current) {
return;
}
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
animationFrame = window.requestAnimationFrame(() => {
const { scrollLeft, offsetWidth } = container;
const threshold = offsetWidth / 2;
const nextView: MobileView = scrollLeft >= threshold ? "workbench" : "tools";
setActiveMobileView((current) => (current === nextView ? current : nextView));
});
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
};
}, [isMobile]);
const baseUrl = getBaseUrl();
@ -38,19 +124,107 @@ export default function HomePage() {
return (
<div className="h-screen overflow-hidden">
<Group
align="flex-start"
gap={0}
h="100%"
className="flex-nowrap flex"
>
<QuickAccessBar
ref={quickAccessRef} />
<ToolPanel />
<Workbench />
<RightRail />
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
</Group>
{isMobile ? (
<div className="mobile-layout">
<div className="mobile-toggle">
<div className="mobile-header">
<div className="mobile-brand">
<img src={brandIconSrc} alt="" className="mobile-brand-icon" />
<img src={brandTextSrc} alt={brandAltText} className="mobile-brand-text" />
</div>
</div>
<div className="mobile-toggle-buttons" role="tablist" aria-label={t('home.mobile.viewSwitcher', 'Switch workspace view')}>
<button
type="button"
role="tab"
aria-selected={activeMobileView === "tools"}
className={`mobile-toggle-button ${activeMobileView === "tools" ? "active" : ""}`}
onClick={() => handleSelectMobileView("tools")}
>
{t('home.mobile.tools', 'Tools')}
</button>
<button
type="button"
role="tab"
aria-selected={activeMobileView === "workbench"}
className={`mobile-toggle-button ${activeMobileView === "workbench" ? "active" : ""}`}
onClick={() => handleSelectMobileView("workbench")}
>
{t('home.mobile.workspace', 'Workspace')}
</button>
</div>
<span className="mobile-toggle-hint">
{t('home.mobile.swipeHint', 'Swipe left or right to switch views')}
</span>
</div>
<div ref={sliderRef} className="mobile-slider">
<div className="mobile-slide" aria-label={t('home.mobile.toolsSlide', 'Tool selection panel')}>
<div className="mobile-slide-content">
<ToolPanel />
</div>
</div>
<div className="mobile-slide" aria-label={t('home.mobile.workbenchSlide', 'Workspace panel')}>
<div className="mobile-slide-content">
<div className="flex-1 min-h-0 flex">
<Workbench />
<RightRail />
</div>
</div>
</div>
</div>
<div className="mobile-bottom-bar">
<button
className="mobile-bottom-button"
aria-label={t('quickAccess.allTools', 'All Tools')}
onClick={() => {
handleBackToTools();
if (isMobile) {
setActiveMobileView('tools');
}
}}
>
<AppsIcon sx={{ fontSize: '1.5rem' }} />
<span className="mobile-bottom-button-label">{t('quickAccess.allTools', 'All Tools')}</span>
</button>
<button
className="mobile-bottom-button"
aria-label={t('quickAccess.automate', 'Automate')}
onClick={() => {
handleToolSelect('automate');
if (isMobile) {
setActiveMobileView('tools');
}
}}
>
<LocalIcon icon="automation-outline" width="1.5rem" height="1.5rem" />
<span className="mobile-bottom-button-label">{t('quickAccess.automate', 'Automate')}</span>
</button>
<button
className="mobile-bottom-button"
aria-label={t('home.mobile.openFiles', 'Open files')}
onClick={() => openFilesModal()}
>
<LocalIcon icon="folder-rounded" width="1.5rem" height="1.5rem" />
<span className="mobile-bottom-button-label">{t('quickAccess.files', 'Files')}</span>
</button>
</div>
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
</div>
) : (
<Group
align="flex-start"
gap={0}
h="100%"
className="flex-nowrap flex"
>
<QuickAccessBar
ref={quickAccessRef} />
<ToolPanel />
<Workbench />
<RightRail />
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
</Group>
)}
</div>
);
}

View File

@ -0,0 +1,203 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useFileSelection } from "../contexts/FileContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useAddPageNumbersParameters } from "../components/tools/addPageNumbers/useAddPageNumbersParameters";
import { useAddPageNumbersOperation } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
import { Select, Stack, TextInput, NumberInput, Divider, Text } from "@mantine/core";
import { Tooltip } from "../components/shared/Tooltip";
import PageNumberPreview from "../components/tools/addPageNumbers/PageNumberPreview";
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const params = useAddPageNumbersParameters();
const operation = useAddPageNumbersOperation();
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-page-numbers");
useEffect(() => {
operation.resetResults();
onPreviewFile?.(null);
}, [params.parameters]);
const handleExecute = async () => {
try {
await operation.executeOperation(params.parameters, selectedFiles);
if (operation.files && onComplete) {
onComplete(operation.files);
}
} catch (error: any) {
onError?.(error?.message || t("addPageNumbers.error.failed", "Add page numbers operation failed"));
}
};
const hasFiles = selectedFiles.length > 0;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
enum AddPageNumbersStep {
NONE = 'none',
POSITION_AND_PAGES = 'position_and_pages',
CUSTOMIZE = 'customize'
}
const accordion = useAccordionSteps<AddPageNumbersStep>({
noneValue: AddPageNumbersStep.NONE,
initialStep: AddPageNumbersStep.POSITION_AND_PAGES,
stateConditions: {
hasFiles,
hasResults
},
afterResults: () => {
operation.resetResults();
onPreviewFile?.(null);
}
});
const getSteps = () => {
const steps: any[] = [];
// Step 1: Position Selection & Pages/Starting Number
steps.push({
title: t("addPageNumbers.positionAndPages", "Position & Pages"),
isCollapsed: accordion.getCollapsedState(AddPageNumbersStep.POSITION_AND_PAGES),
onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.POSITION_AND_PAGES),
isVisible: hasFiles || hasResults,
content: (
<Stack gap="lg">
{/* Position Selection */}
<Stack gap="md">
<PageNumberPreview
parameters={params.parameters}
onParameterChange={params.updateParameter}
file={selectedFiles[0] || null}
showQuickGrid={true}
/>
</Stack>
<Divider />
{/* Pages & Starting Number Section */}
<Stack gap="md">
<Text size="sm" fw={500} mb="xs">{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}</Text>
<Tooltip content={t('pageSelectionPrompt', 'Specify which pages to add numbers to. Examples: "1,3,5" for specific pages, "1-5" for ranges, "2n" for even pages, or leave blank for all pages.')}>
<TextInput
label={t('addPageNumbers.selectText.5', 'Pages to Number')}
value={params.parameters.pagesToNumber}
onChange={(e) => params.updateParameter('pagesToNumber', e.currentTarget.value)}
placeholder={t('addPageNumbers.numberPagesDesc', 'e.g., 1,3,5-8 or leave blank for all pages')}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('startingNumberTooltip', 'The first number to display. Subsequent pages will increment from this number.')}>
<NumberInput
label={t('addPageNumbers.selectText.4', 'Starting Number')}
value={params.parameters.startingNumber}
onChange={(v) => params.updateParameter('startingNumber', typeof v === 'number' ? v : 1)}
min={1}
disabled={endpointLoading}
/>
</Tooltip>
</Stack>
</Stack>
),
});
// Step 2: Customize Appearance
steps.push({
title: t("addPageNumbers.customize", "Customize Appearance"),
isCollapsed: accordion.getCollapsedState(AddPageNumbersStep.CUSTOMIZE),
onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.CUSTOMIZE),
isVisible: hasFiles || hasResults,
content: (
<Stack gap="md">
<Tooltip content={t('marginTooltip', 'Distance between the page number and the edge of the page.')}>
<Select
label={t('addPageNumbers.selectText.2', 'Margin')}
value={params.parameters.customMargin}
onChange={(v) => params.updateParameter('customMargin', (v as any) || 'medium')}
data={[
{ value: 'small', label: t('sizes.small', 'Small') },
{ value: 'medium', label: t('sizes.medium', 'Medium') },
{ value: 'large', label: t('sizes.large', 'Large') },
{ value: 'x-large', label: t('sizes.x-large', 'Extra Large') },
]}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('fontSizeTooltip', 'Size of the page number text in points. Larger numbers create bigger text.')}>
<NumberInput
label={t('addPageNumbers.fontSize', 'Font Size')}
value={params.parameters.fontSize}
onChange={(v) => params.updateParameter('fontSize', typeof v === 'number' ? v : 12)}
min={1}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('fontTypeTooltip', 'Font family for the page numbers. Choose based on your document style.')}>
<Select
label={t('addPageNumbers.fontName', 'Font Type')}
value={params.parameters.fontType}
onChange={(v) => params.updateParameter('fontType', (v as any) || 'Times')}
data={[
{ value: 'Times', label: 'Times Roman' },
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Courier', label: 'Courier New' },
]}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('customTextTooltip', 'Optional custom format for page numbers. Use {n} as placeholder for the number. Example: "Page {n}" will show "Page 1", "Page 2", etc.')}>
<TextInput
label={t('addPageNumbers.selectText.6', 'Custom Text Format')}
value={params.parameters.customText}
onChange={(e) => params.updateParameter('customText', e.currentTarget.value)}
placeholder={t('addPageNumbers.customNumberDesc', 'e.g., "Page {n}" or leave blank for just numbers')}
disabled={endpointLoading}
/>
</Tooltip>
</Stack>
),
});
return steps;
};
return createToolFlow({
files: {
selectedFiles,
isCollapsed: hasResults,
},
steps: getSteps(),
executeButton: {
text: t('addPageNumbers.submit', 'Add Page Numbers'),
isVisible: !hasResults,
loadingText: t('loading'),
onClick: handleExecute,
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: operation,
title: t('addPageNumbers.results.title', 'Page Number Results'),
onFileClick: (file) => onPreviewFile?.(file),
onUndo: async () => {
await operation.undoOperation();
onPreviewFile?.(null);
},
},
});
};
AddPageNumbers.tool = () => useAddPageNumbersOperation;
export default AddPageNumbers as ToolComponent;

View File

@ -1,9 +1,96 @@
# Translation Management Scripts
This directory contains Python scripts for managing frontend translations in Stirling PDF. These tools help analyze, merge, and manage translations against the en-GB golden truth file.
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.
## Scripts Overview
### 0. Validation Scripts (Run First!)
#### `json_validator.py`
Validates JSON syntax in translation files with detailed error reporting.
**Usage:**
```bash
# Validate single file
python scripts/translations/json_validator.py ar_AR_batch_1_of_3.json
# Validate all batches for a language
python scripts/translations/json_validator.py --all-batches ar_AR
# Validate pattern with wildcards
python scripts/translations/json_validator.py "ar_AR_batch_*.json"
# Brief output (no context)
python scripts/translations/json_validator.py --all-batches ar_AR --brief
# Only show files with errors
python scripts/translations/json_validator.py --all-batches ar_AR --quiet
```
**Features:**
- Validates JSON syntax with detailed error messages
- Shows exact line, column, and character position of errors
- Displays context around errors for easy fixing
- Suggests common fixes based on error type
- Detects unescaped quotes and backslashes
- Reports entry counts for valid files
- Exit code 1 if any files invalid (good for CI/CD)
**Common Issues Detected:**
- Unescaped quotes inside strings: `"text with "quotes""``"text with \"quotes\""`
- Invalid backslash escapes: `\d{4}``\\d{4}`
- Missing commas between entries
- Trailing commas before closing braces
#### `validate_placeholders.py`
Validates that translation files have correct placeholders matching en-GB (source of truth).
**Usage:**
```bash
# Validate all languages
python scripts/translations/validate_placeholders.py
# Validate specific language
python scripts/translations/validate_placeholders.py --language es-ES
# Show detailed text samples
python scripts/translations/validate_placeholders.py --verbose
# Output as JSON
python scripts/translations/validate_placeholders.py --json
```
**Features:**
- Detects missing placeholders (e.g., {n}, {total}, {filename})
- Detects extra placeholders not in en-GB
- Shows exact keys and text where issues occur
- Exit code 1 if issues found (good for CI/CD)
#### `validate_json_structure.py`
Validates JSON structure and key consistency with en-GB.
**Usage:**
```bash
# Validate all languages
python scripts/translations/validate_json_structure.py
# Validate specific language
python scripts/translations/validate_json_structure.py --language de-DE
# Show all missing/extra keys
python scripts/translations/validate_json_structure.py --verbose
# Output as JSON
python scripts/translations/validate_json_structure.py --json
```
**Features:**
- Validates JSON syntax
- Detects missing keys (not translated yet)
- Detects extra keys (not in en-GB, should be removed)
- Reports key counts and structure differences
- Exit code 1 if issues found (good for CI/CD)
### 1. `translation_analyzer.py`
Analyzes translation files to find missing translations, untranslated entries, and provides completion statistics.
@ -142,7 +229,20 @@ python scripts/translations/translation_analyzer.py --language it-IT --summary
#### Step 2: Extract Untranslated Entries
```bash
# For small files (< 1200 entries)
python scripts/translations/compact_translator.py it-IT --output to_translate.json
# For large files, split into batches
python scripts/translations/compact_translator.py it-IT --output it_IT_batch --batch-size 400
# Creates: it_IT_batch_1_of_N.json, it_IT_batch_2_of_N.json, etc.
```
#### Step 2.5: Validate JSON (if using batches)
```bash
# After AI translates the batches, validate them before merging
python scripts/translations/json_validator.py --all-batches it_IT
# Fix any errors reported (common issues: unescaped quotes, backslashes)
```
**Output format**: Compact JSON with minimal whitespace
@ -309,6 +409,34 @@ ignore = [
### Common Issues and Solutions
#### JSON Syntax Errors in AI Translations
**Problem**: AI-translated batch files have JSON syntax errors
**Symptoms**:
- `JSONDecodeError: Expecting ',' delimiter`
- `JSONDecodeError: Invalid \escape`
**Solution**:
```bash
# 1. Validate all batches to find errors
python scripts/translations/json_validator.py --all-batches ar_AR
# 2. Check detailed error with context
python scripts/translations/json_validator.py ar_AR_batch_2_of_3.json
# 3. Fix the reported issues:
# - Unescaped quotes: "text with "quotes"" → "text with \"quotes\""
# - Backslashes in regex: "\d{4}" → "\\d{4}"
# - Missing commas between entries
# 4. Validate again until all pass
python scripts/translations/json_validator.py --all-batches ar_AR
```
**Common fixes:**
- Arabic/RTL text with embedded quotes: Always escape with backslash
- Regex patterns: Double all backslashes (`\d` → `\\d`)
- Check for missing/extra commas at line reported in error
#### [UNTRANSLATED] Pollution
**Problem**: Hundreds of [UNTRANSLATED] markers from incomplete translation attempts
**Solution**:
@ -326,6 +454,54 @@ ignore = [
## Real-World Examples
### Complete Arabic Translation with Validation (Batch Method)
```bash
# Check status
python scripts/translations/translation_analyzer.py --language ar-AR --summary
# Result: 50% complete, 1088 missing
# Extract in batches due to AI token limits
python scripts/translations/compact_translator.py ar-AR --output ar_AR_batch --batch-size 400
# Created: ar_AR_batch_1_of_3.json (400 entries)
# ar_AR_batch_2_of_3.json (400 entries)
# ar_AR_batch_3_of_3.json (288 entries)
# [Send each batch to AI for translation]
# Validate translated batches before merging
python scripts/translations/json_validator.py --all-batches ar_AR
# Found errors in batch 1 and 2:
# - Line 263: Unescaped quotes in "انقر "إضافة ملفات""
# - Line 132: Unescaped quotes in "أو "and""
# - Line 213: Invalid escape "\d{4}"
# Fix errors manually or with sed, then validate again
python scripts/translations/json_validator.py --all-batches ar_AR
# All valid!
# Merge all batches
python3 << 'EOF'
import json
merged = {}
for i in range(1, 4):
with open(f'ar_AR_batch_{i}_of_3.json', 'r', encoding='utf-8') as f:
merged.update(json.load(f))
with open('ar_AR_merged.json', 'w', encoding='utf-8') as f:
json.dump(merged, f, ensure_ascii=False, indent=2)
EOF
# Apply merged translations
python scripts/translations/translation_merger.py ar-AR apply-translations --translations-file ar_AR_merged.json
# Result: Applied 1088 translations
# Beautify to match en-GB structure
python scripts/translations/json_beautifier.py --language ar-AR
# Check final progress
python scripts/translations/translation_analyzer.py --language ar-AR --summary
# Result: 98.7% complete, 9 missing, 20 untranslated
```
### Complete Italian Translation (Compact Method)
```bash
# Check status

View File

@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""
JSON Validator for Translation Files
Validates JSON syntax in translation files and reports detailed error information.
Useful for validating batch translation files before merging.
Usage:
python3 json_validator.py <file_or_pattern>
python3 json_validator.py ar_AR_batch_*.json
python3 json_validator.py ar_AR_batch_1_of_3.json
python3 json_validator.py --all-batches ar_AR
"""
import json
import sys
import argparse
import glob
from pathlib import Path
def get_line_context(file_path, line_num, context_lines=3):
"""Get lines around the error for context"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
start = max(0, line_num - context_lines - 1)
end = min(len(lines), line_num + context_lines)
context = []
for i in range(start, end):
marker = ">>> " if i == line_num - 1 else " "
context.append(f"{marker}{i+1:4d}: {lines[i].rstrip()}")
return "\n".join(context)
except Exception as e:
return f"Could not read context: {e}"
def get_character_context(file_path, char_pos, context_chars=100):
"""Get characters around the error position"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
start = max(0, char_pos - context_chars)
end = min(len(content), char_pos + context_chars)
before = content[start:char_pos]
error_char = content[char_pos] if char_pos < len(content) else "EOF"
after = content[char_pos+1:end]
return {
'before': before,
'error_char': error_char,
'after': after,
'display': f"{before}[{error_char}]{after}"
}
except Exception as e:
return None
def validate_json_file(file_path):
"""Validate a single JSON file and return detailed error info"""
result = {
'file': str(file_path),
'valid': False,
'error': None,
'line': None,
'column': None,
'position': None,
'context': None,
'char_context': None,
'entry_count': 0
}
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
result['valid'] = True
result['entry_count'] = len(data) if isinstance(data, dict) else 0
except json.JSONDecodeError as e:
result['error'] = e.msg
result['line'] = e.lineno
result['column'] = e.colno
result['position'] = e.pos
result['context'] = get_line_context(file_path, e.lineno)
result['char_context'] = get_character_context(file_path, e.pos)
except FileNotFoundError:
result['error'] = "File not found"
except Exception as e:
result['error'] = str(e)
return result
def print_validation_result(result, verbose=True):
"""Print validation result in a formatted way"""
file_name = Path(result['file']).name
if result['valid']:
print(f"{file_name}: Valid JSON ({result['entry_count']} entries)")
else:
print(f"{file_name}: Invalid JSON")
print(f" Error: {result['error']}")
if result['line']:
print(f" Location: Line {result['line']}, Column {result['column']} (character {result['position']})")
if verbose and result['context']:
print(f"\n Context:")
for line in result['context'].split('\n'):
print(f" {line}")
if verbose and result['char_context']:
print(f"\n Character context:")
print(f" ...{result['char_context']['display'][-150:]}...")
print(f" Error character: {repr(result['char_context']['error_char'])}")
print()
def get_common_fixes(error_msg):
"""Suggest common fixes based on error message"""
fixes = []
if "Expecting ',' delimiter" in error_msg:
fixes.append("Missing comma between JSON entries")
fixes.append("Check for unescaped quotes inside string values")
if "Invalid \\escape" in error_msg or "Invalid escape" in error_msg:
fixes.append("Unescaped backslash in string (use \\\\ for literal backslash)")
fixes.append("Common in regex patterns: \\d should be \\\\d")
if "Expecting property name" in error_msg:
fixes.append("Missing or extra comma")
fixes.append("Trailing comma before closing brace")
if "Expecting value" in error_msg:
fixes.append("Missing value after colon")
fixes.append("Extra comma")
return fixes
def main():
parser = argparse.ArgumentParser(
description='Validate JSON syntax in translation files',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
Validate single file:
python3 json_validator.py ar_AR_batch_1_of_3.json
Validate all batches for a language:
python3 json_validator.py --all-batches ar_AR
Validate pattern:
python3 json_validator.py "ar_AR_batch_*.json"
Validate multiple files:
python3 json_validator.py file1.json file2.json file3.json
"""
)
parser.add_argument(
'files',
nargs='*',
help='JSON file(s) to validate (supports wildcards)'
)
parser.add_argument(
'--all-batches',
metavar='LANGUAGE',
help='Validate all batch files for a language (e.g., ar_AR)'
)
parser.add_argument(
'--quiet',
action='store_true',
help='Only show files with errors'
)
parser.add_argument(
'--brief',
action='store_true',
help='Brief output without context'
)
args = parser.parse_args()
# Determine which files to validate
files_to_validate = []
if args.all_batches:
pattern = f"{args.all_batches}_batch_*.json"
files_to_validate = glob.glob(pattern)
if not files_to_validate:
print(f"No batch files found matching: {pattern}")
return 1
elif args.files:
for file_pattern in args.files:
if '*' in file_pattern or '?' in file_pattern:
files_to_validate.extend(glob.glob(file_pattern))
else:
files_to_validate.append(file_pattern)
else:
parser.print_help()
return 1
if not files_to_validate:
print("No files to validate")
return 1
# Sort files for consistent output
files_to_validate.sort()
print(f"Validating {len(files_to_validate)} file(s)...\n")
# Validate each file
results = []
for file_path in files_to_validate:
result = validate_json_file(file_path)
results.append(result)
if not args.quiet or not result['valid']:
print_validation_result(result, verbose=not args.brief)
# Summary
valid_count = sum(1 for r in results if r['valid'])
invalid_count = len(results) - valid_count
print("=" * 60)
print(f"Summary: {valid_count} valid, {invalid_count} invalid")
# Show common fixes for errors
if invalid_count > 0:
all_errors = [r['error'] for r in results if r['error']]
unique_error_types = set(all_errors)
print("\nCommon fixes:")
fixes_shown = set()
for error in unique_error_types:
fixes = get_common_fixes(error)
for fix in fixes:
if fix not in fixes_shown:
print(f"{fix}")
fixes_shown.add(fix)
return 0 if invalid_count == 0 else 1
if __name__ == '__main__':
sys.exit(main())

View File

@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Validate JSON structure and formatting of translation files.
Checks for:
- Valid JSON syntax
- Consistent key structure with en-GB
- Missing keys
- Extra keys not in en-GB
- Malformed entries
Usage:
python scripts/translations/validate_json_structure.py [--language LANG]
"""
import json
import sys
from pathlib import Path
from typing import Dict, List, Set
import argparse
def get_all_keys(d: dict, parent_key: str = '', sep: str = '.') -> Set[str]:
"""Get all keys from nested dict as dot-notation paths."""
keys = set()
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
keys.add(new_key)
if isinstance(v, dict):
keys.update(get_all_keys(v, new_key, sep=sep))
return keys
def validate_json_file(file_path: Path) -> tuple[bool, str]:
"""Validate that a file contains valid JSON."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
json.load(f)
return True, "Valid JSON"
except json.JSONDecodeError as e:
return False, f"Invalid JSON at line {e.lineno}, column {e.colno}: {e.msg}"
except Exception as e:
return False, f"Error reading file: {str(e)}"
def validate_structure(
en_gb_keys: Set[str],
lang_keys: Set[str],
lang_code: str
) -> Dict:
"""Compare structure between en-GB and target language."""
missing_keys = en_gb_keys - lang_keys
extra_keys = lang_keys - en_gb_keys
return {
'language': lang_code,
'missing_keys': sorted(missing_keys),
'extra_keys': sorted(extra_keys),
'total_keys': len(lang_keys),
'expected_keys': len(en_gb_keys),
'missing_count': len(missing_keys),
'extra_count': len(extra_keys)
}
def print_validation_result(result: Dict, verbose: bool = False):
"""Print validation results in readable format."""
lang = result['language']
print(f"\n{'='*100}")
print(f"Language: {lang}")
print(f"{'='*100}")
print(f" Total keys: {result['total_keys']}")
print(f" Expected keys (en-GB): {result['expected_keys']}")
print(f" Missing keys: {result['missing_count']}")
print(f" Extra keys: {result['extra_count']}")
if result['missing_count'] == 0 and result['extra_count'] == 0:
print(f" ✅ Structure matches en-GB perfectly!")
else:
if result['missing_count'] > 0:
print(f"\n ⚠️ Missing {result['missing_count']} key(s):")
if verbose or result['missing_count'] <= 20:
for key in result['missing_keys'][:50]:
print(f" - {key}")
if result['missing_count'] > 50:
print(f" ... and {result['missing_count'] - 50} more")
else:
print(f" (use --verbose to see all)")
if result['extra_count'] > 0:
print(f"\n ⚠️ Extra {result['extra_count']} key(s) not in en-GB:")
if verbose or result['extra_count'] <= 20:
for key in result['extra_keys'][:50]:
print(f" - {key}")
if result['extra_count'] > 50:
print(f" ... and {result['extra_count'] - 50} more")
else:
print(f" (use --verbose to see all)")
print("-" * 100)
def main():
parser = argparse.ArgumentParser(
description='Validate translation JSON structure'
)
parser.add_argument(
'--language',
help='Specific language code to validate (e.g., es-ES)',
default=None
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Show all missing/extra keys'
)
parser.add_argument(
'--json',
action='store_true',
help='Output results as JSON'
)
args = parser.parse_args()
# Define paths
locales_dir = Path('frontend/public/locales')
en_gb_path = locales_dir / 'en-GB' / 'translation.json'
if not en_gb_path.exists():
print(f"❌ Error: en-GB translation file not found at {en_gb_path}")
sys.exit(1)
# Validate en-GB itself
is_valid, message = validate_json_file(en_gb_path)
if not is_valid:
print(f"❌ Error in en-GB file: {message}")
sys.exit(1)
# Load en-GB structure
with open(en_gb_path, 'r', encoding='utf-8') as f:
en_gb = json.load(f)
en_gb_keys = get_all_keys(en_gb)
# Get list of languages to validate
if args.language:
languages = [args.language]
else:
languages = [
d.name for d in locales_dir.iterdir()
if d.is_dir() and d.name != 'en-GB' and (d / 'translation.json').exists()
]
results = []
json_errors = []
# Validate each language
for lang_code in sorted(languages):
lang_path = locales_dir / lang_code / 'translation.json'
if not lang_path.exists():
print(f"⚠️ Warning: {lang_code}/translation.json not found, skipping")
continue
# First check if JSON is valid
is_valid, message = validate_json_file(lang_path)
if not is_valid:
json_errors.append({
'language': lang_code,
'file': str(lang_path),
'error': message
})
continue
# Load and compare structure
with open(lang_path, 'r', encoding='utf-8') as f:
lang_data = json.load(f)
lang_keys = get_all_keys(lang_data)
result = validate_structure(en_gb_keys, lang_keys, lang_code)
results.append(result)
# Output results
if args.json:
output = {
'json_errors': json_errors,
'structure_validation': results
}
print(json.dumps(output, indent=2, ensure_ascii=False))
else:
# Print JSON errors first
if json_errors:
print("\n❌ JSON Syntax Errors:")
print("=" * 100)
for error in json_errors:
print(f"\nLanguage: {error['language']}")
print(f"File: {error['file']}")
print(f"Error: {error['error']}")
print("\n")
# Print structure validation results
if results:
print("\n📊 Structure Validation Summary:")
print(f" Languages validated: {len(results)}")
perfect = sum(1 for r in results if r['missing_count'] == 0 and r['extra_count'] == 0)
print(f" Perfect matches: {perfect}/{len(results)}")
total_missing = sum(r['missing_count'] for r in results)
total_extra = sum(r['extra_count'] for r in results)
print(f" Total missing keys: {total_missing}")
print(f" Total extra keys: {total_extra}")
for result in results:
print_validation_result(result, verbose=args.verbose)
if not json_errors and perfect == len(results):
print("\n✅ All translations have perfect structure!")
# Exit with error code if issues found
has_issues = len(json_errors) > 0 or any(
r['missing_count'] > 0 or r['extra_count'] > 0 for r in results
)
sys.exit(1 if has_issues else 0)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
Validate that translation files have the same placeholders as en-GB (source of truth).
Usage:
python scripts/translations/validate_placeholders.py [--language LANG] [--fix]
--language: Validate specific language (e.g., es-ES, de-DE)
--fix: Automatically remove extra placeholders (use with caution)
"""
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Set, Tuple
import argparse
def find_placeholders(text: str) -> Set[str]:
"""Find all placeholders in text like {n}, {{var}}, {0}, etc."""
if not isinstance(text, str):
return set()
return set(re.findall(r'\{\{?[^}]+\}\}?', text))
def flatten_dict(d: dict, parent_key: str = '', sep: str = '.') -> Dict[str, str]:
"""Flatten nested dict to dot-notation keys."""
items = []
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.extend(flatten_dict(v, new_key, sep=sep).items())
else:
items.append((new_key, v))
return dict(items)
def validate_language(
en_gb_flat: Dict[str, str],
lang_flat: Dict[str, str],
lang_code: str
) -> List[Dict]:
"""Validate placeholders for a language against en-GB."""
issues = []
for key in en_gb_flat:
if key not in lang_flat:
continue
en_placeholders = find_placeholders(en_gb_flat[key])
lang_placeholders = find_placeholders(lang_flat[key])
if en_placeholders != lang_placeholders:
missing = en_placeholders - lang_placeholders
extra = lang_placeholders - en_placeholders
issue = {
'language': lang_code,
'key': key,
'missing': missing,
'extra': extra,
'en_text': en_gb_flat[key],
'lang_text': lang_flat[key]
}
issues.append(issue)
return issues
def print_issues(issues: List[Dict], verbose: bool = False):
"""Print validation issues in a readable format."""
if not issues:
print("✅ No placeholder validation issues found!")
return
print(f"❌ Found {len(issues)} placeholder validation issue(s):\n")
print("=" * 100)
for i, issue in enumerate(issues, 1):
print(f"\n{i}. Language: {issue['language']}")
print(f" Key: {issue['key']}")
if issue['missing']:
print(f" ⚠️ MISSING placeholders: {issue['missing']}")
if issue['extra']:
print(f" ⚠️ EXTRA placeholders: {issue['extra']}")
if verbose:
print(f" EN-GB: {issue['en_text'][:150]}")
print(f" {issue['language']}: {issue['lang_text'][:150]}")
print("-" * 100)
def main():
parser = argparse.ArgumentParser(
description='Validate translation placeholder consistency'
)
parser.add_argument(
'--language',
help='Specific language code to validate (e.g., es-ES)',
default=None
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Show full text samples for each issue'
)
parser.add_argument(
'--json',
action='store_true',
help='Output results as JSON'
)
args = parser.parse_args()
# Define paths
locales_dir = Path('frontend/public/locales')
en_gb_path = locales_dir / 'en-GB' / 'translation.json'
if not en_gb_path.exists():
print(f"❌ Error: en-GB translation file not found at {en_gb_path}")
sys.exit(1)
# Load en-GB (source of truth)
with open(en_gb_path, 'r', encoding='utf-8') as f:
en_gb = json.load(f)
en_gb_flat = flatten_dict(en_gb)
# Get list of languages to validate
if args.language:
languages = [args.language]
else:
# Validate all languages except en-GB
languages = [
d.name for d in locales_dir.iterdir()
if d.is_dir() and d.name != 'en-GB' and (d / 'translation.json').exists()
]
all_issues = []
# Validate each language
for lang_code in sorted(languages):
lang_path = locales_dir / lang_code / 'translation.json'
if not lang_path.exists():
print(f"⚠️ Warning: {lang_code}/translation.json not found, skipping")
continue
with open(lang_path, 'r', encoding='utf-8') as f:
lang_data = json.load(f)
lang_flat = flatten_dict(lang_data)
issues = validate_language(en_gb_flat, lang_flat, lang_code)
all_issues.extend(issues)
# Output results
if args.json:
print(json.dumps(all_issues, indent=2, ensure_ascii=False))
else:
if all_issues:
# Group by language
by_language = {}
for issue in all_issues:
lang = issue['language']
if lang not in by_language:
by_language[lang] = []
by_language[lang].append(issue)
print(f"📊 Validation Summary:")
print(f" Total issues: {len(all_issues)}")
print(f" Languages with issues: {len(by_language)}\n")
for lang in sorted(by_language.keys()):
print(f"\n{'='*100}")
print(f"Language: {lang} ({len(by_language[lang])} issue(s))")
print(f"{'='*100}")
print_issues(by_language[lang], verbose=args.verbose)
else:
print("✅ All translations have correct placeholders!")
# Exit with error code if issues found
sys.exit(1 if all_issues else 0)
if __name__ == '__main__':
main()