From 87bf7a5b7ff1d1292034320e0ddd3a8722c83901 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 18 Nov 2025 15:07:12 +0000 Subject: [PATCH] Add edit table of contents tool to React UI (#4917) ## Summary - add a dedicated edit table of contents tool to the React UI, complete with bookmark editor, import/export actions, and parameter handling - register the tool in the translated registry and extend the English translations with the new strings - wire up the backend endpoints through a new operation hook and form-data serialization helpers ## Testing - ./gradlew build ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_691a4a87a9c4832899ecd1c55989f27f) --------- Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- .../api/EditTableOfContentsController.java | 10 +- .../EditTableOfContentsControllerTest.java | 25 +- .../public/locales/en-GB/translation.json | 87 ++++ .../public/locales/en-US/translation.json | 70 ++++ .../editTableOfContents/BookmarkEditor.tsx | 313 ++++++++++++++ .../EditTableOfContentsSettings.tsx | 187 +++++++++ .../EditTableOfContentsWorkbenchView.tsx | 203 +++++++++ .../core/data/useTranslatedToolRegistry.tsx | 29 +- .../useEditTableOfContentsOperation.ts | 31 ++ .../useEditTableOfContentsParameters.ts | 37 ++ .../src/core/tools/EditTableOfContents.tsx | 394 ++++++++++++++++++ .../src/core/utils/editTableOfContents.ts | 47 +++ 12 files changed, 1413 insertions(+), 20 deletions(-) create mode 100644 frontend/src/core/components/tools/editTableOfContents/BookmarkEditor.tsx create mode 100644 frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.tsx create mode 100644 frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx create mode 100644 frontend/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts create mode 100644 frontend/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsParameters.ts create mode 100644 frontend/src/core/tools/EditTableOfContents.tsx create mode 100644 frontend/src/core/utils/editTableOfContents.ts diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java index 481ca16d6..10a2ac8a2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTableOfContentsController.java @@ -29,7 +29,6 @@ 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; @@ -49,13 +48,12 @@ 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 List> extractBookmarks(@RequestParam("file") MultipartFile file) - throws Exception { + public ResponseEntity>> extractBookmarks( + @RequestParam("file") MultipartFile file) throws Exception { PDDocument document = null; try { document = pdfDocumentFactory.load(file); @@ -63,10 +61,10 @@ public class EditTableOfContentsController { if (outline == null) { log.info("No outline/bookmarks found in PDF"); - return new ArrayList<>(); + return ResponseEntity.ok(new ArrayList<>()); } - return extractBookmarkItems(document, outline); + return ResponseEntity.ok(extractBookmarkItems(document, outline)); } finally { if (document != null) { document.close(); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/EditTableOfContentsControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/EditTableOfContentsControllerTest.java index 5368ef193..d99c1eac3 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/EditTableOfContentsControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/EditTableOfContentsControllerTest.java @@ -24,6 +24,7 @@ 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; @@ -86,9 +87,13 @@ class EditTableOfContentsControllerTest { when(mockOutlineItem.getNextSibling()).thenReturn(null); // When - List> result = editTableOfContentsController.extractBookmarks(mockFile); + ResponseEntity>> response = + editTableOfContentsController.extractBookmarks(mockFile); // Then + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + List> result = response.getBody(); assertNotNull(result); assertEquals(1, result.size()); @@ -108,9 +113,13 @@ class EditTableOfContentsControllerTest { when(mockCatalog.getDocumentOutline()).thenReturn(null); // When - List> result = editTableOfContentsController.extractBookmarks(mockFile); + ResponseEntity>> response = + editTableOfContentsController.extractBookmarks(mockFile); // Then + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + List> result = response.getBody(); assertNotNull(result); assertTrue(result.isEmpty()); verify(mockDocument).close(); @@ -142,9 +151,13 @@ class EditTableOfContentsControllerTest { when(childItem.getNextSibling()).thenReturn(null); // When - List> result = editTableOfContentsController.extractBookmarks(mockFile); + ResponseEntity>> response = + editTableOfContentsController.extractBookmarks(mockFile); // Then + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + List> result = response.getBody(); assertNotNull(result); assertEquals(1, result.size()); @@ -178,9 +191,13 @@ class EditTableOfContentsControllerTest { when(mockOutlineItem.getNextSibling()).thenReturn(null); // When - List> result = editTableOfContentsController.extractBookmarks(mockFile); + ResponseEntity>> response = + editTableOfContentsController.extractBookmarks(mockFile); // Then + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + List> result = response.getBody(); assertNotNull(result); assertEquals(1, result.size()); diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 9909f4c5d..c9cd9c692 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1427,6 +1427,93 @@ }, "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", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 405804e22..6a1d7255c 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -1397,6 +1397,76 @@ }, "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." + }, + "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" + }, + "submit": "Apply table of contents", + "results": { + "title": "Updated PDF with bookmarks" + } + }, "removePages": { "tags": "Remove pages,delete pages", "title": "Remove Pages", diff --git a/frontend/src/core/components/tools/editTableOfContents/BookmarkEditor.tsx b/frontend/src/core/components/tools/editTableOfContents/BookmarkEditor.tsx new file mode 100644 index 000000000..348c49b56 --- /dev/null +++ b/frontend/src/core/components/tools/editTableOfContents/BookmarkEditor.tsx @@ -0,0 +1,313 @@ +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 ( + + + + + hasChildren && handleToggle(bookmark.id)} + disabled={disabled || !hasChildren} + aria-label={t('editTableOfContents.editor.actions.toggle', 'Toggle children')} + style={{ marginTop: 4 }} + > + + + + + {bookmark.title || t('editTableOfContents.editor.untitled', 'Untitled bookmark')} + {level > 0 && ( + + {t('editTableOfContents.editor.childBadge', 'Child')} + + )} + + + {t('editTableOfContents.editor.pagePreview', { page: bookmark.pageNumber })} + + + + + + handleAddChild(bookmark.id)} + disabled={disabled} + > + + + + + handleAddSibling(bookmark.id)} + disabled={disabled} + > + + + + + handleRemove(bookmark.id)} + disabled={disabled} + > + + + + + + + {bookmark.expanded && ( + + handleTitleChange(bookmark.id, event.currentTarget.value)} + disabled={disabled} + /> + handlePageChange(bookmark.id, value ?? 1)} + disabled={disabled} + /> + + )} + + {bookmark.expanded && hasChildren && ( + + {bookmark.children.map(child => ( + {renderBookmark(child, level + 1)} + ))} + + )} + + + ); + }; + + return ( + + +
+ {t('editTableOfContents.editor.heading', 'Bookmark editor')} + + {t('editTableOfContents.editor.description', 'Add, nest, and reorder bookmarks to craft your PDF outline.')} + +
+ +
+ + {bookmarks.length === 0 ? ( + + + + {t('editTableOfContents.editor.empty.title', 'No bookmarks yet')} + + {t('editTableOfContents.editor.empty.description', 'Import existing bookmarks or start by adding your first entry.')} + + + + + ) : ( + + {bookmarks.map(bookmark => renderBookmark(bookmark))} + + )} +
+ ); +} diff --git a/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.tsx b/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.tsx new file mode 100644 index 000000000..aa644acfd --- /dev/null +++ b/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.tsx @@ -0,0 +1,187 @@ +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 ( + + + {t('editTableOfContents.actions.source', 'Load bookmarks')} + + {selectedFileName + ? t('editTableOfContents.actions.selectedFile', { file: selectedFileName }) + : t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.')} + + + + + + + + + + + file && onImportJson(file)} + accept="application/json" + disabled={disabled} + > + {(props) => ( + + )} + + + + + + + + {loadError && ( + }> + {loadError} + + )} + + + + + {t('editTableOfContents.actions.export', 'Export bookmarks')} + + + + + + + + + + + + + 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} + /> + + + {infoLines.map((line, index) => ( + + {line} + + ))} + + + ); +} diff --git a/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx b/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx new file mode 100644 index 000000000..228a5c83b --- /dev/null +++ b/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx @@ -0,0 +1,203 @@ +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 ( + + + + {t('editTableOfContents.workbench.empty.title', 'Open the tool to start editing')} + + {t('editTableOfContents.workbench.empty.description', 'Select the Edit Table of Contents tool to load its workspace.')} + + + + + ); + } + + 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 ( + + + + + {t('home.editTableOfContents.title', 'Edit Table of Contents')} + + + {t('editTableOfContents.workbench.subtitle', 'Import bookmarks, build hierarchies, and apply the outline without cramped side panels.')} + + + + + + + {t('editTableOfContents.editor.heading', 'Bookmark editor')} + + {selectedFileName + ? t('editTableOfContents.actions.selectedFile', { file: selectedFileName }) + : t('editTableOfContents.workbench.filePrompt', 'Select a PDF from your library or upload a new one to begin.')} + + + + + + + + + + + {showResults && ( + + + + {t('editTableOfContents.results.title', 'Updated PDF with bookmarks')} + + {t('editTableOfContents.results.subtitle', 'Download the processed file or undo the operation below.')} + + + + + + {previewFiles.length > 0 && ( + + )} + + + {downloadUrl && ( + + )} + + + + + )} + + + ); +}; + +export default EditTableOfContentsWorkbenchView; diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index bcd11478b..e9ecf0c08 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -31,6 +31,7 @@ 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"; @@ -63,6 +64,7 @@ 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"; @@ -345,6 +347,23 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { automationSettings: ChangeMetadataSingleStep, synonyms: getSynonyms(t, "changeMetadata") }, + editTableOfContents: { + icon: , + 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: { @@ -689,16 +708,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { synonyms: getSynonyms(t, "addImage"), automationSettings: null }, - editTableOfContents: { - icon: , - 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: , name: t("home.scannerEffect.title", "Scanner Effect"), diff --git a/frontend/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts b/frontend/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts new file mode 100644 index 000000000..58878d811 --- /dev/null +++ b/frontend/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts @@ -0,0 +1,31 @@ +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 = { + toolType: ToolType.singleFile, + operationType: 'editTableOfContents', + endpoint: '/api/v1/general/edit-table-of-contents', + buildFormData, +}; + +export const useEditTableOfContentsOperation = () => { + const { t } = useTranslation(); + return useToolOperation({ + ...editTableOfContentsOperationConfig, + getErrorMessage: createStandardErrorHandler( + t('editTableOfContents.error.failed', 'Failed to update the table of contents') + ), + }); +}; + diff --git a/frontend/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsParameters.ts b/frontend/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsParameters.ts new file mode 100644 index 000000000..5c6866168 --- /dev/null +++ b/frontend/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsParameters.ts @@ -0,0 +1,37 @@ +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 { + setBookmarks: (bookmarks: BookmarkNode[]) => void; +} + +const defaultParameters: EditTableOfContentsParameters = { + replaceExisting: true, + bookmarks: [], +}; + +export const useEditTableOfContentsParameters = (): EditTableOfContentsParametersHook => { + const base = useBaseParameters({ + defaultParameters, + endpointName: 'edit-table-of-contents', + }); + + const setBookmarks = useCallback((bookmarks: BookmarkNode[]) => { + base.setParameters(prev => ({ + ...prev, + bookmarks, + })); + }, [base.setParameters]); + + return { + ...base, + setBookmarks, + }; +}; + diff --git a/frontend/src/core/tools/EditTableOfContents.tsx b/frontend/src/core/tools/EditTableOfContents.tsx new file mode 100644 index 000000000..a86260394 --- /dev/null +++ b/frontend/src/core/tools/EditTableOfContents.tsx @@ -0,0 +1,394 @@ +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 => { + 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 = any>(callback: T): T => { + const callbackRef = useRef(callback); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + return useMemo(() => ((...args: Parameters) => 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(() => , []); + + const [loadError, setLoadError] = useState(null); + const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(false); + const [lastLoadedFileId, setLastLoadedFileId] = useState(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: ( + + ), + }, + ], + 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; diff --git a/frontend/src/core/utils/editTableOfContents.ts b/frontend/src/core/utils/editTableOfContents.ts new file mode 100644 index 000000000..2c7762a45 --- /dev/null +++ b/frontend/src/core/utils/editTableOfContents.ts @@ -0,0 +1,47 @@ +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 => ({ + 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), + })); +};