mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Feature/v2/toggle_for_auto_unzip (#4584)
## default
<img width="1012" height="627"
alt="{BF57458D-50A6-4057-94F1-D6AB4628EFD8}"
src="https://github.com/user-attachments/assets/85e550ab-0aed-4341-be95-d5d3bc7146db"
/>
## disabled
<img width="1141" height="620"
alt="{140DB87B-05CF-4E0E-A14A-ED15075BD2EE}"
src="https://github.com/user-attachments/assets/e0f56e84-fb9d-4787-b5cb-ba7c5a54b1e1"
/>
## unzip options
<img width="530" height="255"
alt="{482CE185-73D5-4D90-91BB-B9305C711391}"
src="https://github.com/user-attachments/assets/609b18ee-4eae-4cee-afc1-5db01f9d1088"
/>
<img width="579" height="473"
alt="{4DFCA96D-792D-4370-8C62-4BA42C9F1A5F}"
src="https://github.com/user-attachments/assets/c67fa4af-04ef-41df-9420-65ce4247e25b"
/>
## pop up and maintains version metadata
<img width="1071" height="1220"
alt="{7F2A785C-5717-4A79-9D45-74BDA46DF273}"
src="https://github.com/user-attachments/assets/9374cd2a-b7e5-46c4-a722-e141ab42f0de"
/>
---------
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
parent
be7e79be55
commit
ab6edd3196
@ -257,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.",
|
||||||
@ -3187,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,25 +42,27 @@ export default function App() {
|
|||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<RainbowThemeProvider>
|
<RainbowThemeProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
<PreferencesProvider>
|
||||||
<NavigationProvider>
|
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||||
<FilesModalProvider>
|
<NavigationProvider>
|
||||||
<ToolWorkflowProvider>
|
<FilesModalProvider>
|
||||||
<HotkeyProvider>
|
<ToolWorkflowProvider>
|
||||||
<SidebarProvider>
|
<HotkeyProvider>
|
||||||
<ViewerProvider>
|
<SidebarProvider>
|
||||||
<SignatureProvider>
|
<ViewerProvider>
|
||||||
<RightRailProvider>
|
<SignatureProvider>
|
||||||
<HomePage />
|
<RightRailProvider>
|
||||||
</RightRailProvider>
|
<HomePage />
|
||||||
</SignatureProvider>
|
</RightRailProvider>
|
||||||
</ViewerProvider>
|
</SignatureProvider>
|
||||||
</SidebarProvider>
|
</ViewerProvider>
|
||||||
</HotkeyProvider>
|
</SidebarProvider>
|
||||||
</ToolWorkflowProvider>
|
</HotkeyProvider>
|
||||||
</FilesModalProvider>
|
</ToolWorkflowProvider>
|
||||||
</NavigationProvider>
|
</FilesModalProvider>
|
||||||
</FileContextProvider>
|
</NavigationProvider>
|
||||||
|
</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) {
|
||||||
@ -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) => {
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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[]> => {
|
|
||||||
try {
|
|
||||||
// Always try to extract as ZIP first, regardless of content-type
|
|
||||||
const extractionResult = await zipFileService.extractAllFiles(responseData);
|
|
||||||
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
|
|
||||||
return extractionResult.extractedFiles;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to extract as ZIP, treating as single file:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: treat as single file (PNG image)
|
|
||||||
const inputFileName = inputFiles[0]?.name || 'document';
|
|
||||||
const baseFileName = inputFileName.replace(/\.[^.]+$/, '');
|
|
||||||
const singleFile = new File([responseData], `${baseFileName}.png`, { type: 'image/png' });
|
|
||||||
return [singleFile];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const scannerImageSplitOperationConfig = {
|
export const scannerImageSplitOperationConfig = {
|
||||||
toolType: ToolType.singleFile,
|
toolType: ToolType.singleFile,
|
||||||
buildFormData: buildScannerImageSplitFormData,
|
buildFormData: buildScannerImageSplitFormData,
|
||||||
operationType: 'scannerImageSplit',
|
operationType: 'scannerImageSplit',
|
||||||
endpoint: '/api/v1/misc/extract-image-scans',
|
endpoint: '/api/v1/misc/extract-image-scans',
|
||||||
multiFileEndpoint: false,
|
|
||||||
responseHandler: scannerImageSplitResponseHandler,
|
|
||||||
defaultParameters,
|
defaultParameters,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const useScannerImageSplitOperation = () => {
|
export const useScannerImageSplitOperation = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { extractAllZipFiles } = useToolResources();
|
||||||
|
|
||||||
return useToolOperation<ScannerImageSplitParameters>({
|
// 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 {
|
||||||
|
// Scanner image split returns ZIP files with multiple images
|
||||||
|
const extractedFiles = await extractAllZipFiles(blob);
|
||||||
|
|
||||||
|
// If extraction succeeded and returned files, use them
|
||||||
|
if (extractedFiles.length > 0) {
|
||||||
|
return extractedFiles;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to extract as ZIP, treating as single file:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: treat as single file (PNG image)
|
||||||
|
const inputFileName = originalFiles[0]?.name || 'document';
|
||||||
|
const baseFileName = inputFileName.replace(/\.[^.]+$/, '');
|
||||||
|
const singleFile = new File([blob], `${baseFileName}.png`, { type: 'image/png' });
|
||||||
|
return [singleFile];
|
||||||
|
}, [extractAllZipFiles]);
|
||||||
|
|
||||||
|
const config: ToolOperationConfig<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 {
|
||||||
const zip = new JSZip();
|
// Automation always extracts
|
||||||
await zip.loadAsync(file);
|
if (skipAutoUnzip) {
|
||||||
|
return true;
|
||||||
// Check if any files are encrypted
|
|
||||||
for (const [_filename, zipEntry] of Object.entries(zip.files)) {
|
|
||||||
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
|
// Check if auto-unzip is enabled
|
||||||
|
if (!autoUnzip) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load ZIP and count files
|
||||||
|
const zip = new JSZip();
|
||||||
|
const zipContents = await zip.loadAsync(zipBlob);
|
||||||
|
|
||||||
|
// Count non-directory entries
|
||||||
|
const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length;
|
||||||
|
|
||||||
|
// 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}>
|
||||||
<FileContextProvider>
|
<PreferencesProvider>
|
||||||
{children}
|
<FileContextProvider>
|
||||||
</FileContextProvider>
|
{children}
|
||||||
|
</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}>
|
||||||
<FileContextProvider>
|
<PreferencesProvider>
|
||||||
{children}
|
<FileContextProvider>
|
||||||
</FileContextProvider>
|
{children}
|
||||||
|
</FileContextProvider>
|
||||||
|
</PreferencesProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user