From b8ce4e47c1c358e03d33acd48024c052279de201 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:15:28 +0000 Subject: [PATCH] Preserve local paths for desktop saves (#5543) # Summary - Adds desktop file tracking: local paths are preserved and save buttons now work as expcted (doing Save/Save As as appropriate) - Adds logic to track whether files are 'dirty' (they've been modified by some tool, and not saved to disk yet). - Improves file state UX (dirty vs saved) and close warnings - Web behaviour should be unaffected by these changes ## Indicators Files now have indicators in desktop mode to tell you their state. ### File up-to-date with disk image ### File modified by a tool but not saved to disk yet image ### File not tracked on disk image # Limitations - It's a bit weird that we still have files stored in indexeddb in the app, which are still loadable. We might want to change this behaviour in the future - Viewer's Save doesn't persist to disk. I've left that out here because it'd need a lot of testing to make sure the logic's right with making sure you can leave the Viewer with applying the changes to the PDF _without_ saving to disk - There's no current way to do Save As on a file that has already been persisted to disk - it's only ever Save. Similarly, there's no way to duplicate a file. --------- Co-authored-by: James Brunton Co-authored-by: James Brunton --- CLAUDE.md | 118 ++++++++++++++- frontend/package-lock.json | 10 ++ frontend/package.json | 1 + .../public/locales/en-GB/translation.toml | 8 ++ frontend/src-tauri/Cargo.lock | 45 ++++++ frontend/src-tauri/Cargo.toml | 1 + frontend/src-tauri/capabilities/default.json | 13 ++ frontend/src-tauri/src/commands/files.rs | 9 ++ frontend/src-tauri/src/commands/mod.rs | 2 +- frontend/src-tauri/src/lib.rs | 9 +- .../components/fileEditor/AddFileCard.tsx | 13 +- .../core/components/fileEditor/FileEditor.tsx | 24 +++- .../fileEditor/FileEditorFileName.tsx | 13 ++ .../fileEditor/FileEditorThumbnail.tsx | 86 ++++++++--- .../components/fileManager/FileActions.tsx | 11 +- .../components/fileManager/FileListItem.tsx | 3 +- .../components/pageEditor/FileThumbnail.tsx | 9 +- .../pageEditor/hooks/usePageEditorExport.ts | 15 ++ .../core/components/shared/LandingPage.tsx | 11 +- .../src/core/components/shared/RightRail.tsx | 48 +++++-- .../EditTableOfContentsWorkbenchView.tsx | 5 +- .../tools/getPdfInfo/GetPdfInfoResults.tsx | 11 +- .../tools/shared/ReviewToolStep.tsx | 34 ++++- .../src/core/components/tools/showJS/utils.ts | 11 +- .../ValidateSignatureResults.tsx | 10 +- .../src/core/contexts/FileManagerContext.tsx | 27 +++- .../src/core/contexts/file/fileActions.ts | 53 ++++++- .../hooks/tools/shared/useToolOperation.ts | 26 +++- .../core/hooks/tools/shared/useToolState.ts | 22 +-- frontend/src/core/services/downloadService.ts | 40 ++++++ .../src/core/services/fileDialogService.ts | 28 ++++ .../src/core/services/localFileSaveService.ts | 48 +++++++ .../src/core/services/openFilesFromDisk.ts | 29 ++++ .../services/operationResultsSaveService.ts | 30 ++++ .../src/core/services/pdfExportService.ts | 14 +- .../tests/convert/ConvertIntegration.test.tsx | 4 +- .../src/core/tools/EditTableOfContents.tsx | 10 +- frontend/src/core/types/fileContext.ts | 2 + .../src/core/utils/automationConverter.ts | 14 +- frontend/src/core/utils/downloadUtils.ts | 26 ++-- frontend/src/core/utils/fileDialogUtils.ts | 6 + .../src/desktop/components/AppProviders.tsx | 2 + .../components/SaveShortcutListener.tsx | 14 ++ .../fileEditor/FileEditorFileName.tsx | 66 +++++++++ frontend/src/desktop/constants/connection.ts | 3 - .../src/desktop/hooks/useAppInitialization.ts | 62 +++++--- frontend/src/desktop/hooks/useExitWarning.ts | 135 ++++++++++++++++++ frontend/src/desktop/hooks/useOpenedFile.ts | 19 ++- frontend/src/desktop/hooks/useSaveShortcut.ts | 63 ++++++++ .../src/desktop/services/downloadService.ts | 39 +++++ .../src/desktop/services/fileDialogService.ts | 60 ++++++++ .../src/desktop/services/fileOpenService.ts | 6 +- .../desktop/services/localFileSaveService.ts | 123 ++++++++++++++++ .../services/operationResultsSaveService.ts | 42 ++++++ frontend/vite.config.ts | 16 +++ testing/test_pdf_1.pdf | Bin 1591 -> 1105 bytes 56 files changed, 1367 insertions(+), 182 deletions(-) create mode 100644 frontend/src/core/components/fileEditor/FileEditorFileName.tsx create mode 100644 frontend/src/core/services/downloadService.ts create mode 100644 frontend/src/core/services/fileDialogService.ts create mode 100644 frontend/src/core/services/localFileSaveService.ts create mode 100644 frontend/src/core/services/openFilesFromDisk.ts create mode 100644 frontend/src/core/services/operationResultsSaveService.ts create mode 100644 frontend/src/core/utils/fileDialogUtils.ts create mode 100644 frontend/src/desktop/components/SaveShortcutListener.tsx create mode 100644 frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx create mode 100644 frontend/src/desktop/hooks/useExitWarning.ts create mode 100644 frontend/src/desktop/hooks/useSaveShortcut.ts create mode 100644 frontend/src/desktop/services/downloadService.ts create mode 100644 frontend/src/desktop/services/fileDialogService.ts create mode 100644 frontend/src/desktop/services/localFileSaveService.ts create mode 100644 frontend/src/desktop/services/operationResultsSaveService.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2c4cfa537..f1461dcb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,91 @@ Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security featur - **Web Server**: `npm run build` then serve dist/ folder - **Development**: `npm run tauri-dev` for desktop dev mode +#### Import Paths - CRITICAL +**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation. + +```typescript +// ✅ CORRECT - Use @app/* for all imports +import { AppLayout } from "@app/components/AppLayout"; +import { useFileContext } from "@app/contexts/FileContext"; +import { FileContext } from "@app/contexts/FileContext"; + +// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code +import { AppLayout } from "@core/components/AppLayout"; +import { useFileContext } from "@proprietary/contexts/FileContext"; +``` + +**Only use explicit aliases when:** +- Building layer-specific override that wraps a lower layer's component +- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version + +The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade. + +#### Component Override Pattern (Stub/Shadow) +Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals. + +**How it works:** +1. Core defines stub component (returns null or no-op) +2. Desktop/proprietary overrides with same path/name +3. Core imports via `@app/*` - higher layer "shadows" core in those builds +4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals! + +**Example - Desktop-specific footer:** + +```typescript +// core/components/rightRail/RightRailFooterExtensions.tsx (stub) +interface RightRailFooterExtensionsProps { + className?: string; +} + +export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) { + return null; // Stub - does nothing in web builds +} +``` + +```typescript +// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation) +import { Box } from '@mantine/core'; +import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator'; + +interface RightRailFooterExtensionsProps { + className?: string; +} + +export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) { + return ( + + + + ); +} +``` + +```typescript +// core/components/shared/RightRail.tsx (usage - works in ALL builds) +import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions'; + +export function RightRail() { + return ( +
+ {/* In web builds: renders nothing (stub returns null) */} + {/* In desktop builds: renders BackendHealthIndicator */} + +
+ ); +} +``` + +**Build resolution:** +- **Core build**: `@app/*` → `core/*` → Gets stub (returns null) +- **Desktop build**: `@app/*` → `desktop/*` → Gets real implementation (shadows core) + +**Benefits:** +- No runtime checks or feature flags +- Type-safe across all builds +- Clean, readable code +- Build-time optimization (dead code elimination) + #### Multi-Tool Workflow Architecture Frontend designed for **stateful document processing**: - Users upload PDFs once, then chain tools (split → merge → compress → view) @@ -37,7 +122,7 @@ Frontend designed for **stateful document processing**: - No file reloading between tools - performance critical for large PDFs (up to 100GB+) #### FileContext - Central State Management -**Location**: `src/contexts/FileContext.tsx` +**Location**: `frontend/src/core/contexts/FileContext.tsx` - **Active files**: Currently loaded PDFs and their variants - **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName) - **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management @@ -62,7 +147,7 @@ Without cleanup: browser crashes with memory leaks. **Architecture**: Modular hook-based system with clear separation of concerns: -- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook +- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook - Coordinates all tool operations with consistent interface - Integrates with FileContext for operation tracking - Handles validation, error handling, and UI state management @@ -147,8 +232,34 @@ return useToolOperation({ - **Pipeline System**: Automated PDF processing workflows via `PipelineController` - **Security Layer**: Authentication, authorization, and user management (when enabled) +### Frontend Directory Structure +The frontend is organized with a clear separation of concerns: + +- **`frontend/src/core/`**: Main application code (shared, production-ready components) + - **`core/components/`**: React components organized by feature + - `core/components/tools/`: Individual PDF tool implementations + - `core/components/viewer/`: PDF viewer components + - `core/components/pageEditor/`: Page manipulation UI + - `core/components/tooltips/`: Help tooltips for tools + - `core/components/shared/`: Reusable UI components + - **`core/contexts/`**: React Context providers + - `FileContext.tsx`: Central file state management + - `file/`: File reducer and selectors + - `toolWorkflow/`: Tool workflow state + - **`core/hooks/`**: Custom React hooks + - `hooks/tools/`: Tool-specific operation hooks (one directory per tool) + - `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.) + - **`core/constants/`**: Application constants and configuration + - **`core/data/`**: Static data (tool taxonomy, etc.) + - **`core/services/`**: Business logic services (PDF processing, storage, etc.) + +- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code +- **`frontend/src/proprietary/`**: Proprietary/licensed features +- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code +- **`frontend/public/`**: Static assets served directly + - `public/locales/`: Translation JSON files + ### Component Architecture -- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/` - **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern) - **Internationalization**: - Backend: `messages_*.properties` files @@ -203,6 +314,7 @@ return useToolOperation({ - **Backend**: Designed to be stateless - files are processed in memory/temp locations only - **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails) - **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation +- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer - **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling - **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code - **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 46c65fe28..266e9661e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -50,6 +50,7 @@ "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-http": "^2.5.6", "@tauri-apps/plugin-shell": "^2.3.4", @@ -4198,6 +4199,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", + "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-fs": { "version": "2.4.5", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index fac874d13..ba9fb5de3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-http": "^2.5.6", "@tauri-apps/plugin-shell": "^2.3.4", diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index b4cb435dc..33dee7950 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -17,6 +17,12 @@ confirmClose = "Confirm Close" confirmCloseCancel = "Cancel" confirmCloseConfirm = "Close File" confirmCloseMessage = "Are you sure you want to close this file?" +confirmCloseDiscard = "Discard changes and close" +confirmCloseSave = "Save and close" +confirmCloseUnsaved = "This file has unsaved changes." +confirmCloseUnsavedList = "You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}" +confirmCloseSaveFailedTitle = "Save Failed" +confirmCloseSaveFailed = "Saved with errors. {{count}} file{{plural}} could not be saved." confirmPasswordErrorMessage = "New Password and Confirm New Password must match." custom = "Custom..." customPosition = "Custom Position" @@ -39,6 +45,8 @@ edit = "Edit" editYourNewFiles = "Edit your new file(s)" exportAndContinue = "Export & Continue" false = "False" +fileSavedToDisk = "File saved to disk" +fileNotSavedToDisk = "Not saved to disk" fileSelected = "Selected: {{filename}}" filesSelected = "{{count}} files selected" font = "Font" diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index bda073963..7029acb12 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -907,6 +907,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", + "block2", + "libc", "objc2", ] @@ -3531,6 +3533,30 @@ dependencies = [ "webpki-roots 1.0.5", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4229,6 +4255,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-deep-link", + "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-log", @@ -4609,6 +4636,24 @@ dependencies = [ "windows-result 0.3.4", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + [[package]] name = "tauri-plugin-fs" version = "2.4.5" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index a415d879f..f25f63d88 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ tauri = { version = "2.9.0", features = [ "devtools"] } tauri-plugin-log = "2.8.0" tauri-plugin-shell = "2.3.4" tauri-plugin-fs = "2.4.5" +tauri-plugin-dialog = "2.4.2" tauri-plugin-http = { version = "2.5.6", features = ["dangerous-settings"] } tauri-plugin-single-instance = { version = "2.3.7", features = ["deep-link"] } tauri-plugin-store = "2.4.2" diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index ac4e364ae..a9243c2e2 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ ], "permissions": [ "core:default", + "core:window:allow-destroy", "http:default", { "identifier": "http:allow-fetch", @@ -40,6 +41,18 @@ "identifier": "fs:allow-read-file", "allow": [{ "path": "**" }] }, + { + "identifier": "fs:allow-write-file", + "allow": [{ "path": "**" }] + }, + { + "identifier": "fs:allow-remove", + "allow": [{ "path": "**" }] + }, + "dialog:default", + "dialog:allow-message", + "dialog:allow-open", + "dialog:allow-save", "opener:default", "shell:allow-open" ] diff --git a/frontend/src-tauri/src/commands/files.rs b/frontend/src-tauri/src/commands/files.rs index 7faadf0db..1e3c9b61c 100644 --- a/frontend/src-tauri/src/commands/files.rs +++ b/frontend/src-tauri/src/commands/files.rs @@ -33,3 +33,12 @@ pub async fn clear_opened_files() -> Result<(), String> { Ok(()) } +// Command to atomically get and clear opened file paths +#[tauri::command] +pub async fn pop_opened_files() -> Result, String> { + let mut opened_files = OPENED_FILES.lock().unwrap(); + let all_files = opened_files.clone(); + opened_files.clear(); + add_log(format!("📂 Returning and clearing {} opened file(s)", all_files.len())); + Ok(all_files) +} diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index 30904bbd9..3e9840811 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -5,7 +5,7 @@ pub mod auth; pub mod default_app; pub use backend::{cleanup_backend, get_backend_port, start_backend}; -pub use files::{add_opened_file, clear_opened_files, get_opened_files}; +pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files}; pub use connection::{ get_connection_config, is_first_launch, diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 673442dcf..5d6a38400 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ use commands::{ get_backend_port, get_connection_config, get_opened_files, + pop_opened_files, get_refresh_token, get_user_info, is_first_launch, @@ -55,6 +56,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_deep_link::init()) @@ -139,6 +141,7 @@ pub fn run() { start_backend, get_backend_port, get_opened_files, + pop_opened_files, clear_opened_files, get_tauri_logs, get_connection_config, @@ -170,9 +173,9 @@ pub fn run() { app_handle.cleanup_before_exit(); } RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, .. } => { - add_log("🔄 Window close requested, cleaning up...".to_string()); - cleanup_backend(); - // Allow the window to close + add_log("🔄 Window close requested (will cleanup on actual exit)...".to_string()); + // Don't cleanup here - let JavaScript handler prevent close if needed + // Backend cleanup happens in ExitRequested when window actually closes } RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => { use tauri::DragDropEvent; diff --git a/frontend/src/core/components/fileEditor/AddFileCard.tsx b/frontend/src/core/components/fileEditor/AddFileCard.tsx index a4a549d82..c5cafd756 100644 --- a/frontend/src/core/components/fileEditor/AddFileCard.tsx +++ b/frontend/src/core/components/fileEditor/AddFileCard.tsx @@ -8,6 +8,7 @@ import { useLogoAssets } from '@app/hooks/useLogoAssets'; import styles from '@app/components/fileEditor/FileEditor.module.css'; import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; +import { openFilesFromDisk } from '@app/services/openFilesFromDisk'; interface AddFileCardProps { onFileSelect: (files: File[]) => void; @@ -33,9 +34,15 @@ const AddFileCard = ({ openFilesModal(); }; - const handleNativeUploadClick = (e: React.MouseEvent) => { + const handleNativeUploadClick = async (e: React.MouseEvent) => { e.stopPropagation(); - fileInputRef.current?.click(); + const files = await openFilesFromDisk({ + multiple, + onFallbackOpen: () => fileInputRef.current?.click() + }); + if (files.length > 0) { + onFileSelect(files); + } }; const handleOpenFilesModal = (e: React.MouseEvent) => { @@ -179,4 +186,4 @@ const AddFileCard = ({ ); }; -export default AddFileCard; \ No newline at end of file +export default AddFileCard; diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index cc825d367..8636d82c0 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -12,7 +12,7 @@ import AddFileCard from '@app/components/fileEditor/AddFileCard'; import FilePickerModal from '@app/components/shared/FilePickerModal'; import { FileId, StirlingFile } from '@app/types/fileContext'; import { alert } from '@app/components/toast'; -import { downloadBlob } from '@app/utils/downloadUtils'; +import { downloadFile } from '@app/services/downloadService'; import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; @@ -278,13 +278,29 @@ const FileEditor = ({ } }, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]); - const handleDownloadFile = useCallback((fileId: FileId) => { + const handleDownloadFile = useCallback(async (fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; + console.log('[FileEditor] handleDownloadFile called:', { fileId, hasRecord: !!record, hasFile: !!file, localFilePath: record?.localFilePath, isDirty: record?.isDirty }); if (record && file) { - downloadBlob(file, file.name); + const result = await downloadFile({ + data: file, + filename: file.name, + localPath: record.localFilePath + }); + console.log('[FileEditor] Download complete, checking dirty state:', { localFilePath: record.localFilePath, isDirty: record.isDirty, savedPath: result.savedPath }); + // Mark file as clean after successful save to disk + if (result.savedPath) { + console.log('[FileEditor] Marking file as clean:', fileId); + fileActions.updateStirlingFileStub(fileId, { + localFilePath: record.localFilePath ?? result.savedPath, + isDirty: false + }); + } else { + console.log('[FileEditor] Skipping clean mark:', { savedPath: result.savedPath, isDirty: record.isDirty }); + } } - }, [activeStirlingFileStubs, selectors, _setStatus]); + }, [activeStirlingFileStubs, selectors, fileActions]); const handleUnzipFile = useCallback(async (fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); diff --git a/frontend/src/core/components/fileEditor/FileEditorFileName.tsx b/frontend/src/core/components/fileEditor/FileEditorFileName.tsx new file mode 100644 index 000000000..a3e4cd449 --- /dev/null +++ b/frontend/src/core/components/fileEditor/FileEditorFileName.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { StirlingFileStub } from '@app/types/fileContext'; +import { PrivateContent } from '@app/components/shared/PrivateContent'; + +interface FileEditorFileNameProps { + file: StirlingFileStub; +} + +const FileEditorFileName = ({ file }: FileEditorFileNameProps) => ( + {file.name} +); + +export default FileEditorFileName; diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index f6339be5e..40dd0966d 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -23,6 +23,8 @@ import { FileId } from '@app/types/file'; import { formatFileSize } from '@app/utils/fileUtils'; import ToolChain from '@app/components/shared/ToolChain'; import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu'; +import { downloadFile } from '@app/services/downloadService'; +import FileEditorFileName from '@app/components/fileEditor/FileEditorFileName'; import { PrivateContent } from '@app/components/shared/PrivateContent'; @@ -69,7 +71,7 @@ const FileEditorThumbnail = ({ actions: fileActions, openEncryptedUnlockPrompt, } = useFileContext(); - const { state } = useFileState(); + const { state, selectors } = useFileState(); const hasError = state.ui.errorFileIds.includes(file.id); // ---- Drag state ---- @@ -191,6 +193,37 @@ const FileEditorThumbnail = ({ setShowCloseModal(false); }, [file.id, file.name, onCloseFile]); + const handleSaveAndClose = useCallback(async () => { + const fileToSave = selectors.getFile(file.id); + if (fileToSave) { + try { + const result = await downloadFile({ + data: fileToSave, + filename: file.name, + localPath: file.localFilePath + }); + if (!result.cancelled && result.savedPath) { + fileActions.updateStirlingFileStub(file.id, { + localFilePath: file.localFilePath ?? result.savedPath, + isDirty: false + }); + } else if (result.cancelled) { + setShowCloseModal(false); + return; + } + } catch (error) { + console.error(`Failed to save ${file.name}:`, error); + alert({ alertType: 'error', title: 'Save failed', body: `Could not save ${file.name}`, expandable: true }); + setShowCloseModal(false); + return; + } + } + // Then close + onCloseFile(file.id); + alert({ alertType: 'success', title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 }); + setShowCloseModal(false); + }, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]); + const handleCancelClose = useCallback(() => { setShowCloseModal(false); }, []); @@ -213,7 +246,6 @@ const FileEditorThumbnail = ({ onClick: (e) => { e.stopPropagation(); onDownloadFile(file.id); - alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); }, }, { @@ -365,8 +397,8 @@ const FileEditorThumbnail = ({ marginTop: '0.5rem', marginBottom: '0.5rem', }}> - - {file.name} + + - {t('confirmCloseMessage', 'Are you sure you want to close this file?')} - - {file.name} - - - - - + {file.isDirty && file.localFilePath ? ( + <> + {t('confirmCloseUnsaved', 'This file has unsaved changes.')} + + {file.name} + + + + + + + + ) : ( + <> + {t('confirmCloseMessage', 'Are you sure you want to close this file?')} + + {file.name} + + + + + + + )} diff --git a/frontend/src/core/components/fileManager/FileActions.tsx b/frontend/src/core/components/fileManager/FileActions.tsx index 8f919e92c..19d5e634e 100644 --- a/frontend/src/core/components/fileManager/FileActions.tsx +++ b/frontend/src/core/components/fileManager/FileActions.tsx @@ -12,8 +12,14 @@ const FileActions: React.FC = () => { const terminology = useFileActionTerminology(); const icons = useFileActionIcons(); const DownloadIcon = icons.download; - const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } = - useFileManagerContext(); + const { + recentFiles, + selectedFileIds, + filteredFiles, + onSelectAll, + onDeleteSelected, + onDownloadSelected + } = useFileManagerContext(); const handleSelectAll = () => { onSelectAll(); @@ -31,6 +37,7 @@ const FileActions: React.FC = () => { } }; + // Only show actions if there are files if (recentFiles.length === 0) { return null; diff --git a/frontend/src/core/components/fileManager/FileListItem.tsx b/frontend/src/core/components/fileManager/FileListItem.tsx index 87b458e10..bab58c1b2 100644 --- a/frontend/src/core/components/fileManager/FileListItem.tsx +++ b/frontend/src/core/components/fileManager/FileListItem.tsx @@ -46,7 +46,7 @@ const FileListItem: React.FC = ({ const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const { t } = useTranslation(); - const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext(); + const { expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext(); const { removeFiles } = useFileManagement(); // Check if this is a ZIP file @@ -269,6 +269,7 @@ const FileListItem: React.FC = ({ > {t('fileManager.delete', 'Delete')} + diff --git a/frontend/src/core/components/pageEditor/FileThumbnail.tsx b/frontend/src/core/components/pageEditor/FileThumbnail.tsx index fffba18b4..b35ae89d0 100644 --- a/frontend/src/core/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/core/components/pageEditor/FileThumbnail.tsx @@ -14,6 +14,7 @@ import { FileId } from '@app/types/file'; import { PrivateContent } from '@app/components/shared/PrivateContent'; import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; +import { downloadFile } from '@app/services/downloadService'; interface FileItem { id: FileId; @@ -79,13 +80,7 @@ const FileThumbnail = ({ // Fallback: attempt to download using the File object if provided const maybeFile = (file as unknown as { file?: File }).file; if (maybeFile instanceof File) { - const link = document.createElement('a'); - link.href = URL.createObjectURL(maybeFile); - link.download = maybeFile.name || file.name || 'download'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(link.href); + void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || 'download' }); return; } diff --git a/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts b/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts index 3a24d9089..72cefce44 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts @@ -295,6 +295,21 @@ export const usePageEditorExport = ({ actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId)); } + if (sourceFileIds.length === 1 && newStirlingFiles.length === 1) { + const sourceStub = selectors.getStirlingFileStub(sourceFileIds[0]); + if (sourceStub?.localFilePath) { + actions.updateStirlingFileStub(newStirlingFiles[0].fileId, { + localFilePath: sourceStub.localFilePath, + isDirty: true + }); + } + } + + // Remove source files from context + if (sourceFileIds.length > 0) { + await actions.removeFiles(sourceFileIds, true); + } + setHasUnsavedChanges(false); setSplitPositions(new Set()); setExportLoading(false); diff --git a/frontend/src/core/components/shared/LandingPage.tsx b/frontend/src/core/components/shared/LandingPage.tsx index 82af8a9e7..a2ad72e2a 100644 --- a/frontend/src/core/components/shared/LandingPage.tsx +++ b/frontend/src/core/components/shared/LandingPage.tsx @@ -14,6 +14,7 @@ import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; import { useAppConfig } from '@app/contexts/AppConfigContext'; import { useIsMobile } from '@app/hooks/useIsMobile'; import MobileUploadModal from '@app/components/shared/MobileUploadModal'; +import { openFilesFromDisk } from '@app/services/openFilesFromDisk'; const LandingPage = () => { const { addFiles } = useFileHandler(); @@ -41,8 +42,14 @@ const LandingPage = () => { openFilesModal(); }; - const handleNativeUploadClick = () => { - fileInputRef.current?.click(); + const handleNativeUploadClick = async () => { + const files = await openFilesFromDisk({ + multiple: true, + onFallbackOpen: () => fileInputRef.current?.click() + }); + if (files.length > 0) { + await addFiles(files); + } }; const handleFileSelect = async (event: React.ChangeEvent) => { diff --git a/frontend/src/core/components/shared/RightRail.tsx b/frontend/src/core/components/shared/RightRail.tsx index 78c78a1b3..7a6f78fed 100644 --- a/frontend/src/core/components/shared/RightRail.tsx +++ b/frontend/src/core/components/shared/RightRail.tsx @@ -3,7 +3,7 @@ import { ActionIcon, Divider } from '@mantine/core'; import '@app/components/shared/rightRail/RightRail.css'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useRightRail } from '@app/contexts/RightRailContext'; -import { useFileState, useFileSelection } from '@app/contexts/FileContext'; +import { useFileState, useFileSelection, useFileActions } from '@app/contexts/FileContext'; import { useNavigationState } from '@app/contexts/NavigationContext'; import { useTranslation } from 'react-i18next'; import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; @@ -22,6 +22,7 @@ import LightModeIcon from '@mui/icons-material/LightMode'; import { useSidebarContext } from '@app/contexts/SidebarContext'; import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail'; import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide'; +import { downloadFile } from '@app/services/downloadService'; const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom']; @@ -59,6 +60,7 @@ export default function RightRail() { const { selectors } = useFileState(); const { selectedFiles, selectedFileIds } = useFileSelection(); + const { actions: fileActions } = useFileActions(); const { signaturesApplied } = useSignature(); const activeFiles = selectors.getFiles(); @@ -141,7 +143,7 @@ export default function RightRail() { alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.'); return; } - viewerContext?.exportActions?.download(); + viewerContext?.exportActions?.download?.(); return; } @@ -150,23 +152,43 @@ export default function RightRail() { return; } - const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles; - filesToDownload.forEach(file => { - const link = document.createElement('a'); - link.href = URL.createObjectURL(file); - link.download = file.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(link.href); - }); + const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles; + const stubsToExport = selectedFiles.length > 0 + ? selectors.getSelectedStirlingFileStubs() + : selectors.getStirlingFileStubs(); + + if (filesToExport.length > 0) { + for (let i = 0; i < filesToExport.length; i++) { + const file = filesToExport[i]; + const stub = stubsToExport[i]; + console.log('[RightRail] Exporting file:', { fileName: file.name, stubId: stub?.id, localFilePath: stub?.localFilePath, isDirty: stub?.isDirty }); + const result = await downloadFile({ + data: file, + filename: file.name, + localPath: stub?.localFilePath + }); + console.log('[RightRail] Export complete, checking dirty state:', { localFilePath: stub?.localFilePath, isDirty: stub?.isDirty, savedPath: result.savedPath }); + // Mark file as clean after successful save to disk + if (stub && result.savedPath) { + console.log('[RightRail] Marking file as clean:', stub.id); + fileActions.updateStirlingFileStub(stub.id, { + localFilePath: stub.localFilePath ?? result.savedPath, + isDirty: false + }); + } else { + console.log('[RightRail] Skipping clean mark:', { savedPath: result.savedPath, isDirty: stub?.isDirty }); + } + } + } }, [ currentView, selectedFiles, activeFiles, pageEditorFunctions, viewerContext, - signaturesApplied + signaturesApplied, + selectors, + fileActions, ]); const downloadTooltip = useMemo(() => { diff --git a/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx b/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx index b737921e6..d6c80cf62 100644 --- a/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx +++ b/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx @@ -15,6 +15,7 @@ import ErrorNotification from '@app/components/tools/shared/ErrorNotification'; import ResultsPreview from '@app/components/tools/shared/ResultsPreview'; import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor'; import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; +import { downloadFromUrl } from '@app/services/downloadService'; export interface EditTableOfContentsWorkbenchViewData { bookmarks: BookmarkNode[]; @@ -177,10 +178,8 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench {downloadUrl && ( diff --git a/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx b/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx index 5ee89db4a..a6be1bcc1 100644 --- a/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx +++ b/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react'; import { Alert, Button, Group, Loader, Stack, Text } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import type { GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation'; +import { downloadFile } from '@app/services/downloadService'; interface GetPdfInfoResultsProps { operation: GetPdfInfoOperationHook; @@ -21,14 +22,7 @@ const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoRes const selectedDownloadLabel = useMemo(() => t('getPdfInfo.downloadJson', 'Download JSON'), [t]); const handleDownload = useCallback((file: File) => { - const blobUrl = URL.createObjectURL(file); - const link = document.createElement('a'); - link.href = blobUrl; - link.download = file.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(blobUrl); + void downloadFile({ data: file, filename: file.name }); }, []); if (isLoading && operation.results.length === 0) { @@ -76,4 +70,3 @@ const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoRes export default GetPdfInfoResults; - diff --git a/frontend/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/src/core/components/tools/shared/ReviewToolStep.tsx index 7c6429168..7b324d5dc 100644 --- a/frontend/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/src/core/components/tools/shared/ReviewToolStep.tsx @@ -9,6 +9,9 @@ import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation"; import { Tooltip } from "@app/components/shared/Tooltip"; import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; +import { saveOperationResults } from "@app/services/operationResultsSaveService"; +import { useFileActions, useFileState } from "@app/contexts/FileContext"; +import { FileId } from "@app/types/fileContext"; export interface ReviewToolStepProps { isVisible: boolean; @@ -34,6 +37,8 @@ function ReviewStepContent({ const icons = useFileActionIcons(); const DownloadIcon = icons.download; const stepRef = useRef(null); + const { actions: fileActions } = useFileActions(); + const { selectors } = useFileState(); const handleUndo = async () => { try { @@ -50,6 +55,31 @@ function ReviewStepContent({ thumbnail: operation.thumbnails[index], })) || []; + const handleDownload = async () => { + if (!operation.downloadUrl) return; + try { + await saveOperationResults({ + downloadUrl: operation.downloadUrl, + downloadFilename: operation.downloadFilename || "download", + downloadLocalPath: operation.downloadLocalPath, + outputFileIds: operation.outputFileIds, + getFile: (fileId) => selectors.getFile(fileId as FileId), + getStub: (fileId) => selectors.getStirlingFileStub(fileId as FileId), + markSaved: (fileId, savedPath) => { + const stub = selectors.getStirlingFileStub(fileId as FileId); + fileActions.updateStirlingFileStub(fileId as FileId, { + localFilePath: stub?.localFilePath ?? savedPath, + isDirty: false + }); + } + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error("[ReviewToolStep] Failed to download file:", message); + alert(`Failed to download file: ${message}`); + } + }; + // Auto-scroll to bottom when content appears useEffect(() => { if (stepRef.current && (previewFiles.length > 0 || operation.downloadUrl || operation.errorMessage)) { @@ -92,13 +122,11 @@ function ReviewStepContent({ )} {operation.downloadUrl && ( diff --git a/frontend/src/core/components/tools/showJS/utils.ts b/frontend/src/core/components/tools/showJS/utils.ts index c5cca2488..0d891ddc5 100644 --- a/frontend/src/core/components/tools/showJS/utils.ts +++ b/frontend/src/core/components/tools/showJS/utils.ts @@ -1,3 +1,5 @@ +import { downloadFromUrl } from "@app/services/downloadService"; + export type ShowJsTokenType = "kw" | "str" | "num" | "com" | "plain"; export type ShowJsToken = { type: ShowJsTokenType; text: string }; @@ -372,11 +374,6 @@ export async function copyTextToClipboard(text: string, fallbackElement?: HTMLEl } } -export function triggerDownload(url: string, filename: string): void { - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); +export async function triggerDownload(url: string, filename: string): Promise { + await downloadFromUrl(url, filename); } diff --git a/frontend/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx b/frontend/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx index ccda07370..acbc1158e 100644 --- a/frontend/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx +++ b/frontend/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx @@ -6,6 +6,7 @@ import type { ValidateSignatureOperationHook } from '@app/hooks/tools/validateSi import '@app/components/tools/validateSignature/reportView/styles.css'; import FitText from '@app/components/shared/FitText'; import { SuggestedToolsSection } from '@app/components/tools/shared/SuggestedToolsSection'; +import { downloadFile } from '@app/services/downloadService'; interface ValidateSignatureResultsProps { operation: ValidateSignatureOperationHook; @@ -80,14 +81,7 @@ const ValidateSignatureResults = ({ ]; const handleDownload = useCallback((file: File) => { - const blobUrl = URL.createObjectURL(file); - const link = document.createElement('a'); - link.href = blobUrl; - link.download = file.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(blobUrl); + void downloadFile({ data: file, filename: file.name }); }, []); // Show the big loader only while we're still waiting for the first results. diff --git a/frontend/src/core/contexts/FileManagerContext.tsx b/frontend/src/core/contexts/FileManagerContext.tsx index 5d6fe3b10..0ae6e4b50 100644 --- a/frontend/src/core/contexts/FileManagerContext.tsx +++ b/frontend/src/core/contexts/FileManagerContext.tsx @@ -5,6 +5,11 @@ import { StirlingFileStub } from '@app/types/fileContext'; import { downloadFiles } from '@app/utils/downloadUtils'; import { FileId } from '@app/types/file'; import { groupFilesByOriginal } from '@app/utils/fileHistoryUtils'; +import { openFilesFromDisk } from '@app/services/openFilesFromDisk'; + +// Module-level storage for file path mappings (quickKey -> localFilePath) +// Used to pass file paths from Tauri file dialog to FileContext +export const pendingFilePathMappings = new Map(); // Type for the context value - now contains everything directly interface FileManagerContextValue { @@ -121,6 +126,7 @@ export const FileManagerProvider: React.FC = ({ const selectedFiles = selectedFileIds.length === 0 ? [] : displayFiles.filter(file => selectedFilesSet.has(file.id)); + const filteredFiles = !searchTerm ? displayFiles : displayFiles.filter(file => file.name.toLowerCase().includes(searchTerm.toLowerCase()) @@ -135,9 +141,23 @@ export const FileManagerProvider: React.FC = ({ } }, []); - const handleLocalFileClick = useCallback(() => { - fileInputRef.current?.click(); - }, []); + const handleLocalFileClick = useCallback(async () => { + console.log('[FileManager] Opening file dialog...'); + + // Try native dialog first (desktop), falls back to empty array (web) + const files = await openFilesFromDisk({ + multiple: true, + onFallbackOpen: () => fileInputRef.current?.click() + }); + + if (files.length > 0) { + console.log('[FileManager] Passing files to FileContext:', files.map(f => f.name)); + onNewFilesSelect(files); + + await refreshRecentFiles(); + onClose(); + } + }, [onNewFilesSelect, refreshRecentFiles, onClose]); const handleFileSelect = useCallback((file: StirlingFileStub, currentIndex: number, shiftKey?: boolean) => { const fileId = file.id; @@ -434,7 +454,6 @@ export const FileManagerProvider: React.FC = ({ } }, [selectedFileIds, handleFileRemoveById]); - const handleDownloadSelected = useCallback(async () => { if (selectedFileIds.length === 0) return; diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index adf05f9e0..e7d03ff9f 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -178,7 +178,7 @@ export function createChildStub( // Copy parent metadata but exclude processedFile to prevent stale data const { processedFile: _processedFile, ...parentMetadata } = parentStub; - return { + const childStub = { // Copy parent metadata (excluding processedFile) ...parentMetadata, @@ -197,8 +197,24 @@ export function createChildStub( thumbnailUrl: thumbnail, // Set fresh processedFile metadata (no inheritance from parent) - processedFile: processedFileMetadata + processedFile: processedFileMetadata, + + // Mark as dirty if parent has a localFilePath (modified file not yet saved to disk) + isDirty: parentStub.localFilePath ? true : undefined }; + + if (DEBUG) { + console.log('[createChildStub] Created child:', { + childId: newFileId, + parentId: parentStub.id, + parentLocalFilePath: parentStub.localFilePath, + childLocalFilePath: childStub.localFilePath, + childIsDirty: childStub.isDirty, + versionNumber: newVersionNumber + }); + } + + return childStub; } interface AddFileOptions { @@ -311,6 +327,25 @@ export async function addFiles( // Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously const fileStub = createNewStirlingFileStub(file, fileId); + // Check for pending file path mapping from Tauri file dialog (desktop only) + try { + const { pendingFilePathMappings } = await import('@app/contexts/FileManagerContext'); + console.log(`[FileActions] Checking for localFilePath mapping for quickKey: ${quickKey}`); + console.log(`[FileActions] Available mappings:`, Array.from(pendingFilePathMappings.keys())); + const localFilePath = pendingFilePathMappings.get(quickKey); + if (localFilePath) { + console.log(`[FileActions] ✓ Found localFilePath: ${localFilePath}`); + fileStub.localFilePath = localFilePath; + pendingFilePathMappings.delete(quickKey); // Clean up after use + console.log(`[FileActions] Applied localFilePath to file: ${file.name}`); + } else { + console.log(`[FileActions] ✗ No localFilePath found for this file`); + } + } catch (error) { + console.log('[FileActions] Could not check for localFilePath:', error); + // FileManagerContext may not be available in all contexts + } + // Store insertion position if provided if (options.insertAfterPageId !== undefined) { fileStub.insertAfterPageId = options.insertAfterPageId; @@ -558,11 +593,20 @@ export async function undoConsumeFiles( indexedDB ); + // Mark restored files as dirty if they have localFilePath + // (they now differ from what's saved on disk) + const stubsWithDirtyMarked = inputStirlingFileStubs.map(stub => { + if (stub.localFilePath) { + return { ...stub, isDirty: true }; + } + return stub; + }); + // Dispatch the undo action (only if everything else succeeded) dispatch({ type: 'UNDO_CONSUME_FILES', payload: { - inputStirlingFileStubs, + inputStirlingFileStubs: stubsWithDirtyMarked, outputFileIds } }); @@ -697,5 +741,6 @@ export const createFileActions = (dispatch: React.Dispatch) = resetContext: () => dispatch({ type: 'RESET_CONTEXT' }), markFileError: (fileId: FileId) => dispatch({ type: 'MARK_FILE_ERROR', payload: { fileId } }), clearFileError: (fileId: FileId) => dispatch({ type: 'CLEAR_FILE_ERROR', payload: { fileId } }), - clearAllFileErrors: () => dispatch({ type: 'CLEAR_ALL_FILE_ERRORS' }) + clearAllFileErrors: () => dispatch({ type: 'CLEAR_ALL_FILE_ERRORS' }), + updateStirlingFileStub: (fileId: FileId, updates: Partial) => dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } }) }); diff --git a/frontend/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/src/core/hooks/tools/shared/useToolOperation.ts index b4881408a..58ee11540 100644 --- a/frontend/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/core/hooks/tools/shared/useToolOperation.ts @@ -11,6 +11,7 @@ import { FILE_EVENTS } from '@app/services/errorUtils'; import { getFilenameWithoutExtension } from '@app/utils/fileUtils'; import { ResponseHandler } from '@app/utils/toolResponseProcessor'; import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions'; +import { createNewStirlingFileStub } from '@app/types/fileContext'; import { ToolOperation } from '@app/types/file'; import { ToolId } from '@app/types/toolId'; import { ensureBackendReady } from '@app/services/backendReadinessGuard'; @@ -140,6 +141,8 @@ export interface ToolOperationHook { isGeneratingThumbnails: boolean; downloadUrl: string | null; downloadFilename: string; + downloadLocalPath?: string | null; + outputFileIds?: string[] | null; isLoading: boolean; status: string; errorMessage: string | null; @@ -375,7 +378,10 @@ export const useToolOperation = ( actions.setGeneratingThumbnails(false); actions.setThumbnails(thumbnails); - actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename); + const downloadLocalPath = + validFiles.length === 1 && processedFiles.length === 1 + ? selectors.getStirlingFileStub(validFiles[0].fileId)?.localFilePath ?? null + : null; // Replace input files with processed files (consumeFiles handles pinning) const inputFileIds: FileId[] = []; @@ -390,6 +396,9 @@ export const useToolOperation = ( inputStirlingFileStubs.push(record); } else { console.warn(`No file stub found for file: ${file.name}`); + const fallbackStub = createNewStirlingFileStub(file, fileId); + inputFileIds.push(fileId); + inputStirlingFileStubs.push(fallbackStub); } } @@ -437,6 +446,18 @@ export const useToolOperation = ( console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length }); const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs); + if (toConsumeInputIds.length === 1 && outputFileIds.length === 1) { + const inputStub = selectors.getStirlingFileStub(toConsumeInputIds[0]); + if (inputStub?.localFilePath) { + fileActions.updateStirlingFileStub(outputFileIds[0], { + localFilePath: inputStub.localFilePath + }); + } + } + + // Pass output file IDs to download info for marking clean after save + actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename, downloadLocalPath, outputFileIds); + // Store operation data for undo (only store what we need to avoid memory bloat) lastOperationRef.current = { inputFiles: extractFiles(validFiles), // Convert to File objects for undo @@ -532,7 +553,6 @@ export const useToolOperation = ( // Undo the consume operation await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds); - // Clear results and operation tracking resetResults(); lastOperationRef.current = null; @@ -565,6 +585,8 @@ export const useToolOperation = ( isGeneratingThumbnails: state.isGeneratingThumbnails, downloadUrl: state.downloadUrl, downloadFilename: state.downloadFilename, + downloadLocalPath: state.downloadLocalPath, + outputFileIds: state.outputFileIds, isLoading: state.isLoading, status: state.status, errorMessage: state.errorMessage, diff --git a/frontend/src/core/hooks/tools/shared/useToolState.ts b/frontend/src/core/hooks/tools/shared/useToolState.ts index 883a1555f..6c7e3b821 100644 --- a/frontend/src/core/hooks/tools/shared/useToolState.ts +++ b/frontend/src/core/hooks/tools/shared/useToolState.ts @@ -12,6 +12,8 @@ export interface OperationState { isGeneratingThumbnails: boolean; downloadUrl: string | null; downloadFilename: string; + downloadLocalPath?: string | null; + outputFileIds?: string[] | null; isLoading: boolean; status: string; errorMessage: string | null; @@ -23,7 +25,7 @@ type OperationAction = | { type: 'SET_FILES'; payload: File[] } | { type: 'SET_THUMBNAILS'; payload: string[] } | { type: 'SET_GENERATING_THUMBNAILS'; payload: boolean } - | { type: 'SET_DOWNLOAD_INFO'; payload: { url: string | null; filename: string } } + | { type: 'SET_DOWNLOAD_INFO'; payload: { url: string | null; filename: string; localPath?: string | null; outputFileIds?: string[] | null } } | { type: 'SET_STATUS'; payload: string } | { type: 'SET_ERROR'; payload: string | null } | { type: 'SET_PROGRESS'; payload: ProcessingProgress | null } @@ -36,6 +38,8 @@ const initialState: OperationState = { isGeneratingThumbnails: false, downloadUrl: null, downloadFilename: '', + downloadLocalPath: null, + outputFileIds: null, isLoading: false, status: '', errorMessage: null, @@ -53,10 +57,12 @@ const operationReducer = (state: OperationState, action: OperationAction): Opera case 'SET_GENERATING_THUMBNAILS': return { ...state, isGeneratingThumbnails: action.payload }; case 'SET_DOWNLOAD_INFO': - return { - ...state, - downloadUrl: action.payload.url, - downloadFilename: action.payload.filename + return { + ...state, + downloadUrl: action.payload.url, + downloadFilename: action.payload.filename, + downloadLocalPath: action.payload.localPath ?? null, + outputFileIds: action.payload.outputFileIds ?? null }; case 'SET_STATUS': return { ...state, status: action.payload }; @@ -97,8 +103,8 @@ export const useToolState = () => { dispatch({ type: 'SET_GENERATING_THUMBNAILS', payload: generating }); }, []); - const setDownloadInfo = useCallback((url: string | null, filename: string) => { - dispatch({ type: 'SET_DOWNLOAD_INFO', payload: { url, filename } }); + const setDownloadInfo = useCallback((url: string | null, filename: string, localPath?: string | null, outputFileIds?: string[] | null) => { + dispatch({ type: 'SET_DOWNLOAD_INFO', payload: { url, filename, localPath, outputFileIds } }); }, []); const setStatus = useCallback((status: string) => { @@ -136,4 +142,4 @@ export const useToolState = () => { clearError, }, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/core/services/downloadService.ts b/frontend/src/core/services/downloadService.ts new file mode 100644 index 000000000..0905402a9 --- /dev/null +++ b/frontend/src/core/services/downloadService.ts @@ -0,0 +1,40 @@ +export interface DownloadRequest { + data: Blob | File; + filename: string; + localPath?: string; +} + +export interface DownloadResult { + savedPath?: string; + cancelled?: boolean; +} + +export async function downloadFile(request: DownloadRequest): Promise { + const url = URL.createObjectURL(request.data); + + const link = document.createElement("a"); + link.href = url; + link.download = request.filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + URL.revokeObjectURL(url); + + return { savedPath: request.localPath }; +} + +export async function downloadFromUrl( + url: string, + filename: string, + localPath?: string +): Promise { + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + return { savedPath: localPath }; +} diff --git a/frontend/src/core/services/fileDialogService.ts b/frontend/src/core/services/fileDialogService.ts new file mode 100644 index 000000000..96cf402c9 --- /dev/null +++ b/frontend/src/core/services/fileDialogService.ts @@ -0,0 +1,28 @@ +// Core stub - no-op implementation for web builds +// Desktop overrides this with actual Tauri implementation + +export interface FileWithPath { + file: File; + path: string; + quickKey: string; +} + +export interface FileDialogOptions { + multiple?: boolean; + filters?: Array<{ + name: string; + extensions: string[]; + }>; +} + +/** + * Open native file dialog and read selected files + * Core stub - returns empty array (no native dialog in web) + * Desktop builds override this with actual Tauri implementation + */ +export async function openFileDialog( + _options?: FileDialogOptions +): Promise { + // Web build: no native file dialog support + return []; +} diff --git a/frontend/src/core/services/localFileSaveService.ts b/frontend/src/core/services/localFileSaveService.ts new file mode 100644 index 000000000..8717c7bf6 --- /dev/null +++ b/frontend/src/core/services/localFileSaveService.ts @@ -0,0 +1,48 @@ +// Core stub - no-op implementation for web builds +// Desktop overrides this with actual Tauri implementation + +export interface SaveResult { + success: boolean; + error?: string; +} + +export interface MultiFileSaveResult { + success: boolean; + savedCount: number; + cancelledByUser?: boolean; + error?: string; +} + +/** + * Save file data to a local filesystem path + * Core stub - always returns failure + * Desktop builds override this with actual implementation + */ +export async function saveToLocalPath( + _data: Blob | File, + _filePath: string +): Promise { + return { success: false, error: "Local file save not available in web mode" }; +} + +/** + * Show native save dialog + * Core stub - always returns null + */ +export async function showSaveDialog( + _defaultFilename: string, + _defaultDirectory?: string +): Promise { + return null; +} + +/** + * Prompt user to select folder and save multiple files + * Core stub - always returns failure + */ +export async function saveMultipleFilesWithPrompt( + _files: (Blob | File)[], + _defaultDirectory?: string +): Promise { + return { success: false, savedCount: 0, error: "Multi-file save not available in web mode" }; +} diff --git a/frontend/src/core/services/openFilesFromDisk.ts b/frontend/src/core/services/openFilesFromDisk.ts new file mode 100644 index 000000000..df1b70946 --- /dev/null +++ b/frontend/src/core/services/openFilesFromDisk.ts @@ -0,0 +1,29 @@ +import { openFileDialog } from '@app/services/fileDialogService'; +import { pendingFilePathMappings } from '@app/contexts/FileManagerContext'; +import { getDocumentFileDialogFilter } from '@app/utils/fileDialogUtils'; + +interface OpenFilesFromDiskOptions { + multiple?: boolean; + filters?: Array<{ + name: string; + extensions: string[]; + }>; + onFallbackOpen?: () => void; +} + +export async function openFilesFromDisk(options: OpenFilesFromDiskOptions = {}): Promise { + const filesWithPaths = await openFileDialog({ + multiple: options.multiple ?? true, + filters: options.filters ?? getDocumentFileDialogFilter() + }); + + if (filesWithPaths.length > 0) { + for (const { quickKey, path } of filesWithPaths) { + pendingFilePathMappings.set(quickKey, path); + } + return filesWithPaths.map((entry) => entry.file); + } + + options.onFallbackOpen?.(); + return []; +} diff --git a/frontend/src/core/services/operationResultsSaveService.ts b/frontend/src/core/services/operationResultsSaveService.ts new file mode 100644 index 000000000..8579da62e --- /dev/null +++ b/frontend/src/core/services/operationResultsSaveService.ts @@ -0,0 +1,30 @@ +import type { FileId, StirlingFileStub } from '@app/types/fileContext'; +import { downloadFromUrl, DownloadResult } from '@app/services/downloadService'; + +export interface OperationSaveContext { + downloadUrl: string | null; + downloadFilename: string; + downloadLocalPath?: string | null; + outputFileIds?: string[] | null; + getFile: (fileId: FileId) => File | undefined; + getStub: (fileId: FileId) => StirlingFileStub | undefined; + markSaved: (fileId: FileId, savedPath?: string) => void; +} + +export async function saveOperationResults(context: OperationSaveContext): Promise { + if (!context.downloadUrl) return null; + + const result = await downloadFromUrl( + context.downloadUrl, + context.downloadFilename || 'download', + context.downloadLocalPath || undefined + ); + + if (context.outputFileIds && result.savedPath) { + for (const fileId of context.outputFileIds) { + context.markSaved(fileId as FileId, result.savedPath); + } + } + + return result; +} diff --git a/frontend/src/core/services/pdfExportService.ts b/frontend/src/core/services/pdfExportService.ts index ebbd4d2b0..74708a177 100644 --- a/frontend/src/core/services/pdfExportService.ts +++ b/frontend/src/core/services/pdfExportService.ts @@ -1,4 +1,5 @@ import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib'; +import { downloadFile } from '@app/services/downloadService'; import { PDFDocument, PDFPage } from '@app/types/pageEditor'; export interface ExportOptions { @@ -181,18 +182,7 @@ export class PDFExportService { * Download a single file */ downloadFile(blob: Blob, filename: string): void { - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - link.style.display = 'none'; - - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // Clean up the URL after a short delay - setTimeout(() => URL.revokeObjectURL(url), 1000); + void downloadFile({ data: blob, filename }); } /** diff --git a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx index 40bc8583d..e4e7ffeaa 100644 --- a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx @@ -192,7 +192,7 @@ describe('Convert Tool Integration Tests', () => { expect(result.current.downloadUrl).toBeTruthy(); expect(result.current.downloadFilename).toBe('test.png'); expect(result.current.isLoading).toBe(false); - expect(result.current.errorMessage).not.toBe(null); + expect(result.current.errorMessage).toBe(null); }); test('should handle API error responses correctly', async () => { @@ -478,7 +478,7 @@ describe('Convert Tool Integration Tests', () => { expect(result.current.downloadUrl).toBeTruthy(); expect(result.current.downloadFilename).toBe('test.csv'); expect(result.current.isLoading).toBe(false); - expect(result.current.errorMessage).not.toBe(null); + expect(result.current.errorMessage).toBe(null); }); test('should handle complete unsupported conversion workflow', async () => { diff --git a/frontend/src/core/tools/EditTableOfContents.tsx b/frontend/src/core/tools/EditTableOfContents.tsx index a86260394..57334c891 100644 --- a/frontend/src/core/tools/EditTableOfContents.tsx +++ b/frontend/src/core/tools/EditTableOfContents.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { downloadFile } from '@app/services/downloadService'; import MenuBookRoundedIcon from '@mui/icons-material/MenuBookRounded'; import { alert } from '@app/components/toast'; import { createToolFlow } from '@app/components/tools/shared/createToolFlow'; @@ -191,14 +192,7 @@ const EditTableOfContents = (props: BaseToolProps) => { 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); + void downloadFile({ data: blob, filename: 'bookmarks.json' }); alert({ title: t('editTableOfContents.messages.exported', 'JSON download ready'), alertType: 'success', diff --git a/frontend/src/core/types/fileContext.ts b/frontend/src/core/types/fileContext.ts index d28ea3a0d..b0a304c35 100644 --- a/frontend/src/core/types/fileContext.ts +++ b/frontend/src/core/types/fileContext.ts @@ -45,9 +45,11 @@ export interface StirlingFileStub extends BaseFileMetadata { quickKey?: string; // Fast deduplication key: name|size|lastModified thumbnailUrl?: string; // Generated thumbnail blob URL for visual display blobUrl?: string; // File access blob URL for downloads/processing + localFilePath?: string; // Original local filesystem path (desktop app only) processedFile?: ProcessedFileMetadata; // PDF page data and processing results insertAfterPageId?: string; // Page ID after which this file should be inserted isPinned?: boolean; // Protected from tool consumption (replace/remove) + isDirty?: boolean; // Has unsaved changes (only for files with localFilePath) // Note: File object stored in provider ref, not in state } diff --git a/frontend/src/core/utils/automationConverter.ts b/frontend/src/core/utils/automationConverter.ts index 74b8e0a79..b35546f33 100644 --- a/frontend/src/core/utils/automationConverter.ts +++ b/frontend/src/core/utils/automationConverter.ts @@ -4,6 +4,7 @@ import { AutomationConfig } from '@app/types/automation'; import { ToolRegistry } from '@app/data/toolsTaxonomy'; +import { downloadFile } from '@app/services/downloadService'; import { ToolId } from '@app/types/toolId'; /** @@ -95,16 +96,5 @@ export function downloadFolderScanningConfig( const config = convertToFolderScanningConfig(automation, toolRegistry); const json = JSON.stringify(config, null, 2); const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - - const a = document.createElement('a'); - a.href = url; - a.download = `${automation.name}.json`; - a.style.display = 'none'; - - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - - URL.revokeObjectURL(url); + void downloadFile({ data: blob, filename: `${automation.name}.json` }); } diff --git a/frontend/src/core/utils/downloadUtils.ts b/frontend/src/core/utils/downloadUtils.ts index 4bf8223fb..f6c1ff347 100644 --- a/frontend/src/core/utils/downloadUtils.ts +++ b/frontend/src/core/utils/downloadUtils.ts @@ -1,6 +1,7 @@ import { StirlingFileStub } from '@app/types/fileContext'; import { fileStorage } from '@app/services/fileStorage'; import { zipFileService } from '@app/services/zipFileService'; +import { downloadFile } from '@app/services/downloadService'; /** * Downloads a blob as a file using browser download API @@ -8,17 +9,7 @@ import { zipFileService } from '@app/services/zipFileService'; * @param filename - The filename for the download */ export function downloadBlob(blob: Blob, filename: string): void { - const url = URL.createObjectURL(blob); - - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // Clean up the blob URL - URL.revokeObjectURL(url); + void downloadFile({ data: blob, filename }); } /** @@ -34,8 +25,11 @@ export async function downloadFileFromStorage(file: StirlingFileStub): Promise + {children} ); diff --git a/frontend/src/desktop/components/SaveShortcutListener.tsx b/frontend/src/desktop/components/SaveShortcutListener.tsx new file mode 100644 index 000000000..64b5d681a --- /dev/null +++ b/frontend/src/desktop/components/SaveShortcutListener.tsx @@ -0,0 +1,14 @@ +import { useSaveShortcut } from '@app/hooks/useSaveShortcut'; +import { useExitWarning } from '@app/hooks/useExitWarning'; + +/** + * Desktop-only component that sets up keyboard shortcuts and exit warnings + * - Ctrl/Cmd+S to save selected files + * - Warning on app exit if unsaved files + * Renders nothing, just sets up the listeners + */ +export function SaveShortcutListener() { + useSaveShortcut(); + useExitWarning(); + return null; +} diff --git a/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx b/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx new file mode 100644 index 000000000..1fe75e085 --- /dev/null +++ b/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Tooltip } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { StirlingFileStub } from '@app/types/fileContext'; +import { PrivateContent } from '@app/components/shared/PrivateContent'; + +interface FileEditorFileNameProps { + file: StirlingFileStub; +} + +const FileEditorFileName = ({ file }: FileEditorFileNameProps) => { + const { t } = useTranslation(); + + return ( + <> + {file.name} + {!file.localFilePath && ( + + + + )} + {file.localFilePath && file.isDirty && ( + + + + )} + {file.localFilePath && !file.isDirty && ( + + + + )} + + ); +}; + +export default FileEditorFileName; diff --git a/frontend/src/desktop/constants/connection.ts b/frontend/src/desktop/constants/connection.ts index 2a7379a2e..863bc5140 100644 --- a/frontend/src/desktop/constants/connection.ts +++ b/frontend/src/desktop/constants/connection.ts @@ -6,9 +6,6 @@ // The SaaS authentication server export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL || ''; -// SaaS signup URL for creating new cloud accounts -export const STIRLING_SAAS_SIGNUP_URL: string = import.meta.env.VITE_SAAS_SIGNUP_URL || ''; - // Supabase publishable key from environment variable // Used for SaaS authentication export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb'; diff --git a/frontend/src/desktop/hooks/useAppInitialization.ts b/frontend/src/desktop/hooks/useAppInitialization.ts index 68b5fc219..7796842ca 100644 --- a/frontend/src/desktop/hooks/useAppInitialization.ts +++ b/frontend/src/desktop/hooks/useAppInitialization.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useOpenedFile } from '@app/hooks/useOpenedFile'; import { fileOpenService } from '@app/services/fileOpenService'; import { useFileManagement } from '@app/contexts/file/fileHooks'; +import { createQuickKey } from '@app/types/fileContext'; /** * App initialization hook @@ -11,10 +12,10 @@ import { useFileManagement } from '@app/contexts/file/fileHooks'; */ export function useAppInitialization(): void { // Get file management actions - const { addFiles } = useFileManagement(); + const { addFiles, updateStirlingFileStub } = useFileManagement(); // Handle files opened with app (Tauri mode) - const { openedFilePaths, loading: openedFileLoading } = useOpenedFile(); + const { openedFilePaths, loading: openedFileLoading, consumeOpenedFilePaths } = useOpenedFile(); // Load opened files and add directly to FileContext useEffect(() => { @@ -23,29 +24,50 @@ export function useAppInitialization(): void { } const loadOpenedFiles = async () => { + const filePaths = consumeOpenedFilePaths(); + if (filePaths.length === 0) { + return; + } try { - const filesArray: File[] = []; + const loadedFiles = ( + await Promise.all( + filePaths.map(async (filePath) => { + try { + const fileData = await fileOpenService.readFileAsArrayBuffer(filePath); + if (!fileData) return null; - await Promise.all( - openedFilePaths.map(async (filePath) => { - try { - const fileData = await fileOpenService.readFileAsArrayBuffer(filePath); - if (fileData) { const file = new File([fileData.arrayBuffer], fileData.fileName, { type: 'application/pdf' }); - filesArray.push(file); - console.log('[Desktop] Loaded file:', fileData.fileName); - } - } catch (error) { - console.error('[Desktop] Failed to load file:', filePath, error); - } - }) - ); - if (filesArray.length > 0) { - await addFiles(filesArray); - console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`); + console.log('[Desktop] Loaded file:', fileData.fileName); + + return { + file, + filePath, + quickKey: createQuickKey(file), + }; + } catch (error) { + console.error('[Desktop] Failed to load file:', filePath, error); + return null; + } + }) + ) + ).filter((entry): entry is { file: File; filePath: string; quickKey: string } => Boolean(entry)); + + if (loadedFiles.length > 0) { + const filesArray = loadedFiles.map(entry => entry.file); + const quickKeyToPath = new Map(loadedFiles.map(entry => [entry.quickKey, entry.filePath])); + + const addedFiles = await addFiles(filesArray); + addedFiles.forEach(file => { + const localFilePath = quickKeyToPath.get(file.quickKey); + if (localFilePath) { + updateStirlingFileStub(file.fileId, { localFilePath }); + } + }); + + console.log(`[Desktop] ${loadedFiles.length} opened file(s) added to FileContext`); } } catch (error) { console.error('[Desktop] Failed to load opened files:', error); @@ -53,7 +75,7 @@ export function useAppInitialization(): void { }; loadOpenedFiles(); - }, [openedFilePaths, openedFileLoading, addFiles]); + }, [openedFilePaths, openedFileLoading, addFiles, updateStirlingFileStub, consumeOpenedFilePaths]); } export function useSetupCompletion(): (completed: boolean) => void { diff --git a/frontend/src/desktop/hooks/useExitWarning.ts b/frontend/src/desktop/hooks/useExitWarning.ts new file mode 100644 index 000000000..4e45a91cc --- /dev/null +++ b/frontend/src/desktop/hooks/useExitWarning.ts @@ -0,0 +1,135 @@ +import { useEffect, useRef } from 'react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { message } from '@tauri-apps/plugin-dialog'; +import { useFileState, useFileActions } from '@app/contexts/FileContext'; +import { downloadFile } from '@app/services/downloadService'; +import type { StirlingFileStub } from '@app/types/fileContext'; +import { useTranslation } from 'react-i18next'; + +export function useExitWarning() { + const { t } = useTranslation(); + const { selectors } = useFileState(); + const { actions: fileActions } = useFileActions(); + const selectorsRef = useRef(selectors); + const isClosingRef = useRef(false); + + selectorsRef.current = selectors; + + useEffect(() => { + const appWindow = getCurrentWindow(); + + const handleCloseRequested = async (event: { preventDefault: () => void }) => { + event.preventDefault(); + + if (isClosingRef.current) { + return; + } + + const allStubs = selectorsRef.current.getStirlingFileStubs(); + const dirtyStubs = allStubs.filter(stub => stub.isDirty); + + if (dirtyStubs.length > 0) { + const fileList = dirtyStubs.map(f => `• ${f.name}`).join('\n'); + const saveLabel = t('confirmCloseSave', 'Save and close'); + const discardLabel = t('confirmCloseDiscard', 'Discard changes and close'); + const cancelLabel = t('confirmCloseCancel', 'Cancel'); + + const choice = await message( + t( + 'confirmCloseUnsavedList', + 'You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}', + { count: dirtyStubs.length, plural: dirtyStubs.length > 1 ? 's' : '', fileList } + ), + { + title: t('confirmCloseUnsaved', 'This file has unsaved changes.'), + kind: 'warning', + buttons: { + yes: saveLabel, + no: discardLabel, + cancel: cancelLabel, + }, + } + ); + + if (choice === cancelLabel) { + return; + } + + if (choice === saveLabel) { + const { failedCount, cancelled } = await saveDirtyFiles(dirtyStubs); + if (cancelled) { + return; + } + if (failedCount > 0) { + await message( + t( + 'confirmCloseSaveFailed', + 'Saved with errors. {{count}} file{{plural}} could not be saved.', + { count: failedCount, plural: failedCount > 1 ? 's' : '' } + ), + { title: t('confirmCloseSaveFailedTitle', 'Save Failed'), kind: 'error' } + ); + return; + } + } else if (choice !== discardLabel) { + return; + } + } + + isClosingRef.current = true; + try { + await appWindow.destroy(); + } catch (error) { + console.error('[exit-warning] destroy failed', error); + isClosingRef.current = false; + } + }; + + const unlisten = appWindow.onCloseRequested(handleCloseRequested); + return () => { + unlisten.then(fn => { + fn(); + }); + }; + }, [fileActions, t]); + + const saveDirtyFiles = async (dirtyStubs: StirlingFileStub[]) => { + const filesById = new Map(selectorsRef.current.getFiles().map(file => [file.fileId, file])); + let failedCount = 0; + let cancelled = false; + + for (const stub of dirtyStubs) { + const file = filesById.get(stub.id); + if (!file || !stub.localFilePath) { + if (!file) { + failedCount += 1; + continue; + } + } + + try { + const result = await downloadFile({ + data: file, + filename: file.name, + localPath: stub.localFilePath, + }); + + if (result.cancelled) { + cancelled = true; + break; + } + + if (result.savedPath) { + fileActions.updateStirlingFileStub(stub.id, { + localFilePath: stub.localFilePath ?? result.savedPath, + isDirty: false + }); + } + } catch { + failedCount += 1; + } + } + + return { failedCount, cancelled }; + }; +} diff --git a/frontend/src/desktop/hooks/useOpenedFile.ts b/frontend/src/desktop/hooks/useOpenedFile.ts index 6347ef736..38ae77cdb 100644 --- a/frontend/src/desktop/hooks/useOpenedFile.ts +++ b/frontend/src/desktop/hooks/useOpenedFile.ts @@ -1,10 +1,23 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { fileOpenService } from '@app/services/fileOpenService'; import { listen } from '@tauri-apps/api/event'; export function useOpenedFile() { const [openedFilePaths, setOpenedFilePaths] = useState([]); const [loading, setLoading] = useState(true); + const openedFilePathsRef = useRef([]); + + const clearOpenedFilePaths = useCallback(() => { + openedFilePathsRef.current = []; + setOpenedFilePaths([]); + }, []); + + const consumeOpenedFilePaths = useCallback(() => { + const current = openedFilePathsRef.current; + openedFilePathsRef.current = []; + setOpenedFilePaths([]); + return current; + }, []); useEffect(() => { // Function to read and process files from storage @@ -16,8 +29,8 @@ export function useOpenedFile() { if (filePaths.length > 0) { console.log(`✅ Found ${filePaths.length} file(s) in storage:`, filePaths); + openedFilePathsRef.current = filePaths; setOpenedFilePaths(filePaths); - await fileOpenService.clearOpenedFiles(); } } catch (error) { console.error('❌ Failed to read files from storage:', error); @@ -44,5 +57,5 @@ export function useOpenedFile() { }; }, []); - return { openedFilePaths, loading }; + return { openedFilePaths, loading, clearOpenedFilePaths, consumeOpenedFilePaths }; } diff --git a/frontend/src/desktop/hooks/useSaveShortcut.ts b/frontend/src/desktop/hooks/useSaveShortcut.ts new file mode 100644 index 000000000..82961138a --- /dev/null +++ b/frontend/src/desktop/hooks/useSaveShortcut.ts @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import { useFileState, useFileActions } from '@app/contexts/FileContext'; +import { downloadFile } from '@app/services/downloadService'; + +/** + * Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files + * Only saves files that have a localFilePath (came from disk) + * Matches Right Rail button behavior: saves selected files if any, otherwise all files + */ +export function useSaveShortcut() { + const { selectors, state } = useFileState(); + const { actions: fileActions } = useFileActions(); + + useEffect(() => { + const handleKeyDown = async (event: KeyboardEvent) => { + // Check for Ctrl+S (Windows/Linux) or Cmd+S (Mac) + if ((event.ctrlKey || event.metaKey) && event.key === 's') { + event.preventDefault(); + + // Get selected files or all files if nothing selected + const selectedFileIds = state.ui.selectedFileIds; + const filesToSave = selectedFileIds.length > 0 + ? selectors.getFiles(selectedFileIds) + : selectors.getFiles(); + const stubsToSave = selectedFileIds.length > 0 + ? selectors.getStirlingFileStubs(selectedFileIds) + : selectors.getStirlingFileStubs(); + + if (filesToSave.length === 0) { + return; + } + + // Save files (Save As for files without localFilePath) + for (let i = 0; i < filesToSave.length; i++) { + const file = filesToSave[i]; + const stub = stubsToSave[i]; + if (!stub) continue; + + try { + const result = await downloadFile({ + data: file, + filename: file.name, + localPath: stub.localFilePath + }); + + // Mark file as clean after successful save + if (result.savedPath) { + fileActions.updateStirlingFileStub(stub.id, { + localFilePath: stub.localFilePath ?? result.savedPath, + isDirty: false + }); + } + } catch (error) { + console.error(`Failed to save ${file.name}:`, error); + } + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [selectors, state.ui.selectedFileIds, fileActions]); +} diff --git a/frontend/src/desktop/services/downloadService.ts b/frontend/src/desktop/services/downloadService.ts new file mode 100644 index 000000000..3981ec0cc --- /dev/null +++ b/frontend/src/desktop/services/downloadService.ts @@ -0,0 +1,39 @@ +import type { DownloadRequest, DownloadResult } from "@core/services/downloadService"; +import { saveToLocalPath, showSaveDialog } from "@app/services/localFileSaveService"; + +export type { DownloadRequest, DownloadResult }; + +export async function downloadFile(request: DownloadRequest): Promise { + if (request.localPath) { + const result = await saveToLocalPath(request.data, request.localPath); + if (!result.success) { + throw new Error(result.error || "Failed to save file"); + } + return { savedPath: request.localPath }; + } + + const savePath = await showSaveDialog(request.filename); + if (!savePath) { + return { cancelled: true }; + } + + const result = await saveToLocalPath(request.data, savePath); + if (!result.success) { + throw new Error(result.error || "Failed to save file"); + } + + return { savedPath: savePath }; +} + +export async function downloadFromUrl( + url: string, + filename: string, + localPath?: string +): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Download failed (${response.status})`); + } + const blob = await response.blob(); + return downloadFile({ data: blob, filename, localPath }); +} diff --git a/frontend/src/desktop/services/fileDialogService.ts b/frontend/src/desktop/services/fileDialogService.ts new file mode 100644 index 000000000..d7da45b14 --- /dev/null +++ b/frontend/src/desktop/services/fileDialogService.ts @@ -0,0 +1,60 @@ +// Desktop implementation - Tauri native file dialogs +import type { FileWithPath, FileDialogOptions } from '@core/services/fileDialogService'; +import { createQuickKey } from '@app/types/fileContext'; +import { getDocumentFileDialogFilter } from '@app/utils/fileDialogUtils'; + +export type { FileWithPath, FileDialogOptions }; + +/** + * Open native file dialog and read selected files (Desktop/Tauri only) + */ +export async function openFileDialog( + options?: FileDialogOptions +): Promise { + try { + const { open } = await import('@tauri-apps/plugin-dialog'); + const { readFile } = await import('@tauri-apps/plugin-fs'); + + console.log('[FileDialog] Opening file dialog...'); + const selectedPaths = await open({ + multiple: options?.multiple ?? true, + filters: options?.filters ?? getDocumentFileDialogFilter() + }); + + if (!selectedPaths) { + console.log('[FileDialog] User cancelled'); + return []; + } + + const paths = Array.isArray(selectedPaths) ? selectedPaths : [selectedPaths]; + console.log(`[FileDialog] Selected ${paths.length} file(s):`, paths); + + const filesWithPaths: FileWithPath[] = []; + + for (const filePath of paths) { + try { + console.log(`[FileDialog] Reading file: ${filePath}`); + const fileData = await readFile(filePath); + const fileName = filePath.split(/[/\\]/).pop() || 'document'; + const file = new File([fileData], fileName, { + type: fileName.endsWith('.pdf') ? 'application/pdf' : undefined + }); + const quickKey = createQuickKey(file); + console.log(`[FileDialog] Created File: ${fileName}, quickKey: ${quickKey}`); + + filesWithPaths.push({ + file, + path: filePath, + quickKey + }); + } catch (error) { + console.error(`[FileDialog] Failed to read ${filePath}:`, error); + } + } + + return filesWithPaths; + } catch (error) { + console.error('[FileDialog] Error:', error); + return []; + } +} diff --git a/frontend/src/desktop/services/fileOpenService.ts b/frontend/src/desktop/services/fileOpenService.ts index 67813df38..059191c80 100644 --- a/frontend/src/desktop/services/fileOpenService.ts +++ b/frontend/src/desktop/services/fileOpenService.ts @@ -10,9 +10,9 @@ export interface FileOpenService { class TauriFileOpenService implements FileOpenService { async getOpenedFiles(): Promise { try { - console.log('🔍 Calling invoke(get_opened_files)...'); - const result = await invoke('get_opened_files'); - console.log('🔍 invoke(get_opened_files) returned:', result); + console.log('🔍 Calling invoke(pop_opened_files)...'); + const result = await invoke('pop_opened_files'); + console.log('🔍 invoke(pop_opened_files) returned:', result); return result; } catch (error) { console.error('❌ Failed to get opened files:', error); diff --git a/frontend/src/desktop/services/localFileSaveService.ts b/frontend/src/desktop/services/localFileSaveService.ts new file mode 100644 index 000000000..473892760 --- /dev/null +++ b/frontend/src/desktop/services/localFileSaveService.ts @@ -0,0 +1,123 @@ +import type { SaveResult, MultiFileSaveResult } from "@core/services/localFileSaveService"; +export type { SaveResult, MultiFileSaveResult }; + +/** + * Save file data to a local filesystem path (Tauri desktop only) + * + * @param data - Blob or File to save + * @param filePath - Absolute path to save to + * @returns Result indicating success or failure with error message + */ +export async function saveToLocalPath( + data: Blob | File, + filePath: string +): Promise { + try { + const { writeFile } = await import("@tauri-apps/plugin-fs"); + const arrayBuffer = await data.arrayBuffer(); + await writeFile(filePath, new Uint8Array(arrayBuffer)); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('[LocalFileSave] Failed to save:', message); + return { success: false, error: message }; + } +} + +/** + * Show native save dialog and return selected path + * + * @param defaultFilename - Suggested filename + * @param defaultDirectory - Optional default directory + * @returns Selected file path or null if cancelled + */ +export async function showSaveDialog( + defaultFilename: string, + defaultDirectory?: string +): Promise { + try { + const { save } = await import("@tauri-apps/plugin-dialog"); + + const selectedPath = await save({ + defaultPath: defaultDirectory ? `${defaultDirectory}/${defaultFilename}` : defaultFilename, + filters: [{ + name: 'PDF', + extensions: ['pdf'] + }], + title: 'Save As' + }); + + return selectedPath; + } catch (error) { + console.error('[SaveDialog] Failed to show dialog:', error); + return null; + } +} + +/** + * Prompt user to select a folder and save multiple files there + * + * @param files - Array of files to save + * @param defaultDirectory - Optional default directory to open dialog in + * @returns Result with count of files saved + */ +export async function saveMultipleFilesWithPrompt( + files: (Blob | File)[], + defaultDirectory?: string +): Promise { + try { + const { open } = await import("@tauri-apps/plugin-dialog"); + const { writeFile } = await import("@tauri-apps/plugin-fs"); + const { join } = await import("@tauri-apps/api/path"); + + // Prompt user to select folder + const selectedFolder = await open({ + directory: true, + multiple: false, + defaultPath: defaultDirectory, + title: `Save ${files.length} file${files.length > 1 ? 's' : ''}` + }); + + // User cancelled + if (!selectedFolder) { + return { success: false, savedCount: 0, cancelledByUser: true }; + } + + // Save each file to the selected folder + let savedCount = 0; + const errors: string[] = []; + + for (const file of files) { + try { + const fileName = file instanceof File ? file.name : `output_${savedCount + 1}.pdf`; + const filePath = await join(selectedFolder as string, fileName); + const arrayBuffer = await file.arrayBuffer(); + await writeFile(filePath, new Uint8Array(arrayBuffer)); + savedCount++; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`${file instanceof File ? file.name : 'file'}: ${message}`); + } + } + + if (savedCount === files.length) { + return { success: true, savedCount }; + } else if (savedCount > 0) { + return { + success: false, + savedCount, + error: `Saved ${savedCount}/${files.length} files. Errors: ${errors.join(', ')}` + }; + } else { + return { + success: false, + savedCount: 0, + error: `Failed to save files: ${errors.join(', ')}` + }; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('[LocalFileSave] Failed to save multiple files:', message); + return { success: false, savedCount: 0, error: message }; + } +} diff --git a/frontend/src/desktop/services/operationResultsSaveService.ts b/frontend/src/desktop/services/operationResultsSaveService.ts new file mode 100644 index 000000000..b3e10c3b8 --- /dev/null +++ b/frontend/src/desktop/services/operationResultsSaveService.ts @@ -0,0 +1,42 @@ +import type { FileId } from '@app/types/fileContext'; +import type { OperationSaveContext } from '@core/services/operationResultsSaveService'; +import { downloadFile, downloadFromUrl, DownloadResult } from '@app/services/downloadService'; + +export type { OperationSaveContext }; + +export async function saveOperationResults(context: OperationSaveContext): Promise { + if (!context.downloadUrl) return null; + + if (context.outputFileIds && context.outputFileIds.length > 0) { + for (const fileId of context.outputFileIds) { + const file = context.getFile(fileId as FileId); + const stub = context.getStub(fileId as FileId); + if (!file) continue; + + const result = await downloadFile({ + data: file, + filename: file.name, + localPath: stub?.localFilePath + }); + + if (result.savedPath) { + context.markSaved(fileId as FileId, result.savedPath); + } + } + return null; + } + + const result = await downloadFromUrl( + context.downloadUrl, + context.downloadFilename || 'download', + context.downloadLocalPath || undefined + ); + + if (context.outputFileIds && result.savedPath) { + for (const fileId of context.outputFileIds) { + context.markSaved(fileId as FileId, result.savedPath); + } + } + + return result; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index de3dc20fc..1e39ddf9e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -11,6 +11,22 @@ export default defineConfig(({ mode }) => { process.env.STIRLING_DESKTOP === 'true' || process.env.VITE_DESKTOP === 'true'; + // Validate required environment variables for desktop builds + if (isDesktopMode) { + const requiredEnvVars = [ + 'VITE_SAAS_SERVER_URL', + 'VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY', + ]; + + const missingVars = requiredEnvVars.filter(varName => !process.env[varName]); + + if (missingVars.length > 0) { + throw new Error( + `Desktop build failed: Missing required environment variables:\n${missingVars.map(v => ` - ${v}`).join('\n')}\n\nPlease set these variables before building the desktop app.` + ); + } + } + const baseProject = isProprietary ? './tsconfig.proprietary.vite.json' : './tsconfig.core.vite.json'; const desktopProject = isProprietary ? './tsconfig.desktop.vite.json' : baseProject; const tsconfigProject = isDesktopMode ? desktopProject : baseProject; diff --git a/testing/test_pdf_1.pdf b/testing/test_pdf_1.pdf index 3d63420a29b7ac7507459b9f6fbb0035683f2971..f625c114696ff68d8b3c9b824d6732662c8c0070 100644 GIT binary patch delta 918 zcmdnabCH8jHNeG9*HF)lOZD56Kldl|GRe7f=?5gHrxq)iD;OvQap{Lt7NjcZJ13ST z=H#by+1YWW=B4B(WpP;mr9rff4VS)8YF>IthJvAqIhVd$W==_JkwWz3Q_Rvlu^}KAvBVnZiM>|i17N_{; z)2DApX!ox)WxT;VEnlK!T|?~bJ7TwNn5!>5J5}*Fc1L)0i^uV4U)A1d?^^Rhq}^fe ziLctF)j6jh+5h<@|3|pq>mm#P;lt;jMP!(6d%xjX$cxFG%@bSP6S$vpxTl$H;Mu!2 zwr}4@)k~eu$q#e(9bw4cHe;Cy|9#J2Z^LXK|Bz5WUg5oFNn@(niUxy6EoG-%jz!pM z#0q>^@nsdC{xwH$$?R{@(Z7Qegxj?;vX@O=G%{eo*x=7KR)T{7Op?%Z&&!S zux)B-;xaRVr)R$$)7;)}_NHuiUv|~fTO~!im%5klxE~o_k#R9KuX}&nh3nl`?z1-W z+?u+jvrOibnziTh@>_2niGF5Sd+P^R@!usI7ku(+7fLofvSg)2XsCy_U+Bw^*@2~x zjdovF$((CaX8-TYN%tr0{}e7cf}*}2E$$5suth$XzNd>qw2iZqv8juhiHW0;nVW&L zrIV|%sinE2xtoQdg{70Bk+GcuVI{FZv+~mN70e)sc`^?hPdy|>M+Bv&ap?zVR;4N! z0#lo3UP@|(LbQQ`p#e}&xI(m{f{}tDkO@uCC}|FnsDUXhIU(W4`6CBTG92k~IKy(} zOb^c=1v_qm%|=En#s)@KMhp!4?TkMV312oBmhy{B5{pVIih%C0Ff!#*Rdw}u;{pJ% Cw_nEq literal 1591 zcmb7E-B#l^5We?QOt&XXST@MNNlaqvwvw3RNJf?y=tWJA~Zb-EGT-ZAPQ*Sj-IzOc2qHvAb)Kjs-S~10Wq69KADIErany8|agjVL%Jk z5eQ4VEc?t<8dD(qJQhjHr$EkAzQ>F7S#eC~3_&d*!!lA2ISM2EqpTM^L9DeEmG@NKvRa!RFUw()2;V1+1U z%nDdAsmamQBEqQ@@sKG5Ao@wjh}|P`y*Zt-V7a!JYY#4w1|S2W@CnhKj3ng?NUb+u zMc64iuMy^<>54FYbR-gF^<%+~1;>q66{|!a8E;cXGcjGmsIOt{m1-s=R4EtyVJ^yI z6G&S!s#3v2omn;9u}#}Eo0f0-u4lSirnO<38!J2Q{9Kdz+J|W*seFJMysANT>a&MC zA(H~CC}8&A3WA&_Wz7Cx6ipx2-wlfuR7+muEKVmWOEBapRT>VsF^)^_pJDuzhqd zWJvH*K%_&)r)mb4+t5IT!dI9Z2UMP$CXHh8*6oIIn#`;7-g{>wy!fYnF#Fo7ps16y5xz@K!Mg)F|%**5ml3!US<_VsM6;um(F{~E99 zV=uq3^IiX0rzA}?CKpq&7lJX~jU&fP>aI)e`oxU=h&i6$pp8k>YWk67JFRb9TpE?N zK|THFL0zjsl484JiT>Nn-ZTu-;}Ze8Wd{aM3!y-d0NfAL-x+egC>1Kz;t?#XSvM+` I{gaOIH=2dV2><{9