mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
Unzip working
This commit is contained in:
parent
247f82b5a7
commit
9a0503b33b
@ -256,6 +256,13 @@
|
||||
"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."
|
||||
},
|
||||
"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.",
|
||||
@ -3184,6 +3191,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,25 +42,27 @@ export default function App() {
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</Suspense>
|
||||
|
||||
@ -309,6 +309,47 @@ 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 files from the ZIP
|
||||
const extractionResult = await zipFileService.extractPdfFiles(file);
|
||||
|
||||
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
|
||||
// Add extracted files to FileContext
|
||||
await addFiles(extractionResult.extractedFiles);
|
||||
|
||||
// Remove the original ZIP file
|
||||
removeFiles([fileId], false);
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: `Extracted ${extractionResult.extractedFiles.length} file(s) from ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: `Failed to extract files from ${file.name}`,
|
||||
expandable: false,
|
||||
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, addFiles, removeFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
@ -429,6 +470,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) => {
|
||||
|
||||
@ -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,48 @@
|
||||
import React from 'react';
|
||||
import { Paper, Stack, Switch, Text, Tooltip } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePreferences } from '../../../../contexts/PreferencesContext';
|
||||
|
||||
const GeneralSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
|
||||
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>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSection;
|
||||
@ -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,32 @@ 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 files from the ZIP
|
||||
const extractionResult = await zipFileService.extractPdfFiles(stirlingFile);
|
||||
|
||||
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
|
||||
// Add extracted files to the file manager
|
||||
onNewFilesSelect(extractionResult.extractedFiles);
|
||||
|
||||
// Optionally remove the original ZIP file
|
||||
const fileIndex = filteredFiles.findIndex(f => f.id === file.id);
|
||||
if (fileIndex !== -1) {
|
||||
await handleFileRemove(fileIndex);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unzip file:', error);
|
||||
}
|
||||
}, [onNewFilesSelect, filteredFiles, handleFileRemove]);
|
||||
|
||||
// Cleanup blob URLs when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -595,6 +623,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onDownloadSingle: handleDownloadSingle,
|
||||
onToggleExpansion: handleToggleExpansion,
|
||||
onAddToRecents: handleAddToRecents,
|
||||
onUnzipFile: handleUnzipFile,
|
||||
onNewFilesSelect,
|
||||
|
||||
// External props
|
||||
@ -627,6 +656,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
handleDownloadSelected,
|
||||
handleToggleExpansion,
|
||||
handleAddToRecents,
|
||||
handleUnzipFile,
|
||||
onNewFilesSelect,
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
|
||||
81
frontend/src/contexts/PreferencesContext.tsx
Normal file
81
frontend/src/contexts/PreferencesContext.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
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]) => {
|
||||
try {
|
||||
await preferencesService.setPreference(key, value);
|
||||
setPreferences((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const resetPreferences = useCallback(async () => {
|
||||
try {
|
||||
await preferencesService.clearAllPreferences();
|
||||
setPreferences(DEFAULT_PREFERENCES);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
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;
|
||||
};
|
||||
@ -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,13 @@ 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 auto-unzip is disabled (unless explicitly skipped like in automation)
|
||||
if (!skipAutoUnzip && !preferences.autoUnzip) {
|
||||
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,7 +97,7 @@ export const useToolResources = () => {
|
||||
console.error('useToolResources.extractZipFiles - Error:', error);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
}, [preferences.autoUnzip]);
|
||||
|
||||
const extractAllZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
|
||||
try {
|
||||
|
||||
@ -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();
|
||||
|
||||
127
frontend/src/services/preferencesService.ts
Normal file
127
frontend/src/services/preferencesService.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
|
||||
|
||||
export interface UserPreferences {
|
||||
autoUnzip: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
autoUnzip: true,
|
||||
};
|
||||
|
||||
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, reject) => {
|
||||
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, reject) => {
|
||||
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,5 @@
|
||||
import JSZip, { JSZipObject } from 'jszip';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
|
||||
// Undocumented interface in JSZip for JSZipObject._data
|
||||
interface CompressedObject {
|
||||
@ -41,6 +42,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 +248,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
|
||||
*/
|
||||
@ -308,37 +322,6 @@ export class ZipFileService {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from filename
|
||||
*/
|
||||
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> {
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
await zip.loadAsync(file);
|
||||
|
||||
// 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
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all files from a ZIP archive (not limited to PDFs)
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user