mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Merge branch 'V2' into add_eslint_plugins_20250928
This commit is contained in:
commit
c6f54a2a4c
@ -122,11 +122,11 @@ Stirling-PDF currently supports 40 languages!
|
|||||||
| Catalan (Català) (ca_CA) |  |
|
| Catalan (Català) (ca_CA) |  |
|
||||||
| Croatian (Hrvatski) (hr_HR) |  |
|
| Croatian (Hrvatski) (hr_HR) |  |
|
||||||
| Czech (Česky) (cs_CZ) |  |
|
| Czech (Česky) (cs_CZ) |  |
|
||||||
| Danish (Dansk) (da_DK) |  |
|
| Danish (Dansk) (da_DK) |  |
|
||||||
| Dutch (Nederlands) (nl_NL) |  |
|
| Dutch (Nederlands) (nl_NL) |  |
|
||||||
| English (English) (en_GB) |  |
|
| English (English) (en_GB) |  |
|
||||||
| English (US) (en_US) |  |
|
| English (US) (en_US) |  |
|
||||||
| French (Français) (fr_FR) |  |
|
| French (Français) (fr_FR) |  |
|
||||||
| German (Deutsch) (de_DE) |  |
|
| German (Deutsch) (de_DE) |  |
|
||||||
| Greek (Ελληνικά) (el_GR) |  |
|
| Greek (Ελληνικά) (el_GR) |  |
|
||||||
| Hindi (हिंदी) (hi_IN) |  |
|
| Hindi (हिंदी) (hi_IN) |  |
|
||||||
@ -136,14 +136,14 @@ Stirling-PDF currently supports 40 languages!
|
|||||||
| Italian (Italiano) (it_IT) |  |
|
| Italian (Italiano) (it_IT) |  |
|
||||||
| Japanese (日本語) (ja_JP) |  |
|
| Japanese (日本語) (ja_JP) |  |
|
||||||
| Korean (한국어) (ko_KR) |  |
|
| Korean (한국어) (ko_KR) |  |
|
||||||
| Norwegian (Norsk) (no_NB) |  |
|
| Norwegian (Norsk) (no_NB) |  |
|
||||||
| Persian (فارسی) (fa_IR) |  |
|
| Persian (فارسی) (fa_IR) |  |
|
||||||
| Polish (Polski) (pl_PL) |  |
|
| Polish (Polski) (pl_PL) |  |
|
||||||
| Portuguese (Português) (pt_PT) |  |
|
| Portuguese (Português) (pt_PT) |  |
|
||||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||||
| Romanian (Română) (ro_RO) |  |
|
| Romanian (Română) (ro_RO) |  |
|
||||||
| Russian (Русский) (ru_RU) |  |
|
| Russian (Русский) (ru_RU) |  |
|
||||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||||
| Slovakian (Slovensky) (sk_SK) |  |
|
| Slovakian (Slovensky) (sk_SK) |  |
|
||||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||||
|
|||||||
@ -50,6 +50,14 @@ const nodeGlobs = [
|
|||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('./', import.meta.url));
|
const __dirname = fileURLToPath(new URL('./', import.meta.url));
|
||||||
|
|
||||||
|
const srcGlobs = [
|
||||||
|
'src/**/*.{js,mjs,jsx,ts,tsx}',
|
||||||
|
];
|
||||||
|
const nodeGlobs = [
|
||||||
|
'scripts/**/*.{js,ts,mjs}',
|
||||||
|
'*.config.{js,ts,mjs}',
|
||||||
|
];
|
||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
{ ignores: ignorePatterns },
|
{ ignores: ignorePatterns },
|
||||||
|
|
||||||
@ -224,5 +232,5 @@ export default defineConfig(
|
|||||||
'react/jsx-uses-react': 'error',
|
'react/jsx-uses-react': 'error',
|
||||||
'react/jsx-uses-vars': 'error',
|
'react/jsx-uses-vars': 'error',
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
@ -41,6 +41,7 @@
|
|||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
|
"globals": "^16.4.0",
|
||||||
"i18next": "^25.5.2",
|
"i18next": "^25.5.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
|||||||
@ -37,6 +37,7 @@
|
|||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
|
"globals": "^16.4.0",
|
||||||
"i18next": "^25.5.2",
|
"i18next": "^25.5.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"unsavedChanges": "You have unsaved changes to your PDF. What would you like to do?",
|
"unsavedChanges": "You have unsaved changes to your PDF.",
|
||||||
|
"areYouSure": "Are you sure you want to leave?",
|
||||||
"unsavedChangesTitle": "Unsaved Changes",
|
"unsavedChangesTitle": "Unsaved Changes",
|
||||||
"keepWorking": "Keep Working",
|
"keepWorking": "Keep Working",
|
||||||
"discardChanges": "Discard Changes",
|
"discardChanges": "Discard & Leave",
|
||||||
"applyAndContinue": "Apply & Continue",
|
"applyAndContinue": "Save & Leave",
|
||||||
"exportAndContinue": "Export & Continue",
|
"exportAndContinue": "Export & Continue",
|
||||||
"language": {
|
"language": {
|
||||||
"direction": "ltr"
|
"direction": "ltr"
|
||||||
@ -256,6 +257,16 @@
|
|||||||
"name": "Save form inputs",
|
"name": "Save form inputs",
|
||||||
"help": "Enable to store previously used inputs for future runs"
|
"help": "Enable to store previously used inputs for future runs"
|
||||||
},
|
},
|
||||||
|
"general": {
|
||||||
|
"title": "General",
|
||||||
|
"description": "Configure general application preferences.",
|
||||||
|
"autoUnzip": "Auto-unzip API responses",
|
||||||
|
"autoUnzipDescription": "Automatically extract files from ZIP responses",
|
||||||
|
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
|
||||||
|
"autoUnzipFileLimit": "Auto-unzip file limit",
|
||||||
|
"autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP",
|
||||||
|
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs."
|
||||||
|
},
|
||||||
"hotkeys": {
|
"hotkeys": {
|
||||||
"title": "Keyboard Shortcuts",
|
"title": "Keyboard Shortcuts",
|
||||||
"description": "Hover a tool to see its shortcut or customise it below. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel.",
|
"description": "Hover a tool to see its shortcut or customise it below. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel.",
|
||||||
@ -3186,6 +3197,7 @@
|
|||||||
"lastModified": "Last Modified",
|
"lastModified": "Last Modified",
|
||||||
"toolChain": "Tools Applied",
|
"toolChain": "Tools Applied",
|
||||||
"restore": "Restore",
|
"restore": "Restore",
|
||||||
|
"unzip": "Unzip",
|
||||||
"searchFiles": "Search files...",
|
"searchFiles": "Search files...",
|
||||||
"recent": "Recent",
|
"recent": "Recent",
|
||||||
"localFiles": "Local Files",
|
"localFiles": "Local Files",
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { FilesModalProvider } from "./contexts/FilesModalContext";
|
|||||||
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
|
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
|
||||||
import { HotkeyProvider } from "./contexts/HotkeyContext";
|
import { HotkeyProvider } from "./contexts/HotkeyContext";
|
||||||
import { SidebarProvider } from "./contexts/SidebarContext";
|
import { SidebarProvider } from "./contexts/SidebarContext";
|
||||||
|
import { PreferencesProvider } from "./contexts/PreferencesContext";
|
||||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||||
import HomePage from "./pages/HomePage";
|
import HomePage from "./pages/HomePage";
|
||||||
|
|
||||||
@ -41,6 +42,7 @@ export default function App() {
|
|||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<RainbowThemeProvider>
|
<RainbowThemeProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<PreferencesProvider>
|
||||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<FilesModalProvider>
|
<FilesModalProvider>
|
||||||
@ -60,6 +62,7 @@ export default function App() {
|
|||||||
</FilesModalProvider>
|
</FilesModalProvider>
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
</FileContextProvider>
|
</FileContextProvider>
|
||||||
|
</PreferencesProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</RainbowThemeProvider>
|
</RainbowThemeProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import {
|
|||||||
Text, Center, Box, LoadingOverlay, Stack, Group
|
Text, Center, Box, LoadingOverlay, Stack, Group
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
|
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
|
||||||
import { useNavigationActions } from '../../contexts/NavigationContext';
|
import { useNavigationActions } from '../../contexts/NavigationContext';
|
||||||
import { zipFileService } from '../../services/zipFileService';
|
import { zipFileService } from '../../services/zipFileService';
|
||||||
import { detectFileExtension } from '../../utils/fileUtils';
|
import { detectFileExtension } from '../../utils/fileUtils';
|
||||||
@ -37,6 +37,7 @@ const FileEditor = ({
|
|||||||
// Use optimized FileContext hooks
|
// Use optimized FileContext hooks
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||||
|
const { actions } = useFileActions();
|
||||||
|
|
||||||
// Extract needed values from state (memoized to prevent infinite loops)
|
// Extract needed values from state (memoized to prevent infinite loops)
|
||||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||||
@ -309,6 +310,48 @@ const FileEditor = ({
|
|||||||
}
|
}
|
||||||
}, [activeStirlingFileStubs, selectors, _setStatus]);
|
}, [activeStirlingFileStubs, selectors, _setStatus]);
|
||||||
|
|
||||||
|
const handleUnzipFile = useCallback(async (fileId: FileId) => {
|
||||||
|
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||||
|
const file = record ? selectors.getFile(record.id) : null;
|
||||||
|
if (record && file) {
|
||||||
|
try {
|
||||||
|
// Extract and store files using shared service method
|
||||||
|
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
|
||||||
|
|
||||||
|
if (result.success && result.extractedStubs.length > 0) {
|
||||||
|
// Add extracted file stubs to FileContext
|
||||||
|
await actions.addStirlingFileStubs(result.extractedStubs);
|
||||||
|
|
||||||
|
// Remove the original ZIP file
|
||||||
|
removeFiles([fileId], false);
|
||||||
|
|
||||||
|
alert({
|
||||||
|
alertType: 'success',
|
||||||
|
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 3500
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert({
|
||||||
|
alertType: 'error',
|
||||||
|
title: `Failed to extract files from ${file.name}`,
|
||||||
|
body: result.errors.join('\n'),
|
||||||
|
expandable: true,
|
||||||
|
durationMs: 3500
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unzip file:', error);
|
||||||
|
alert({
|
||||||
|
alertType: 'error',
|
||||||
|
title: `Error unzipping ${file.name}`,
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 3500
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeStirlingFileStubs, selectors, actions, removeFiles]);
|
||||||
|
|
||||||
const handleViewFile = useCallback((fileId: FileId) => {
|
const handleViewFile = useCallback((fileId: FileId) => {
|
||||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||||
if (record) {
|
if (record) {
|
||||||
@ -348,7 +391,7 @@ const FileEditor = ({
|
|||||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
<Box pos="relative" style={{ overflow: 'auto' }}>
|
||||||
<LoadingOverlay visible={false} />
|
<LoadingOverlay visible={false} />
|
||||||
|
|
||||||
<Box p="md" pt="xl">
|
<Box p="md">
|
||||||
|
|
||||||
|
|
||||||
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||||
@ -429,6 +472,7 @@ const FileEditor = ({
|
|||||||
_onSetStatus={showStatus}
|
_onSetStatus={showStatus}
|
||||||
onReorderFiles={handleReorderFiles}
|
onReorderFiles={handleReorderFiles}
|
||||||
onDownloadFile={handleDownloadFile}
|
onDownloadFile={handleDownloadFile}
|
||||||
|
onUnzipFile={handleUnzipFile}
|
||||||
toolMode={toolMode}
|
toolMode={toolMode}
|
||||||
isSupported={isFileSupported(record.name)}
|
isSupported={isFileSupported(record.name)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,11 +5,13 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
|
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||||
import { StirlingFileStub } from '../../types/fileContext';
|
import { StirlingFileStub } from '../../types/fileContext';
|
||||||
|
import { zipFileService } from '../../services/zipFileService';
|
||||||
|
|
||||||
import styles from './FileEditor.module.css';
|
import styles from './FileEditor.module.css';
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
import { useFileContext } from '../../contexts/FileContext';
|
||||||
@ -32,6 +34,7 @@ interface FileEditorThumbnailProps {
|
|||||||
_onSetStatus: (status: string) => void;
|
_onSetStatus: (status: string) => void;
|
||||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||||
onDownloadFile: (fileId: FileId) => void;
|
onDownloadFile: (fileId: FileId) => void;
|
||||||
|
onUnzipFile?: (fileId: FileId) => void;
|
||||||
toolMode?: boolean;
|
toolMode?: boolean;
|
||||||
isSupported?: boolean;
|
isSupported?: boolean;
|
||||||
}
|
}
|
||||||
@ -45,6 +48,7 @@ const FileEditorThumbnail = ({
|
|||||||
_onSetStatus,
|
_onSetStatus,
|
||||||
onReorderFiles,
|
onReorderFiles,
|
||||||
onDownloadFile,
|
onDownloadFile,
|
||||||
|
onUnzipFile,
|
||||||
isSupported = true,
|
isSupported = true,
|
||||||
}: FileEditorThumbnailProps) => {
|
}: FileEditorThumbnailProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -64,6 +68,9 @@ const FileEditorThumbnail = ({
|
|||||||
}, [activeFiles, file.id]);
|
}, [activeFiles, file.id]);
|
||||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||||
|
|
||||||
|
// Check if this is a ZIP file
|
||||||
|
const isZipFile = zipFileService.isZipFileStub(file);
|
||||||
|
|
||||||
const pageCount = file.processedFile?.totalPages || 0;
|
const pageCount = file.processedFile?.totalPages || 0;
|
||||||
|
|
||||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||||
@ -299,6 +306,16 @@ const FileEditorThumbnail = ({
|
|||||||
<span>{t('download', 'Download')}</span>
|
<span>{t('download', 'Download')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{isZipFile && onUnzipFile && (
|
||||||
|
<button
|
||||||
|
className={styles.actionRow}
|
||||||
|
onClick={() => { onUnzipFile(file.id); alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
|
||||||
|
>
|
||||||
|
<UnarchiveIcon fontSize="small" />
|
||||||
|
<span>{t('fileManager.unzip', 'Unzip')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.actionsDivider} />
|
<div className={styles.actionsDivider} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -5,10 +5,12 @@ import DeleteIcon from '@mui/icons-material/Delete';
|
|||||||
import DownloadIcon from '@mui/icons-material/Download';
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
import HistoryIcon from '@mui/icons-material/History';
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
import RestoreIcon from '@mui/icons-material/Restore';
|
import RestoreIcon from '@mui/icons-material/Restore';
|
||||||
|
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||||
import { FileId, StirlingFileStub } from '../../types/fileContext';
|
import { FileId, StirlingFileStub } from '../../types/fileContext';
|
||||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
|
import { zipFileService } from '../../services/zipFileService';
|
||||||
import ToolChain from '../shared/ToolChain';
|
import ToolChain from '../shared/ToolChain';
|
||||||
|
|
||||||
interface FileListItemProps {
|
interface FileListItemProps {
|
||||||
@ -38,7 +40,10 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
|
const {expandedFileIds, onToggleExpansion, onAddToRecents, onUnzipFile } = useFileManagerContext();
|
||||||
|
|
||||||
|
// Check if this is a ZIP file
|
||||||
|
const isZipFile = zipFileService.isZipFileStub(file);
|
||||||
|
|
||||||
// Keep item in hovered state if menu is open
|
// Keep item in hovered state if menu is open
|
||||||
const shouldShowHovered = isHovered || isMenuOpen;
|
const shouldShowHovered = isHovered || isMenuOpen;
|
||||||
@ -192,6 +197,22 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Unzip option for ZIP files */}
|
||||||
|
{isZipFile && !isHistoryFile && (
|
||||||
|
<>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<UnarchiveIcon style={{ fontSize: 16 }} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUnzipFile(file);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('fileManager.unzip', 'Unzip')}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@ -146,10 +146,12 @@ export default function Workbench() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Top Controls */}
|
{/* Top Controls */}
|
||||||
|
{activeFiles.length > 0 && (
|
||||||
<TopControls
|
<TopControls
|
||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
setCurrentView={setCurrentView}
|
setCurrentView={setCurrentView}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Dismiss All Errors Button */}
|
{/* Dismiss All Errors Button */}
|
||||||
<DismissAllErrorsButton />
|
<DismissAllErrorsButton />
|
||||||
@ -159,6 +161,7 @@ export default function Workbench() {
|
|||||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||||
style={{
|
style={{
|
||||||
transition: 'opacity 0.15s ease-in-out',
|
transition: 'opacity 0.15s ease-in-out',
|
||||||
|
paddingTop: activeFiles.length > 0 ? '3.5rem' : '0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderMainContent()}
|
{renderMainContent()}
|
||||||
|
|||||||
@ -662,7 +662,7 @@ const PageEditor = ({
|
|||||||
const displayedPages = displayDocument?.pages || [];
|
const displayedPages = displayDocument?.pages || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pos="relative" h='100%' pt={40} style={{ overflow: 'auto' }} data-scrolling-container="true">
|
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||||
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
||||||
|
|
||||||
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
|
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
|
||||||
|
|||||||
@ -1,25 +1,19 @@
|
|||||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
|
||||||
import { useNavigationGuard } from '../../contexts/NavigationContext';
|
import { useNavigationGuard } from "../../contexts/NavigationContext";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||||
|
|
||||||
interface NavigationWarningModalProps {
|
interface NavigationWarningModalProps {
|
||||||
onApplyAndContinue?: () => Promise<void>;
|
onApplyAndContinue?: () => Promise<void>;
|
||||||
onExportAndContinue?: () => Promise<void>;
|
onExportAndContinue?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavigationWarningModal = ({
|
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
|
||||||
onApplyAndContinue,
|
|
||||||
onExportAndContinue
|
|
||||||
}: NavigationWarningModalProps) => {
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
|
||||||
showNavigationWarning,
|
useNavigationGuard();
|
||||||
hasUnsavedChanges,
|
|
||||||
cancelNavigation,
|
|
||||||
confirmNavigation,
|
|
||||||
setHasUnsavedChanges
|
|
||||||
} = useNavigationGuard();
|
|
||||||
|
|
||||||
const handleKeepWorking = () => {
|
const handleKeepWorking = () => {
|
||||||
cancelNavigation();
|
cancelNavigation();
|
||||||
@ -38,13 +32,14 @@ const NavigationWarningModal = ({
|
|||||||
confirmNavigation();
|
confirmNavigation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportAndContinue = async () => {
|
const _handleExportAndContinue = async () => {
|
||||||
if (onExportAndContinue) {
|
if (onExportAndContinue) {
|
||||||
await onExportAndContinue();
|
await onExportAndContinue();
|
||||||
}
|
}
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
confirmNavigation();
|
confirmNavigation();
|
||||||
};
|
};
|
||||||
|
const BUTTON_WIDTH = "10rem";
|
||||||
|
|
||||||
if (!hasUnsavedChanges) {
|
if (!hasUnsavedChanges) {
|
||||||
return null;
|
return null;
|
||||||
@ -56,55 +51,53 @@ const NavigationWarningModal = ({
|
|||||||
onClose={handleKeepWorking}
|
onClose={handleKeepWorking}
|
||||||
title={t("unsavedChangesTitle", "Unsaved Changes")}
|
title={t("unsavedChangesTitle", "Unsaved Changes")}
|
||||||
centered
|
centered
|
||||||
size="xl"
|
size="auto"
|
||||||
closeOnClickOutside={false}
|
closeOnClickOutside={true}
|
||||||
closeOnEscape={false}
|
closeOnEscape={true}
|
||||||
>
|
>
|
||||||
<Stack gap="xl">
|
<Stack>
|
||||||
<Text size="md">
|
<Stack ta="center" p="md">
|
||||||
{t("unsavedChanges", "You have unsaved changes to your PDF. What would you like to do?")}
|
<Text size="md" fw="300">
|
||||||
|
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text size="lg" fw="500" >
|
||||||
|
{t("areYouSure", "Are you sure you want to leave?")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Group justify="space-between" gap="xl" mt="xl">
|
{/* Desktop layout: 2 groups side by side */}
|
||||||
<Group gap="xl">
|
<Group justify="space-between" gap="xl" visibleFrom="md">
|
||||||
<Button
|
<Group gap="sm">
|
||||||
variant="light"
|
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
|
||||||
color="red"
|
|
||||||
onClick={handleDiscardChanges}
|
|
||||||
>
|
|
||||||
{t("discardChanges", "Discard Changes")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="var(--mantine-color-gray-8)"
|
|
||||||
onClick={handleKeepWorking}
|
|
||||||
>
|
|
||||||
{t("keepWorking", "Keep Working")}
|
{t("keepWorking", "Keep Working")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group gap="sm">
|
||||||
<Group gap="xl">
|
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
|
||||||
{onExportAndContinue && (
|
{t("discardChanges", "Discard Changes")}
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
onClick={handleExportAndContinue}
|
|
||||||
>
|
|
||||||
{t("exportAndContinue", "Export & Continue")}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
|
|
||||||
{onApplyAndContinue && (
|
{onApplyAndContinue && (
|
||||||
<Button
|
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||||
variant="light"
|
{t("applyAndContinue", "Apply & Leave")}
|
||||||
color="blue"
|
|
||||||
onClick={handleApplyAndContinue}
|
|
||||||
>
|
|
||||||
{t("applyAndContinue", "Apply & Continue")}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* Mobile layout: centered stack of 4 buttons */}
|
||||||
|
<Stack align="center" gap="sm" hiddenFrom="md">
|
||||||
|
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
|
||||||
|
{t("keepWorking", "Keep Working")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
|
||||||
|
{t("discardChanges", "Discard Changes")}
|
||||||
|
</Button>
|
||||||
|
{onApplyAndContinue && (
|
||||||
|
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||||
|
{t("applyAndContinue", "Apply & Leave")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavKey } from './types';
|
import { NavKey } from './types';
|
||||||
import HotkeysSection from './configSections/HotkeysSection';
|
import HotkeysSection from './configSections/HotkeysSection';
|
||||||
|
import GeneralSection from './configSections/GeneralSection';
|
||||||
|
|
||||||
export interface ConfigNavItem {
|
export interface ConfigNavItem {
|
||||||
key: NavKey;
|
key: NavKey;
|
||||||
@ -43,6 +44,12 @@ export const createConfigNavSections = (
|
|||||||
{
|
{
|
||||||
title: 'Preferences',
|
title: 'Preferences',
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
key: 'general',
|
||||||
|
label: 'General',
|
||||||
|
icon: 'settings-rounded',
|
||||||
|
component: <GeneralSection />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'hotkeys',
|
key: 'hotkeys',
|
||||||
label: 'Keyboard Shortcuts',
|
label: 'Keyboard Shortcuts',
|
||||||
|
|||||||
@ -0,0 +1,89 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Paper, Stack, Switch, Text, Tooltip, NumberInput } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePreferences } from '../../../../contexts/PreferencesContext';
|
||||||
|
|
||||||
|
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
|
||||||
|
|
||||||
|
const GeneralSection: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { preferences, updatePreference } = usePreferences();
|
||||||
|
const [fileLimitInput, setFileLimitInput] = useState<number | string>(preferences.autoUnzipFileLimit);
|
||||||
|
|
||||||
|
// Sync local state with preference changes
|
||||||
|
useEffect(() => {
|
||||||
|
setFileLimitInput(preferences.autoUnzipFileLimit);
|
||||||
|
}, [preferences.autoUnzipFileLimit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('settings.general.description', 'Configure general application preferences.')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Tooltip
|
||||||
|
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
|
||||||
|
multiline
|
||||||
|
w={300}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
|
||||||
|
<div>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.autoUnzip}
|
||||||
|
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
|
||||||
|
multiline
|
||||||
|
w={300}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
|
||||||
|
<div>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<NumberInput
|
||||||
|
value={fileLimitInput}
|
||||||
|
onChange={setFileLimitInput}
|
||||||
|
onBlur={() => {
|
||||||
|
const numValue = Number(fileLimitInput);
|
||||||
|
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
|
||||||
|
setFileLimitInput(finalValue);
|
||||||
|
updatePreference('autoUnzipFileLimit', finalValue);
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
disabled={!preferences.autoUnzip}
|
||||||
|
style={{ width: 90 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GeneralSection;
|
||||||
@ -274,7 +274,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
|||||||
<GlobalPointerProvider>
|
<GlobalPointerProvider>
|
||||||
<Viewport
|
<Viewport
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--bg-surface)',
|
backgroundColor: 'var(--bg-background)',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
@ -299,7 +299,8 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
|||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
MozUserSelect: 'none',
|
MozUserSelect: 'none',
|
||||||
msUserSelect: 'none'
|
msUserSelect: 'none',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)'
|
||||||
}}
|
}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onDragStart={(e) => e.preventDefault()}
|
onDragStart={(e) => e.preventDefault()}
|
||||||
|
|||||||
@ -12,30 +12,42 @@ export function ZoomAPIBridge() {
|
|||||||
|
|
||||||
// Set initial zoom once when plugin is ready
|
// Set initial zoom once when plugin is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (zoom && !hasSetInitialZoom.current) {
|
if (!zoom || hasSetInitialZoom.current) {
|
||||||
hasSetInitialZoom.current = true;
|
return;
|
||||||
setTimeout(() => {
|
}
|
||||||
|
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const attemptInitialZoom = () => {
|
||||||
try {
|
try {
|
||||||
zoom.requestZoom(1.4);
|
zoom.requestZoom(1.4);
|
||||||
|
hasSetInitialZoom.current = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Zoom initialization delayed, viewport not ready:', error);
|
console.log('Zoom initialization delayed, viewport not ready:', error);
|
||||||
// Retry after a longer delay
|
retryTimer = setTimeout(() => {
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
try {
|
||||||
zoom.requestZoom(1.4);
|
zoom.requestZoom(1.4);
|
||||||
|
hasSetInitialZoom.current = true;
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
console.log('Zoom initialization failed:', retryError);
|
console.log('Zoom initialization failed:', retryError);
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
}, 50);
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(attemptInitialZoom, 50);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer);
|
||||||
}
|
}
|
||||||
}, [zoom]);
|
};
|
||||||
|
}, [zoom, zoomState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (zoom && zoomState) {
|
if (zoom && zoomState) {
|
||||||
// Update local state
|
// Update local state
|
||||||
const currentZoomLevel = zoomState.currentZoomLevel || 1.4;
|
const currentZoomLevel = zoomState.currentZoomLevel ?? 1.4;
|
||||||
const newState = {
|
const newState = {
|
||||||
currentZoom: currentZoomLevel,
|
currentZoom: currentZoomLevel,
|
||||||
zoomPercent: Math.round(currentZoomLevel * 100),
|
zoomPercent: Math.round(currentZoomLevel * 100),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { fileStorage } from '../services/fileStorage';
|
import { fileStorage } from '../services/fileStorage';
|
||||||
|
import { zipFileService } from '../services/zipFileService';
|
||||||
import { StirlingFileStub } from '../types/fileContext';
|
import { StirlingFileStub } from '../types/fileContext';
|
||||||
import { downloadFiles } from '../utils/downloadUtils';
|
import { downloadFiles } from '../utils/downloadUtils';
|
||||||
import { FileId } from '../types/file';
|
import { FileId } from '../types/file';
|
||||||
@ -36,6 +37,7 @@ interface FileManagerContextValue {
|
|||||||
onDownloadSingle: (file: StirlingFileStub) => void;
|
onDownloadSingle: (file: StirlingFileStub) => void;
|
||||||
onToggleExpansion: (fileId: FileId) => void;
|
onToggleExpansion: (fileId: FileId) => void;
|
||||||
onAddToRecents: (file: StirlingFileStub) => void;
|
onAddToRecents: (file: StirlingFileStub) => void;
|
||||||
|
onUnzipFile: (file: StirlingFileStub) => Promise<void>;
|
||||||
onNewFilesSelect: (files: File[]) => void;
|
onNewFilesSelect: (files: File[]) => void;
|
||||||
|
|
||||||
// External props
|
// External props
|
||||||
@ -544,6 +546,30 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
}
|
}
|
||||||
}, [refreshRecentFiles]);
|
}, [refreshRecentFiles]);
|
||||||
|
|
||||||
|
const handleUnzipFile = useCallback(async (file: StirlingFileStub) => {
|
||||||
|
try {
|
||||||
|
// Load the full file from storage
|
||||||
|
const stirlingFile = await fileStorage.getStirlingFile(file.id);
|
||||||
|
if (!stirlingFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and store files using shared service method
|
||||||
|
const result = await zipFileService.extractAndStoreFilesWithHistory(stirlingFile, file);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Refresh file manager to show new files
|
||||||
|
await refreshRecentFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
console.error('Errors during unzip:', result.errors);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unzip file:', error);
|
||||||
|
}
|
||||||
|
}, [refreshRecentFiles]);
|
||||||
|
|
||||||
// Cleanup blob URLs when component unmounts
|
// Cleanup blob URLs when component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -595,6 +621,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
onDownloadSingle: handleDownloadSingle,
|
onDownloadSingle: handleDownloadSingle,
|
||||||
onToggleExpansion: handleToggleExpansion,
|
onToggleExpansion: handleToggleExpansion,
|
||||||
onAddToRecents: handleAddToRecents,
|
onAddToRecents: handleAddToRecents,
|
||||||
|
onUnzipFile: handleUnzipFile,
|
||||||
onNewFilesSelect,
|
onNewFilesSelect,
|
||||||
|
|
||||||
// External props
|
// External props
|
||||||
@ -627,6 +654,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
handleDownloadSelected,
|
handleDownloadSelected,
|
||||||
handleToggleExpansion,
|
handleToggleExpansion,
|
||||||
handleAddToRecents,
|
handleAddToRecents,
|
||||||
|
handleUnzipFile,
|
||||||
onNewFilesSelect,
|
onNewFilesSelect,
|
||||||
recentFiles,
|
recentFiles,
|
||||||
isFileSupported,
|
isFileSupported,
|
||||||
|
|||||||
73
frontend/src/contexts/PreferencesContext.tsx
Normal file
73
frontend/src/contexts/PreferencesContext.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { preferencesService, UserPreferences, DEFAULT_PREFERENCES } from '../services/preferencesService';
|
||||||
|
|
||||||
|
interface PreferencesContextValue {
|
||||||
|
preferences: UserPreferences;
|
||||||
|
updatePreference: <K extends keyof UserPreferences>(
|
||||||
|
key: K,
|
||||||
|
value: UserPreferences[K]
|
||||||
|
) => Promise<void>;
|
||||||
|
resetPreferences: () => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PreferencesContext = createContext<PreferencesContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [preferences, setPreferences] = useState<UserPreferences>(DEFAULT_PREFERENCES);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPreferences = async () => {
|
||||||
|
try {
|
||||||
|
await preferencesService.initialize();
|
||||||
|
const loadedPreferences = await preferencesService.getAllPreferences();
|
||||||
|
setPreferences(loadedPreferences);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load preferences:', error);
|
||||||
|
// Keep default preferences on error
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPreferences();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updatePreference = useCallback(
|
||||||
|
async <K extends keyof UserPreferences>(key: K, value: UserPreferences[K]) => {
|
||||||
|
await preferencesService.setPreference(key, value);
|
||||||
|
setPreferences((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetPreferences = useCallback(async () => {
|
||||||
|
await preferencesService.clearAllPreferences();
|
||||||
|
setPreferences(DEFAULT_PREFERENCES);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreferencesContext.Provider
|
||||||
|
value={{
|
||||||
|
preferences,
|
||||||
|
updatePreference,
|
||||||
|
resetPreferences,
|
||||||
|
isLoading,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PreferencesContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePreferences = (): PreferencesContextValue => {
|
||||||
|
const context = useContext(PreferencesContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('usePreferences must be used within a PreferencesProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@ -1,8 +1,9 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
import { ScannerImageSplitParameters, defaultParameters } from './useScannerImageSplitParameters';
|
import { ScannerImageSplitParameters, defaultParameters } from './useScannerImageSplitParameters';
|
||||||
import { zipFileService } from '../../../services/zipFileService';
|
import { useToolResources } from '../shared/useToolResources';
|
||||||
|
|
||||||
export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitParameters, file: File): FormData => {
|
export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitParameters, file: File): FormData => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@ -15,40 +16,46 @@ export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitPara
|
|||||||
return formData;
|
return formData;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom response handler to handle ZIP files that might be misidentified
|
// Static configuration object
|
||||||
const scannerImageSplitResponseHandler = async (responseData: Blob, inputFiles: File[]): Promise<File[]> => {
|
export const scannerImageSplitOperationConfig = {
|
||||||
|
toolType: ToolType.singleFile,
|
||||||
|
buildFormData: buildScannerImageSplitFormData,
|
||||||
|
operationType: 'scannerImageSplit',
|
||||||
|
endpoint: '/api/v1/misc/extract-image-scans',
|
||||||
|
defaultParameters,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const useScannerImageSplitOperation = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { extractAllZipFiles } = useToolResources();
|
||||||
|
|
||||||
|
// Custom response handler that extracts ZIP files containing images
|
||||||
|
// Can't add to exported config because it requires access to the hook so must be part of the hook
|
||||||
|
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
|
||||||
try {
|
try {
|
||||||
// Always try to extract as ZIP first, regardless of content-type
|
// Scanner image split returns ZIP files with multiple images
|
||||||
const extractionResult = await zipFileService.extractAllFiles(responseData);
|
const extractedFiles = await extractAllZipFiles(blob);
|
||||||
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
|
|
||||||
return extractionResult.extractedFiles;
|
// If extraction succeeded and returned files, use them
|
||||||
|
if (extractedFiles.length > 0) {
|
||||||
|
return extractedFiles;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to extract as ZIP, treating as single file:', error);
|
console.warn('Failed to extract as ZIP, treating as single file:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: treat as single file (PNG image)
|
// Fallback: treat as single file (PNG image)
|
||||||
const inputFileName = inputFiles[0]?.name || 'document';
|
const inputFileName = originalFiles[0]?.name || 'document';
|
||||||
const baseFileName = inputFileName.replace(/\.[^.]+$/, '');
|
const baseFileName = inputFileName.replace(/\.[^.]+$/, '');
|
||||||
const singleFile = new File([responseData], `${baseFileName}.png`, { type: 'image/png' });
|
const singleFile = new File([blob], `${baseFileName}.png`, { type: 'image/png' });
|
||||||
return [singleFile];
|
return [singleFile];
|
||||||
};
|
}, [extractAllZipFiles]);
|
||||||
|
|
||||||
export const scannerImageSplitOperationConfig = {
|
const config: ToolOperationConfig<ScannerImageSplitParameters> = {
|
||||||
toolType: ToolType.singleFile,
|
|
||||||
buildFormData: buildScannerImageSplitFormData,
|
|
||||||
operationType: 'scannerImageSplit',
|
|
||||||
endpoint: '/api/v1/misc/extract-image-scans',
|
|
||||||
multiFileEndpoint: false,
|
|
||||||
responseHandler: scannerImageSplitResponseHandler,
|
|
||||||
defaultParameters,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const useScannerImageSplitOperation = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useToolOperation<ScannerImageSplitParameters>({
|
|
||||||
...scannerImageSplitOperationConfig,
|
...scannerImageSplitOperationConfig,
|
||||||
|
responseHandler,
|
||||||
getErrorMessage: createStandardErrorHandler(t('scannerImageSplit.error.failed', 'An error occurred while extracting image scans.'))
|
getErrorMessage: createStandardErrorHandler(t('scannerImageSplit.error.failed', 'An error occurred while extracting image scans.'))
|
||||||
});
|
};
|
||||||
|
|
||||||
|
return useToolOperation(config);
|
||||||
};
|
};
|
||||||
@ -257,6 +257,7 @@ export const useToolOperation = <TParams>(
|
|||||||
processedFiles = [singleFile];
|
processedFiles = [singleFile];
|
||||||
} else {
|
} else {
|
||||||
// Default: assume ZIP response for multi-file endpoints
|
// Default: assume ZIP response for multi-file endpoints
|
||||||
|
// Note: extractZipFiles will check preferences.autoUnzip setting
|
||||||
processedFiles = await extractZipFiles(response.data);
|
processedFiles = await extractZipFiles(response.data);
|
||||||
|
|
||||||
if (processedFiles.length === 0) {
|
if (processedFiles.length === 0) {
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { generateThumbnailForFile, generateThumbnailWithMetadata, ThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
|
import { generateThumbnailForFile, generateThumbnailWithMetadata, ThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
|
||||||
import { zipFileService } from '../../../services/zipFileService';
|
import { zipFileService } from '../../../services/zipFileService';
|
||||||
|
import { usePreferences } from '../../../contexts/PreferencesContext';
|
||||||
|
|
||||||
|
|
||||||
export const useToolResources = () => {
|
export const useToolResources = () => {
|
||||||
|
const { preferences } = usePreferences();
|
||||||
const [blobUrls, setBlobUrls] = useState<string[]>([]);
|
const [blobUrls, setBlobUrls] = useState<string[]>([]);
|
||||||
|
|
||||||
const addBlobUrl = useCallback((url: string) => {
|
const addBlobUrl = useCallback((url: string) => {
|
||||||
@ -81,8 +83,20 @@ export const useToolResources = () => {
|
|||||||
return results;
|
return results;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const extractZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
|
const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise<File[]> => {
|
||||||
try {
|
try {
|
||||||
|
// Check if we should extract based on preferences
|
||||||
|
const shouldExtract = await zipFileService.shouldUnzip(
|
||||||
|
zipBlob,
|
||||||
|
preferences.autoUnzip,
|
||||||
|
preferences.autoUnzipFileLimit,
|
||||||
|
skipAutoUnzip
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldExtract) {
|
||||||
|
return [new File([zipBlob], 'result.zip', { type: 'application/zip' })];
|
||||||
|
}
|
||||||
|
|
||||||
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
|
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
|
||||||
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
|
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
|
||||||
return extractionResult.success ? extractionResult.extractedFiles : [];
|
return extractionResult.success ? extractionResult.extractedFiles : [];
|
||||||
@ -90,32 +104,30 @@ export const useToolResources = () => {
|
|||||||
console.error('useToolResources.extractZipFiles - Error:', error);
|
console.error('useToolResources.extractZipFiles - Error:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, []);
|
}, [preferences.autoUnzip, preferences.autoUnzipFileLimit]);
|
||||||
|
|
||||||
const extractAllZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
|
const extractAllZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise<File[]> => {
|
||||||
try {
|
try {
|
||||||
const JSZip = (await import('jszip')).default;
|
// Check if we should extract based on preferences
|
||||||
const zip = new JSZip();
|
const shouldExtract = await zipFileService.shouldUnzip(
|
||||||
|
zipBlob,
|
||||||
|
preferences.autoUnzip,
|
||||||
|
preferences.autoUnzipFileLimit,
|
||||||
|
skipAutoUnzip
|
||||||
|
);
|
||||||
|
|
||||||
const arrayBuffer = await zipBlob.arrayBuffer();
|
if (!shouldExtract) {
|
||||||
const zipContent = await zip.loadAsync(arrayBuffer);
|
return [new File([zipBlob], 'result.zip', { type: 'application/zip' })];
|
||||||
|
|
||||||
const extractedFiles: File[] = [];
|
|
||||||
|
|
||||||
for (const [filename, file] of Object.entries(zipContent.files)) {
|
|
||||||
if (!file.dir) {
|
|
||||||
const content = await file.async('blob');
|
|
||||||
const extractedFile = new File([content], filename, { type: 'application/pdf' });
|
|
||||||
extractedFiles.push(extractedFile);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return extractedFiles;
|
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
|
||||||
|
const extractionResult = await zipFileService.extractAllFiles(zipFile);
|
||||||
|
return extractionResult.success ? extractionResult.extractedFiles : [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in extractAllZipFiles:', error);
|
console.error('useToolResources.extractAllZipFiles - Error:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, []);
|
}, [preferences.autoUnzip, preferences.autoUnzipFileLimit]);
|
||||||
|
|
||||||
const createDownloadInfo = useCallback(async (
|
const createDownloadInfo = useCallback(async (
|
||||||
files: File[],
|
files: File[],
|
||||||
|
|||||||
@ -314,6 +314,15 @@ export const DATABASE_CONFIGS = {
|
|||||||
}]
|
}]
|
||||||
} as DatabaseConfig,
|
} as DatabaseConfig,
|
||||||
|
|
||||||
|
PREFERENCES: {
|
||||||
|
name: 'stirling-pdf-preferences',
|
||||||
|
version: 1,
|
||||||
|
stores: [{
|
||||||
|
name: 'preferences',
|
||||||
|
keyPath: 'key'
|
||||||
|
}]
|
||||||
|
} as DatabaseConfig,
|
||||||
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const indexedDBManager = IndexedDBManager.getInstance();
|
export const indexedDBManager = IndexedDBManager.getInstance();
|
||||||
|
|||||||
129
frontend/src/services/preferencesService.ts
Normal file
129
frontend/src/services/preferencesService.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
|
||||||
|
|
||||||
|
export interface UserPreferences {
|
||||||
|
autoUnzip: boolean;
|
||||||
|
autoUnzipFileLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||||
|
autoUnzip: true,
|
||||||
|
autoUnzipFileLimit: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
class PreferencesService {
|
||||||
|
private db: IDBDatabase | null = null;
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
this.db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.PREFERENCES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureDatabase(): IDBDatabase {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new Error('PreferencesService not initialized. Call initialize() first.');
|
||||||
|
}
|
||||||
|
return this.db;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPreference<K extends keyof UserPreferences>(
|
||||||
|
key: K
|
||||||
|
): Promise<UserPreferences[K]> {
|
||||||
|
const db = this.ensureDatabase();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const transaction = db.transaction(['preferences'], 'readonly');
|
||||||
|
const store = transaction.objectStore('preferences');
|
||||||
|
const request = store.get(key);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const result = request.result;
|
||||||
|
if (result && result.value !== undefined) {
|
||||||
|
resolve(result.value);
|
||||||
|
} else {
|
||||||
|
// Return default value if preference not found
|
||||||
|
resolve(DEFAULT_PREFERENCES[key]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error('Error reading preference:', key, request.error);
|
||||||
|
// Return default value on error
|
||||||
|
resolve(DEFAULT_PREFERENCES[key]);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPreference<K extends keyof UserPreferences>(
|
||||||
|
key: K,
|
||||||
|
value: UserPreferences[K]
|
||||||
|
): Promise<void> {
|
||||||
|
const db = this.ensureDatabase();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['preferences'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('preferences');
|
||||||
|
const request = store.put({ key, value });
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error('Error writing preference:', key, request.error);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPreferences(): Promise<UserPreferences> {
|
||||||
|
const db = this.ensureDatabase();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const transaction = db.transaction(['preferences'], 'readonly');
|
||||||
|
const store = transaction.objectStore('preferences');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const storedPrefs: Partial<UserPreferences> = {};
|
||||||
|
const results = request.result;
|
||||||
|
|
||||||
|
for (const item of results) {
|
||||||
|
if (item.key && item.value !== undefined) {
|
||||||
|
storedPrefs[item.key as keyof UserPreferences] = item.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with defaults to ensure all preferences exist
|
||||||
|
resolve({
|
||||||
|
...DEFAULT_PREFERENCES,
|
||||||
|
...storedPrefs,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error('Error reading all preferences:', request.error);
|
||||||
|
// Return defaults on error
|
||||||
|
resolve({ ...DEFAULT_PREFERENCES });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAllPreferences(): Promise<void> {
|
||||||
|
const db = this.ensureDatabase();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['preferences'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('preferences');
|
||||||
|
const request = store.clear();
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const preferencesService = new PreferencesService();
|
||||||
@ -1,4 +1,7 @@
|
|||||||
import JSZip, { JSZipObject } from 'jszip';
|
import JSZip, { JSZipObject } from 'jszip';
|
||||||
|
import { StirlingFileStub, createStirlingFile } from '../types/fileContext';
|
||||||
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||||
|
import { fileStorage } from './fileStorage';
|
||||||
|
|
||||||
// Undocumented interface in JSZip for JSZipObject._data
|
// Undocumented interface in JSZip for JSZipObject._data
|
||||||
interface CompressedObject {
|
interface CompressedObject {
|
||||||
@ -41,6 +44,15 @@ export class ZipFileService {
|
|||||||
private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit
|
private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit
|
||||||
private readonly supportedExtensions = ['.pdf'];
|
private readonly supportedExtensions = ['.pdf'];
|
||||||
|
|
||||||
|
// ZIP file validation constants
|
||||||
|
private static readonly VALID_ZIP_TYPES = [
|
||||||
|
'application/zip',
|
||||||
|
'application/x-zip-compressed',
|
||||||
|
'application/x-zip',
|
||||||
|
'application/octet-stream' // Some browsers use this for ZIP files
|
||||||
|
];
|
||||||
|
private static readonly VALID_ZIP_EXTENSIONS = ['.zip'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a ZIP file without extracting it
|
* Validate a ZIP file without extracting it
|
||||||
*/
|
*/
|
||||||
@ -238,23 +250,27 @@ export class ZipFileService {
|
|||||||
/**
|
/**
|
||||||
* Check if a file is a ZIP file based on type and extension
|
* Check if a file is a ZIP file based on type and extension
|
||||||
*/
|
*/
|
||||||
private isZipFile(file: File): boolean {
|
public isZipFile(file: File): boolean {
|
||||||
const validTypes = [
|
const hasValidType = ZipFileService.VALID_ZIP_TYPES.includes(file.type);
|
||||||
'application/zip',
|
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some(ext =>
|
||||||
'application/x-zip-compressed',
|
|
||||||
'application/x-zip',
|
|
||||||
'application/octet-stream' // Some browsers use this for ZIP files
|
|
||||||
];
|
|
||||||
|
|
||||||
const validExtensions = ['.zip'];
|
|
||||||
const hasValidType = validTypes.includes(file.type);
|
|
||||||
const hasValidExtension = validExtensions.some(ext =>
|
|
||||||
file.name.toLowerCase().endsWith(ext)
|
file.name.toLowerCase().endsWith(ext)
|
||||||
);
|
);
|
||||||
|
|
||||||
return hasValidType || hasValidExtension;
|
return hasValidType || hasValidExtension;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a StirlingFileStub represents a ZIP file (for UI checks without loading full file)
|
||||||
|
*/
|
||||||
|
public isZipFileStub(stub: StirlingFileStub): boolean {
|
||||||
|
const hasValidType = stub.type && ZipFileService.VALID_ZIP_TYPES.includes(stub.type);
|
||||||
|
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some(ext =>
|
||||||
|
stub.name.toLowerCase().endsWith(ext)
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasValidType || hasValidExtension;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a filename indicates a PDF file
|
* Check if a filename indicates a PDF file
|
||||||
*/
|
*/
|
||||||
@ -309,33 +325,44 @@ export class ZipFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get file extension from filename
|
* Determine if a ZIP file should be extracted based on user preferences
|
||||||
|
*
|
||||||
|
* @param zipBlob - The ZIP file to check
|
||||||
|
* @param autoUnzip - User preference for auto-unzipping
|
||||||
|
* @param autoUnzipFileLimit - Maximum number of files to auto-extract
|
||||||
|
* @param skipAutoUnzip - Bypass preference check (for automation)
|
||||||
|
* @returns true if the ZIP should be extracted, false otherwise
|
||||||
*/
|
*/
|
||||||
private getFileExtension(filename: string): string {
|
async shouldUnzip(
|
||||||
return filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
zipBlob: Blob | File,
|
||||||
}
|
autoUnzip: boolean,
|
||||||
|
autoUnzipFileLimit: number,
|
||||||
/**
|
skipAutoUnzip: boolean = false
|
||||||
* Check if ZIP file contains password protection
|
): Promise<boolean> {
|
||||||
*/
|
|
||||||
private async isPasswordProtected(file: File): Promise<boolean> {
|
|
||||||
try {
|
try {
|
||||||
|
// Automation always extracts
|
||||||
|
if (skipAutoUnzip) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if auto-unzip is enabled
|
||||||
|
if (!autoUnzip) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load ZIP and count files
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
await zip.loadAsync(file);
|
const zipContents = await zip.loadAsync(zipBlob);
|
||||||
|
|
||||||
// Check if any files are encrypted
|
// Count non-directory entries
|
||||||
for (const [_filename, zipEntry] of Object.entries(zip.files)) {
|
const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length;
|
||||||
if (zipEntry.options?.compression === 'STORE' && getData(zipEntry)?.compressedSize === 0) {
|
|
||||||
// This might indicate encryption, but JSZip doesn't provide direct encryption detection
|
|
||||||
// We'll handle this in the extraction phase
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false; // JSZip will throw an error if password is required
|
// Only extract if within limit
|
||||||
|
return fileCount <= autoUnzipFileLimit;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If we can't load the ZIP, it might be password protected
|
console.error('Error checking shouldUnzip:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : '';
|
// On error, default to not extracting (safer)
|
||||||
return errorMessage.includes('password') || errorMessage.includes('encrypted');
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -457,6 +484,79 @@ export class ZipFileService {
|
|||||||
|
|
||||||
return mimeTypes[ext || ''] || 'application/octet-stream';
|
return mimeTypes[ext || ''] || 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract PDF files from ZIP and store them in IndexedDB with preserved history metadata
|
||||||
|
* Used by both FileManager and FileEditor to avoid code duplication
|
||||||
|
*
|
||||||
|
* @param zipFile - The ZIP file to extract from
|
||||||
|
* @param zipStub - The StirlingFileStub for the ZIP (contains metadata to preserve)
|
||||||
|
* @returns Object with success status, extracted stubs, and any errors
|
||||||
|
*/
|
||||||
|
async extractAndStoreFilesWithHistory(
|
||||||
|
zipFile: File,
|
||||||
|
zipStub: StirlingFileStub
|
||||||
|
): Promise<{ success: boolean; extractedStubs: StirlingFileStub[]; errors: string[] }> {
|
||||||
|
const result = {
|
||||||
|
success: false,
|
||||||
|
extractedStubs: [] as StirlingFileStub[],
|
||||||
|
errors: [] as string[]
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract PDF files from ZIP
|
||||||
|
const extractionResult = await this.extractPdfFiles(zipFile);
|
||||||
|
|
||||||
|
if (!extractionResult.success || extractionResult.extractedFiles.length === 0) {
|
||||||
|
result.errors = extractionResult.errors;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each extracted file
|
||||||
|
for (const extractedFile of extractionResult.extractedFiles) {
|
||||||
|
try {
|
||||||
|
// Generate thumbnail
|
||||||
|
const thumbnail = await generateThumbnailForFile(extractedFile);
|
||||||
|
|
||||||
|
// Create StirlingFile
|
||||||
|
const newStirlingFile = createStirlingFile(extractedFile);
|
||||||
|
|
||||||
|
// Create StirlingFileStub with ZIP's history metadata
|
||||||
|
const stub: StirlingFileStub = {
|
||||||
|
id: newStirlingFile.fileId,
|
||||||
|
name: extractedFile.name,
|
||||||
|
size: extractedFile.size,
|
||||||
|
type: extractedFile.type,
|
||||||
|
lastModified: extractedFile.lastModified,
|
||||||
|
quickKey: newStirlingFile.quickKey,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
isLeaf: true,
|
||||||
|
// Preserve ZIP's history - unzipping is NOT a tool operation
|
||||||
|
originalFileId: zipStub.originalFileId,
|
||||||
|
parentFileId: zipStub.parentFileId,
|
||||||
|
versionNumber: zipStub.versionNumber,
|
||||||
|
toolHistory: zipStub.toolHistory || [],
|
||||||
|
thumbnailUrl: thumbnail
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in IndexedDB
|
||||||
|
await fileStorage.storeStirlingFile(newStirlingFile, stub);
|
||||||
|
|
||||||
|
result.extractedStubs.push(stub);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
result.errors.push(`Failed to process "${extractedFile.name}": ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success = result.extractedStubs.length > 0;
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
result.errors.push(`Failed to extract ZIP file: ${errorMessage}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { renderHook, act } from '@testing-library/react';
|
|||||||
import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation';
|
import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation';
|
||||||
import { ConvertParameters } from '../../hooks/tools/convert/useConvertParameters';
|
import { ConvertParameters } from '../../hooks/tools/convert/useConvertParameters';
|
||||||
import { FileContextProvider } from '../../contexts/FileContext';
|
import { FileContextProvider } from '../../contexts/FileContext';
|
||||||
|
import { PreferencesProvider } from '../../contexts/PreferencesContext';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import i18n from '../../i18n/config';
|
import i18n from '../../i18n/config';
|
||||||
import { createTestStirlingFile } from '../utils/testFileHelpers';
|
import { createTestStirlingFile } from '../utils/testFileHelpers';
|
||||||
@ -88,9 +89,11 @@ const createPDFFile = (): StirlingFile => {
|
|||||||
// Test wrapper component
|
// Test wrapper component
|
||||||
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<PreferencesProvider>
|
||||||
<FileContextProvider>
|
<FileContextProvider>
|
||||||
{children}
|
{children}
|
||||||
</FileContextProvider>
|
</FileContextProvider>
|
||||||
|
</PreferencesProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { renderHook, act, waitFor } from '@testing-library/react';
|
|||||||
import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation';
|
import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation';
|
||||||
import { useConvertParameters } from '../../hooks/tools/convert/useConvertParameters';
|
import { useConvertParameters } from '../../hooks/tools/convert/useConvertParameters';
|
||||||
import { FileContextProvider } from '../../contexts/FileContext';
|
import { FileContextProvider } from '../../contexts/FileContext';
|
||||||
|
import { PreferencesProvider } from '../../contexts/PreferencesContext';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import i18n from '../../i18n/config';
|
import i18n from '../../i18n/config';
|
||||||
import { detectFileExtension } from '../../utils/fileUtils';
|
import { detectFileExtension } from '../../utils/fileUtils';
|
||||||
@ -76,9 +77,11 @@ vi.mock('../../services/thumbnailGenerationService', () => ({
|
|||||||
|
|
||||||
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<PreferencesProvider>
|
||||||
<FileContextProvider>
|
<FileContextProvider>
|
||||||
{children}
|
{children}
|
||||||
</FileContextProvider>
|
</FileContextProvider>
|
||||||
|
</PreferencesProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user