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) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
@ -136,14 +136,14 @@ Stirling-PDF currently supports 40 languages!
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
|
||||
@ -50,6 +50,14 @@ const nodeGlobs = [
|
||||
|
||||
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(
|
||||
{ ignores: ignorePatterns },
|
||||
|
||||
@ -224,5 +232,5 @@ export default defineConfig(
|
||||
'react/jsx-uses-react': '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",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^16.4.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^16.4.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"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",
|
||||
"keepWorking": "Keep Working",
|
||||
"discardChanges": "Discard Changes",
|
||||
"applyAndContinue": "Apply & Continue",
|
||||
"discardChanges": "Discard & Leave",
|
||||
"applyAndContinue": "Save & Leave",
|
||||
"exportAndContinue": "Export & Continue",
|
||||
"language": {
|
||||
"direction": "ltr"
|
||||
@ -256,6 +257,16 @@
|
||||
"name": "Save form inputs",
|
||||
"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": {
|
||||
"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.",
|
||||
@ -3186,6 +3197,7 @@
|
||||
"lastModified": "Last Modified",
|
||||
"toolChain": "Tools Applied",
|
||||
"restore": "Restore",
|
||||
"unzip": "Unzip",
|
||||
"searchFiles": "Search files...",
|
||||
"recent": "Recent",
|
||||
"localFiles": "Local Files",
|
||||
|
||||
@ -6,6 +6,7 @@ import { FilesModalProvider } from "./contexts/FilesModalContext";
|
||||
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
|
||||
import { HotkeyProvider } from "./contexts/HotkeyContext";
|
||||
import { SidebarProvider } from "./contexts/SidebarContext";
|
||||
import { PreferencesProvider } from "./contexts/PreferencesContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import HomePage from "./pages/HomePage";
|
||||
|
||||
@ -41,6 +42,7 @@ export default function App() {
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
@ -60,6 +62,7 @@ export default function App() {
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</Suspense>
|
||||
|
||||
@ -3,7 +3,7 @@ import {
|
||||
Text, Center, Box, LoadingOverlay, Stack, Group
|
||||
} from '@mantine/core';
|
||||
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 { zipFileService } from '../../services/zipFileService';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
@ -37,6 +37,7 @@ const FileEditor = ({
|
||||
// Use optimized FileContext hooks
|
||||
const { state, selectors } = useFileState();
|
||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||
@ -309,6 +310,48 @@ const FileEditor = ({
|
||||
}
|
||||
}, [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 record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
@ -348,7 +391,7 @@ const FileEditor = ({
|
||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={false} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Box p="md">
|
||||
|
||||
|
||||
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
@ -429,6 +472,7 @@ const FileEditor = ({
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onUnzipFile={handleUnzipFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
/>
|
||||
|
||||
@ -5,11 +5,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
|
||||
import styles from './FileEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
@ -32,6 +34,7 @@ interface FileEditorThumbnailProps {
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile: (fileId: FileId) => void;
|
||||
onUnzipFile?: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
@ -45,6 +48,7 @@ const FileEditorThumbnail = ({
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
@ -64,6 +68,9 @@ const FileEditorThumbnail = ({
|
||||
}, [activeFiles, file.id]);
|
||||
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 handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
@ -299,6 +306,16 @@ const FileEditorThumbnail = ({
|
||||
<span>{t('download', 'Download')}</span>
|
||||
</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} />
|
||||
|
||||
<button
|
||||
|
||||
@ -5,10 +5,12 @@ import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||
import { FileId, StirlingFileStub } from '../../types/fileContext';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
|
||||
interface FileListItemProps {
|
||||
@ -38,7 +40,10 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
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
|
||||
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
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
|
||||
@ -146,10 +146,12 @@ export default function Workbench() {
|
||||
}
|
||||
>
|
||||
{/* Top Controls */}
|
||||
{activeFiles.length > 0 && (
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
@ -159,6 +161,7 @@ export default function Workbench() {
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
paddingTop: activeFiles.length > 0 ? '3.5rem' : '0',
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
|
||||
@ -662,7 +662,7 @@ const PageEditor = ({
|
||||
const displayedPages = displayDocument?.pages || [];
|
||||
|
||||
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} />
|
||||
|
||||
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
|
||||
|
||||
@ -1,25 +1,19 @@
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
import { useNavigationGuard } from '../../contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
|
||||
import { useNavigationGuard } from "../../contexts/NavigationContext";
|
||||
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 {
|
||||
onApplyAndContinue?: () => Promise<void>;
|
||||
onExportAndContinue?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const NavigationWarningModal = ({
|
||||
onApplyAndContinue,
|
||||
onExportAndContinue
|
||||
}: NavigationWarningModalProps) => {
|
||||
|
||||
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
showNavigationWarning,
|
||||
hasUnsavedChanges,
|
||||
cancelNavigation,
|
||||
confirmNavigation,
|
||||
setHasUnsavedChanges
|
||||
} = useNavigationGuard();
|
||||
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
|
||||
useNavigationGuard();
|
||||
|
||||
const handleKeepWorking = () => {
|
||||
cancelNavigation();
|
||||
@ -38,13 +32,14 @@ const NavigationWarningModal = ({
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const handleExportAndContinue = async () => {
|
||||
const _handleExportAndContinue = async () => {
|
||||
if (onExportAndContinue) {
|
||||
await onExportAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
const BUTTON_WIDTH = "10rem";
|
||||
|
||||
if (!hasUnsavedChanges) {
|
||||
return null;
|
||||
@ -56,55 +51,53 @@ const NavigationWarningModal = ({
|
||||
onClose={handleKeepWorking}
|
||||
title={t("unsavedChangesTitle", "Unsaved Changes")}
|
||||
centered
|
||||
size="xl"
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
size="auto"
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
>
|
||||
<Stack gap="xl">
|
||||
<Text size="md">
|
||||
{t("unsavedChanges", "You have unsaved changes to your PDF. What would you like to do?")}
|
||||
<Stack>
|
||||
<Stack ta="center" p="md">
|
||||
<Text size="md" fw="300">
|
||||
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
|
||||
</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">
|
||||
<Group gap="xl">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={handleDiscardChanges}
|
||||
>
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="var(--mantine-color-gray-8)"
|
||||
onClick={handleKeepWorking}
|
||||
>
|
||||
{/* Desktop layout: 2 groups side by side */}
|
||||
<Group justify="space-between" gap="xl" visibleFrom="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
|
||||
{t("keepWorking", "Keep Working")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="xl">
|
||||
{onExportAndContinue && (
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={handleExportAndContinue}
|
||||
>
|
||||
{t("exportAndContinue", "Export & Continue")}
|
||||
<Group gap="sm">
|
||||
<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="light"
|
||||
color="blue"
|
||||
onClick={handleApplyAndContinue}
|
||||
>
|
||||
{t("applyAndContinue", "Apply & Continue")}
|
||||
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||
{t("applyAndContinue", "Apply & Leave")}
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { NavKey } from './types';
|
||||
import HotkeysSection from './configSections/HotkeysSection';
|
||||
import GeneralSection from './configSections/GeneralSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
@ -43,6 +44,12 @@ export const createConfigNavSections = (
|
||||
{
|
||||
title: 'Preferences',
|
||||
items: [
|
||||
{
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
icon: 'settings-rounded',
|
||||
component: <GeneralSection />
|
||||
},
|
||||
{
|
||||
key: 'hotkeys',
|
||||
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>
|
||||
<Viewport
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
backgroundColor: 'var(--bg-background)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
@ -299,7 +299,8 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none'
|
||||
msUserSelect: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)'
|
||||
}}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
|
||||
@ -12,30 +12,42 @@ export function ZoomAPIBridge() {
|
||||
|
||||
// Set initial zoom once when plugin is ready
|
||||
useEffect(() => {
|
||||
if (zoom && !hasSetInitialZoom.current) {
|
||||
hasSetInitialZoom.current = true;
|
||||
setTimeout(() => {
|
||||
if (!zoom || hasSetInitialZoom.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const attemptInitialZoom = () => {
|
||||
try {
|
||||
zoom.requestZoom(1.4);
|
||||
hasSetInitialZoom.current = true;
|
||||
} catch (error) {
|
||||
console.log('Zoom initialization delayed, viewport not ready:', error);
|
||||
// Retry after a longer delay
|
||||
setTimeout(() => {
|
||||
retryTimer = setTimeout(() => {
|
||||
try {
|
||||
zoom.requestZoom(1.4);
|
||||
hasSetInitialZoom.current = true;
|
||||
} catch (retryError) {
|
||||
console.log('Zoom initialization failed:', retryError);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const timer = setTimeout(attemptInitialZoom, 50);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
}
|
||||
}, [zoom]);
|
||||
};
|
||||
}, [zoom, zoomState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (zoom && zoomState) {
|
||||
// Update local state
|
||||
const currentZoomLevel = zoomState.currentZoomLevel || 1.4;
|
||||
const currentZoomLevel = zoomState.currentZoomLevel ?? 1.4;
|
||||
const newState = {
|
||||
currentZoom: currentZoomLevel,
|
||||
zoomPercent: Math.round(currentZoomLevel * 100),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { zipFileService } from '../services/zipFileService';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
import { downloadFiles } from '../utils/downloadUtils';
|
||||
import { FileId } from '../types/file';
|
||||
@ -36,6 +37,7 @@ interface FileManagerContextValue {
|
||||
onDownloadSingle: (file: StirlingFileStub) => void;
|
||||
onToggleExpansion: (fileId: FileId) => void;
|
||||
onAddToRecents: (file: StirlingFileStub) => void;
|
||||
onUnzipFile: (file: StirlingFileStub) => Promise<void>;
|
||||
onNewFilesSelect: (files: File[]) => void;
|
||||
|
||||
// External props
|
||||
@ -544,6 +546,30 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -595,6 +621,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onDownloadSingle: handleDownloadSingle,
|
||||
onToggleExpansion: handleToggleExpansion,
|
||||
onAddToRecents: handleAddToRecents,
|
||||
onUnzipFile: handleUnzipFile,
|
||||
onNewFilesSelect,
|
||||
|
||||
// External props
|
||||
@ -627,6 +654,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
handleDownloadSelected,
|
||||
handleToggleExpansion,
|
||||
handleAddToRecents,
|
||||
handleUnzipFile,
|
||||
onNewFilesSelect,
|
||||
recentFiles,
|
||||
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 { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { ScannerImageSplitParameters, defaultParameters } from './useScannerImageSplitParameters';
|
||||
import { zipFileService } from '../../../services/zipFileService';
|
||||
import { useToolResources } from '../shared/useToolResources';
|
||||
|
||||
export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
@ -15,40 +16,46 @@ export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitPara
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Custom response handler to handle ZIP files that might be misidentified
|
||||
const scannerImageSplitResponseHandler = async (responseData: Blob, inputFiles: File[]): Promise<File[]> => {
|
||||
// Static configuration object
|
||||
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 {
|
||||
// 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;
|
||||
// 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 = inputFiles[0]?.name || 'document';
|
||||
const inputFileName = originalFiles[0]?.name || 'document';
|
||||
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];
|
||||
};
|
||||
}, [extractAllZipFiles]);
|
||||
|
||||
export const scannerImageSplitOperationConfig = {
|
||||
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>({
|
||||
const config: ToolOperationConfig<ScannerImageSplitParameters> = {
|
||||
...scannerImageSplitOperationConfig,
|
||||
responseHandler,
|
||||
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];
|
||||
} else {
|
||||
// Default: assume ZIP response for multi-file endpoints
|
||||
// Note: extractZipFiles will check preferences.autoUnzip setting
|
||||
processedFiles = await extractZipFiles(response.data);
|
||||
|
||||
if (processedFiles.length === 0) {
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { generateThumbnailForFile, generateThumbnailWithMetadata, ThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
|
||||
import { zipFileService } from '../../../services/zipFileService';
|
||||
import { usePreferences } from '../../../contexts/PreferencesContext';
|
||||
|
||||
|
||||
export const useToolResources = () => {
|
||||
const { preferences } = usePreferences();
|
||||
const [blobUrls, setBlobUrls] = useState<string[]>([]);
|
||||
|
||||
const addBlobUrl = useCallback((url: string) => {
|
||||
@ -81,8 +83,20 @@ export const useToolResources = () => {
|
||||
return results;
|
||||
}, []);
|
||||
|
||||
const extractZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
|
||||
const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise<File[]> => {
|
||||
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 extractionResult = await zipFileService.extractPdfFiles(zipFile);
|
||||
return extractionResult.success ? extractionResult.extractedFiles : [];
|
||||
@ -90,32 +104,30 @@ export const useToolResources = () => {
|
||||
console.error('useToolResources.extractZipFiles - Error:', error);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
}, [preferences.autoUnzip, preferences.autoUnzipFileLimit]);
|
||||
|
||||
const extractAllZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
|
||||
const extractAllZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise<File[]> => {
|
||||
try {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
// Check if we should extract based on preferences
|
||||
const shouldExtract = await zipFileService.shouldUnzip(
|
||||
zipBlob,
|
||||
preferences.autoUnzip,
|
||||
preferences.autoUnzipFileLimit,
|
||||
skipAutoUnzip
|
||||
);
|
||||
|
||||
const arrayBuffer = await zipBlob.arrayBuffer();
|
||||
const zipContent = await zip.loadAsync(arrayBuffer);
|
||||
|
||||
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);
|
||||
}
|
||||
if (!shouldExtract) {
|
||||
return [new File([zipBlob], 'result.zip', { type: 'application/zip' })];
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error('Error in extractAllZipFiles:', error);
|
||||
console.error('useToolResources.extractAllZipFiles - Error:', error);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
}, [preferences.autoUnzip, preferences.autoUnzipFileLimit]);
|
||||
|
||||
const createDownloadInfo = useCallback(async (
|
||||
files: File[],
|
||||
|
||||
@ -314,6 +314,15 @@ export const DATABASE_CONFIGS = {
|
||||
}]
|
||||
} as DatabaseConfig,
|
||||
|
||||
PREFERENCES: {
|
||||
name: 'stirling-pdf-preferences',
|
||||
version: 1,
|
||||
stores: [{
|
||||
name: 'preferences',
|
||||
keyPath: 'key'
|
||||
}]
|
||||
} as DatabaseConfig,
|
||||
|
||||
} as const;
|
||||
|
||||
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 { StirlingFileStub, createStirlingFile } from '../types/fileContext';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
import { fileStorage } from './fileStorage';
|
||||
|
||||
// Undocumented interface in JSZip for JSZipObject._data
|
||||
interface CompressedObject {
|
||||
@ -41,6 +44,15 @@ export class ZipFileService {
|
||||
private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit
|
||||
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
|
||||
*/
|
||||
@ -238,23 +250,27 @@ export class ZipFileService {
|
||||
/**
|
||||
* Check if a file is a ZIP file based on type and extension
|
||||
*/
|
||||
private isZipFile(file: File): boolean {
|
||||
const validTypes = [
|
||||
'application/zip',
|
||||
'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 =>
|
||||
public isZipFile(file: File): boolean {
|
||||
const hasValidType = ZipFileService.VALID_ZIP_TYPES.includes(file.type);
|
||||
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some(ext =>
|
||||
file.name.toLowerCase().endsWith(ext)
|
||||
);
|
||||
|
||||
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
|
||||
*/
|
||||
@ -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 {
|
||||
return filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ZIP file contains password protection
|
||||
*/
|
||||
private async isPasswordProtected(file: File): Promise<boolean> {
|
||||
async shouldUnzip(
|
||||
zipBlob: Blob | File,
|
||||
autoUnzip: boolean,
|
||||
autoUnzipFileLimit: number,
|
||||
skipAutoUnzip: boolean = false
|
||||
): Promise<boolean> {
|
||||
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();
|
||||
await zip.loadAsync(file);
|
||||
const zipContents = await zip.loadAsync(zipBlob);
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// Count non-directory entries
|
||||
const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length;
|
||||
|
||||
return false; // JSZip will throw an error if password is required
|
||||
// Only extract if within limit
|
||||
return fileCount <= autoUnzipFileLimit;
|
||||
} catch (error) {
|
||||
// If we can't load the ZIP, it might be password protected
|
||||
const errorMessage = error instanceof Error ? error.message : '';
|
||||
return errorMessage.includes('password') || errorMessage.includes('encrypted');
|
||||
console.error('Error checking shouldUnzip:', error);
|
||||
// On error, default to not extracting (safer)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -457,6 +484,79 @@ export class ZipFileService {
|
||||
|
||||
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
|
||||
|
||||
@ -15,6 +15,7 @@ import { renderHook, act } from '@testing-library/react';
|
||||
import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation';
|
||||
import { ConvertParameters } from '../../hooks/tools/convert/useConvertParameters';
|
||||
import { FileContextProvider } from '../../contexts/FileContext';
|
||||
import { PreferencesProvider } from '../../contexts/PreferencesContext';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../i18n/config';
|
||||
import { createTestStirlingFile } from '../utils/testFileHelpers';
|
||||
@ -88,9 +89,11 @@ const createPDFFile = (): StirlingFile => {
|
||||
// Test wrapper component
|
||||
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation';
|
||||
import { useConvertParameters } from '../../hooks/tools/convert/useConvertParameters';
|
||||
import { FileContextProvider } from '../../contexts/FileContext';
|
||||
import { PreferencesProvider } from '../../contexts/PreferencesContext';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../i18n/config';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
@ -76,9 +77,11 @@ vi.mock('../../services/thumbnailGenerationService', () => ({
|
||||
|
||||
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user