mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-24 04:26:14 +00:00
Compare commits
11 Commits
f534022f09
...
62b82631b1
Author | SHA1 | Date | |
---|---|---|---|
![]() |
62b82631b1 | ||
![]() |
87c63efcec | ||
![]() |
5caec41d96 | ||
![]() |
d558bb5fac | ||
![]() |
cd1fc682ab | ||
![]() |
b9cf7e7820 | ||
![]() |
94e8f603ff | ||
![]() |
74609e54fe | ||
![]() |
003285506f | ||
![]() |
6d3b08d9b6 | ||
![]() |
295e682e03 |
@ -24,7 +24,7 @@ indent_size = 2
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[{*.js,*.jsx,*.ts,*.tsx}]
|
||||
[{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
|
||||
indent_size = 2
|
||||
|
||||
[*.css]
|
||||
|
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@ -147,6 +147,8 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
- name: Lint frontend
|
||||
run: cd frontend && npm run lint
|
||||
- name: Build frontend
|
||||
run: cd frontend && npm run build
|
||||
- name: Run frontend tests
|
||||
|
1
.vscode/extensions.json
vendored
1
.vscode/extensions.json
vendored
@ -19,5 +19,6 @@
|
||||
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
|
||||
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
|
||||
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
|
||||
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
|
||||
]
|
||||
}
|
||||
|
31
frontend/eslint.config.mjs
Normal file
31
frontend/eslint.config.mjs
Normal file
@ -0,0 +1,31 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default defineConfig(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
ignores: [
|
||||
"dist", // Contains 3rd party code
|
||||
"public", // Contains 3rd party code
|
||||
],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
"no-undef": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
|
||||
allowInterfaces: 'with-single-extends',
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-unused-vars": "off", // Temporarily disabled until codebase conformant
|
||||
},
|
||||
}
|
||||
);
|
934
frontend/package-lock.json
generated
934
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -40,6 +40,7 @@
|
||||
"predev": "npm run generate-icons",
|
||||
"dev": "npx tsc --noEmit && vite",
|
||||
"prebuild": "npm run generate-icons",
|
||||
"lint": "npx eslint",
|
||||
"build": "npx tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@ -72,6 +73,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@iconify-json/material-symbols": "^1.2.33",
|
||||
"@iconify/utils": "^3.0.1",
|
||||
"@playwright/test": "^1.40.0",
|
||||
@ -80,6 +82,7 @@
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"@vitest/coverage-v8": "^1.0.0",
|
||||
"eslint": "^9.34.0",
|
||||
"jsdom": "^23.0.0",
|
||||
"license-checker": "^25.0.1",
|
||||
"madge": "^8.0.0",
|
||||
@ -87,7 +90,8 @@
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
|
@ -224,7 +224,7 @@ function getLicenseUrl(licenseType) {
|
||||
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||
if (licenseType.includes('AND') || licenseType.includes('OR')) {
|
||||
// Extract the first license from compound expressions for URL
|
||||
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-\.]+)/);
|
||||
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
|
||||
if (match && licenseUrls[match[1]]) {
|
||||
return licenseUrls[match[1]];
|
||||
}
|
||||
|
@ -14,6 +14,9 @@ import "./styles/cookieconsent.css";
|
||||
import "./index.css";
|
||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "./utils/fileIdSafety";
|
||||
|
||||
// Loading component for i18next suspense
|
||||
const LoadingFallback = () => (
|
||||
<div
|
||||
|
@ -24,7 +24,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
|
||||
|
||||
// Wrapper for storeFile that generates UUID
|
||||
const storeFileWithId = useCallback(async (file: File) => {
|
||||
const storeStirlingFile = useCallback(async (file: File) => {
|
||||
const fileId = createFileId(); // Generate UUID for storage
|
||||
return await storeFile(file, fileId);
|
||||
}, [storeFile]);
|
||||
|
@ -16,12 +16,12 @@ import styles from './FileEditor.module.css';
|
||||
import FileEditorThumbnail from './FileEditorThumbnail';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { FileId } from '../../types/file';
|
||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenPageEditor?: (file: File) => void;
|
||||
onMergeFiles?: (files: File[]) => void;
|
||||
onOpenPageEditor?: (file: StirlingFile) => void;
|
||||
onMergeFiles?: (files: StirlingFile[]) => void;
|
||||
toolMode?: boolean;
|
||||
showUpload?: boolean;
|
||||
showBulkActions?: boolean;
|
||||
@ -50,7 +50,7 @@ const FileEditor = ({
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
|
||||
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
|
||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const isProcessing = state.ui.isProcessing;
|
||||
|
||||
@ -92,10 +92,10 @@ const FileEditor = ({
|
||||
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
||||
contextSelectedIdsRef.current = contextSelectedIds;
|
||||
|
||||
// Use activeFileRecords directly - no conversion needed
|
||||
// Use activeStirlingFileStubs directly - no conversion needed
|
||||
const localSelectedIds = contextSelectedIds;
|
||||
|
||||
// Helper to convert FileRecord to FileThumbnail format
|
||||
// Helper to convert StirlingFileStub to FileThumbnail format
|
||||
const recordToFileItem = useCallback((record: any) => {
|
||||
const file = selectors.getFile(record.id);
|
||||
if (!file) return null;
|
||||
@ -253,26 +253,26 @@ const FileEditor = ({
|
||||
}, [addFiles]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly
|
||||
}, [activeFileRecords, setSelectedFiles]);
|
||||
setSelectedFiles(activeStirlingFileStubs.map(r => r.id)); // Use StirlingFileStub IDs directly
|
||||
}, [activeStirlingFileStubs, setSelectedFiles]);
|
||||
|
||||
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
|
||||
|
||||
const closeAllFiles = useCallback(() => {
|
||||
if (activeFileRecords.length === 0) return;
|
||||
if (activeStirlingFileStubs.length === 0) return;
|
||||
|
||||
// Remove all files from context but keep in storage
|
||||
const allFileIds = activeFileRecords.map(record => record.id);
|
||||
const allFileIds = activeStirlingFileStubs.map(record => record.id);
|
||||
removeFiles(allFileIds, false); // false = keep in storage
|
||||
|
||||
// Clear selections
|
||||
setSelectedFiles([]);
|
||||
}, [activeFileRecords, removeFiles, setSelectedFiles]);
|
||||
}, [activeStirlingFileStubs, removeFiles, setSelectedFiles]);
|
||||
|
||||
const toggleFile = useCallback((fileId: FileId) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
|
||||
const targetRecord = activeFileRecords.find(r => r.id === fileId);
|
||||
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (!targetRecord) return;
|
||||
|
||||
const contextFileId = fileId; // No need to create a new ID
|
||||
@ -302,7 +302,7 @@ const FileEditor = ({
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]);
|
||||
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode(prev => {
|
||||
@ -316,7 +316,7 @@ const FileEditor = ({
|
||||
|
||||
// File reordering handler for drag and drop
|
||||
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
|
||||
const currentIds = activeFileRecords.map(r => r.id);
|
||||
const currentIds = activeStirlingFileStubs.map(r => r.id);
|
||||
|
||||
// Find indices
|
||||
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
|
||||
@ -368,13 +368,13 @@ const FileEditor = ({
|
||||
// Update status
|
||||
const moveCount = filesToMove.length;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeFileRecords, reorderFiles, setStatus]);
|
||||
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
|
||||
|
||||
|
||||
|
||||
// File operations using context
|
||||
const handleDeleteFile = useCallback((fileId: FileId) => {
|
||||
const record = activeFileRecords.find(r => r.id === fileId);
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
|
||||
if (record && file) {
|
||||
@ -405,27 +405,27 @@ const FileEditor = ({
|
||||
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
|
||||
setSelectedFiles(currentSelected);
|
||||
}
|
||||
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeFileRecords.find(r => r.id === fileId);
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
// Set the file as selected in context and switch to viewer for preview
|
||||
setSelectedFiles([fileId]);
|
||||
navActions.setWorkbench('viewer');
|
||||
}
|
||||
}, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]);
|
||||
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
|
||||
|
||||
const handleMergeFromHere = useCallback((fileId: FileId) => {
|
||||
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
|
||||
const startIndex = activeStirlingFileStubs.findIndex(r => r.id === fileId);
|
||||
if (startIndex === -1) return;
|
||||
|
||||
const recordsToMerge = activeFileRecords.slice(startIndex);
|
||||
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
|
||||
const recordsToMerge = activeStirlingFileStubs.slice(startIndex);
|
||||
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as StirlingFile[];
|
||||
if (onMergeFiles) {
|
||||
onMergeFiles(filesToMerge);
|
||||
}
|
||||
}, [activeFileRecords, selectors, onMergeFiles]);
|
||||
}, [activeStirlingFileStubs, selectors, onMergeFiles]);
|
||||
|
||||
const handleSplitFile = useCallback((fileId: FileId) => {
|
||||
const file = selectors.getFile(fileId);
|
||||
@ -467,7 +467,7 @@ const FileEditor = ({
|
||||
<Box p="md" pt="xl">
|
||||
|
||||
|
||||
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📁</Text>
|
||||
@ -475,7 +475,7 @@ const FileEditor = ({
|
||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? (
|
||||
) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
|
||||
<Box>
|
||||
<SkeletonLoader type="controls" />
|
||||
|
||||
@ -522,7 +522,7 @@ const FileEditor = ({
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{activeFileRecords.map((record, index) => {
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
const fileItem = recordToFileItem(record);
|
||||
if (!fileItem) return null;
|
||||
|
||||
@ -531,7 +531,7 @@ const FileEditor = ({
|
||||
key={record.id}
|
||||
file={fileItem}
|
||||
index={index}
|
||||
totalFiles={activeFileRecords.length}
|
||||
totalFiles={activeStirlingFileStubs.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
|
@ -61,8 +61,8 @@ const FileEditorThumbnail = ({
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
|
||||
}, [activeFiles, file.name, file.size]);
|
||||
return activeFiles.find(f => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
const downloadSelectedFile = useCallback(() => {
|
||||
|
@ -61,8 +61,8 @@ const FileThumbnail = ({
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
|
||||
}, [activeFiles, file.name, file.size]);
|
||||
return activeFiles.find(f => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
const downloadSelectedFile = useCallback(() => {
|
||||
|
@ -27,9 +27,9 @@ export function usePageDocument(): PageDocumentHook {
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
|
||||
// Get primary file record outside useMemo to track processedFile changes
|
||||
const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null;
|
||||
const processedFilePages = primaryFileRecord?.processedFile?.pages;
|
||||
const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages;
|
||||
const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
|
||||
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
|
||||
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
|
||||
|
||||
// Compute merged document with stable signature (prevents infinite loops)
|
||||
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
||||
@ -38,16 +38,16 @@ export function usePageDocument(): PageDocumentHook {
|
||||
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
|
||||
|
||||
// If we have file IDs but no file record, something is wrong - return null to show loading
|
||||
if (!primaryFileRecord) {
|
||||
if (!primaryStirlingFileStub) {
|
||||
console.log('🎬 PageEditor: No primary file record found, showing loading');
|
||||
return null;
|
||||
}
|
||||
|
||||
const name =
|
||||
activeFileIds.length === 1
|
||||
? (primaryFileRecord.name ?? 'document.pdf')
|
||||
? (primaryStirlingFileStub.name ?? 'document.pdf')
|
||||
: activeFileIds
|
||||
.map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
||||
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
||||
.join(' + ');
|
||||
|
||||
// Build page insertion map from files with insertion positions
|
||||
@ -55,7 +55,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
const originalFileIds: FileId[] = [];
|
||||
|
||||
activeFileIds.forEach(fileId => {
|
||||
const record = selectors.getFileRecord(fileId);
|
||||
const record = selectors.getStirlingFileStub(fileId);
|
||||
if (record?.insertAfterPageId !== undefined) {
|
||||
if (!insertionMap.has(record.insertAfterPageId)) {
|
||||
insertionMap.set(record.insertAfterPageId, []);
|
||||
@ -72,12 +72,12 @@ export function usePageDocument(): PageDocumentHook {
|
||||
|
||||
// Helper function to create pages from a file
|
||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
|
||||
const fileRecord = selectors.getFileRecord(fileId);
|
||||
if (!fileRecord) {
|
||||
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||
if (!stirlingFileStub) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const processedFile = fileRecord.processedFile;
|
||||
const processedFile = stirlingFileStub.processedFile;
|
||||
let filePages: PDFPage[] = [];
|
||||
|
||||
if (processedFile?.pages && processedFile.pages.length > 0) {
|
||||
@ -159,7 +159,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
};
|
||||
|
||||
return mergedDoc;
|
||||
}, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
|
||||
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
|
||||
|
||||
// Large document detection for smart loading
|
||||
const isVeryLargeDocument = useMemo(() => {
|
||||
|
@ -6,13 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
|
||||
import { FileRecord } from "../../types/fileContext";
|
||||
import { StirlingFileStub } from "../../types/fileContext";
|
||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||
|
||||
interface FileCardProps {
|
||||
file: File;
|
||||
record?: FileRecord;
|
||||
record?: StirlingFileStub;
|
||||
onRemove: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onView?: () => void;
|
||||
@ -25,7 +25,7 @@ interface FileCardProps {
|
||||
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||
const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
|
||||
const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
|
||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
||||
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
@ -4,15 +4,15 @@ import { useTranslation } from "react-i18next";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import SortIcon from "@mui/icons-material/Sort";
|
||||
import FileCard from "./FileCard";
|
||||
import { FileRecord } from "../../types/fileContext";
|
||||
import { StirlingFileStub } from "../../types/fileContext";
|
||||
import { FileId } from "../../types/file";
|
||||
|
||||
interface FileGridProps {
|
||||
files: Array<{ file: File; record?: FileRecord }>;
|
||||
files: Array<{ file: File; record?: StirlingFileStub }>;
|
||||
onRemove?: (index: number) => void;
|
||||
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onView?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onEdit?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||
onView?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||
onEdit?: (item: { file: File; record?: StirlingFileStub }) => void;
|
||||
onSelect?: (fileId: FileId) => void;
|
||||
selectedFiles?: FileId[];
|
||||
showSearch?: boolean;
|
||||
@ -123,9 +123,17 @@ const FileGrid = ({
|
||||
h="30rem"
|
||||
style={{ overflowY: "auto", width: "100%" }}
|
||||
>
|
||||
{displayFiles.map((item, idx) => {
|
||||
const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */;
|
||||
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
|
||||
{displayFiles
|
||||
.filter(item => {
|
||||
if (!item.record?.id) {
|
||||
console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((item, idx) => {
|
||||
const fileId = item.record!.id; // Safe to assert after filter
|
||||
const originalIdx = files.findIndex(f => f.record?.id === fileId);
|
||||
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
||||
return (
|
||||
<FileCard
|
||||
|
@ -17,8 +17,7 @@ import {
|
||||
getActiveNavButton,
|
||||
} from './quickAccessBar/QuickAccessBar';
|
||||
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
||||
}, ref) => {
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
|
@ -34,7 +34,6 @@ export default function RightRail() {
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
const fileRecords = selectors.getFileRecords();
|
||||
|
||||
// Compute selection state and total items
|
||||
const getSelectionState = useCallback(() => {
|
||||
@ -85,7 +84,7 @@ export default function RightRail() {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
// Download selected files (or all if none selected)
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
|
||||
|
||||
filesToDownload.forEach(file => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(file);
|
||||
@ -206,8 +205,8 @@ export default function RightRail() {
|
||||
)}
|
||||
|
||||
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
||||
<div
|
||||
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
<div
|
||||
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
aria-hidden={currentView === 'viewer'}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
@ -358,14 +357,14 @@ export default function RightRail() {
|
||||
<LanguageSelector position="left-start" offset={6} compact />
|
||||
|
||||
<Tooltip content={
|
||||
currentView === 'pageEditor'
|
||||
? t('rightRail.exportAll', 'Export PDF')
|
||||
currentView === 'pageEditor'
|
||||
? t('rightRail.exportAll', 'Export PDF')
|
||||
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
|
||||
} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={currentView === 'viewer' || totalItems === 0}
|
||||
|
@ -82,8 +82,8 @@ export function adjustFontSizeToFit(
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
try { ro.disconnect(); } catch {}
|
||||
try { mo.disconnect(); } catch {}
|
||||
try { ro.disconnect(); } catch { /* Ignore errors */ }
|
||||
try { mo.disconnect(); } catch { /* Ignore errors */ }
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -22,13 +22,13 @@ import {
|
||||
OUTPUT_OPTIONS,
|
||||
FIT_OPTIONS
|
||||
} from "../../../constants/convertConstants";
|
||||
import { FileId } from "../../../types/file";
|
||||
import { StirlingFile } from "../../../types/fileContext";
|
||||
|
||||
interface ConvertSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@ -129,7 +129,7 @@ const ConvertSettings = ({
|
||||
};
|
||||
|
||||
const filterFilesByExtension = (extension: string) => {
|
||||
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
|
||||
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as StirlingFile[];
|
||||
return files.filter(file => {
|
||||
const fileExtension = detectFileExtension(file.name);
|
||||
|
||||
@ -143,21 +143,8 @@ const ConvertSettings = ({
|
||||
});
|
||||
};
|
||||
|
||||
const updateFileSelection = (files: File[]) => {
|
||||
// Map File objects to their actual IDs in FileContext
|
||||
const fileIds = files.map(file => {
|
||||
// Find the file ID by matching file properties
|
||||
const fileRecord = state.files.ids
|
||||
.map(id => selectors.getFileRecord(id))
|
||||
.find(record =>
|
||||
record &&
|
||||
record.name === file.name &&
|
||||
record.size === file.size &&
|
||||
record.lastModified === file.lastModified
|
||||
);
|
||||
return fileRecord?.id;
|
||||
}).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
|
||||
|
||||
const updateFileSelection = (files: StirlingFile[]) => {
|
||||
const fileIds = files.map(file => file.fileId);
|
||||
setSelectedFiles(fileIds);
|
||||
};
|
||||
|
||||
|
@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
||||
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
|
||||
import { StirlingFile } from '../../../types/fileContext';
|
||||
|
||||
interface ConvertToPdfaSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload';
|
||||
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
|
||||
import { useAllFiles } from "../../../contexts/FileContext";
|
||||
import { useFileManager } from "../../../hooks/useFileManager";
|
||||
import { StirlingFile } from "../../../types/fileContext";
|
||||
|
||||
export interface FileStatusIndicatorProps {
|
||||
selectedFiles?: File[];
|
||||
selectedFiles?: StirlingFile[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
@ -17,7 +18,7 @@ const FileStatusIndicator = ({
|
||||
}: FileStatusIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal, onFilesSelect } = useFilesModalContext();
|
||||
const { files: workbenchFiles } = useAllFiles();
|
||||
const { files: stirlingFileStubs } = useAllFiles();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
|
||||
|
||||
@ -55,7 +56,7 @@ const FileStatusIndicator = ({
|
||||
}
|
||||
|
||||
// Check if there are no files in the workbench
|
||||
if (workbenchFiles.length === 0) {
|
||||
if (stirlingFileStubs.length === 0) {
|
||||
// If no recent files, show upload button
|
||||
if (!hasRecentFiles) {
|
||||
return (
|
||||
|
@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileStatusIndicator from './FileStatusIndicator';
|
||||
import { StirlingFile } from '../../../types/fileContext';
|
||||
|
||||
export interface FilesToolStepProps {
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
isCollapsed?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
placeholder?: string;
|
||||
|
@ -3,8 +3,6 @@ import { Stack, Text, Divider, Card, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
|
||||
|
||||
export interface SuggestedToolsSectionProps {}
|
||||
|
||||
export function SuggestedToolsSection(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const suggestedTools = useSuggestedTools();
|
||||
|
@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
|
||||
import OperationButton from './OperationButton';
|
||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
|
||||
import { StirlingFile } from '../../../types/fileContext';
|
||||
|
||||
export interface FilesStepConfig {
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
isCollapsed?: boolean;
|
||||
placeholder?: string;
|
||||
onCollapsedClick?: () => void;
|
||||
|
@ -126,7 +126,7 @@ const ToolSearch = ({
|
||||
key={id}
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
onToolSelect && onToolSelect(id);
|
||||
onToolSelect?.(id);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
|
||||
|
@ -15,6 +15,7 @@ import { fileStorage } from "../../services/fileStorage";
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
|
||||
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||||
import { isFileObject } from "../../types/fileContext";
|
||||
import { FileId } from "../../types/file";
|
||||
|
||||
|
||||
@ -201,7 +202,7 @@ const Viewer = ({
|
||||
const effectiveFile = React.useMemo(() => {
|
||||
if (previewFile) {
|
||||
// Validate the preview file
|
||||
if (!(previewFile instanceof File)) {
|
||||
if (!isFileObject(previewFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -19,7 +19,10 @@ import {
|
||||
FileContextStateValue,
|
||||
FileContextActionsValue,
|
||||
FileContextActions,
|
||||
FileRecord
|
||||
FileId,
|
||||
StirlingFileStub,
|
||||
StirlingFile,
|
||||
createStirlingFile
|
||||
} from '../types/fileContext';
|
||||
|
||||
// Import modular components
|
||||
@ -29,7 +32,6 @@ import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions
|
||||
import { FileLifecycleManager } from './file/lifecycle';
|
||||
import { FileStateContext, FileActionsContext } from './file/contexts';
|
||||
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
||||
import { FileId } from '../types/file';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
@ -79,7 +81,7 @@ function FileContextInner({
|
||||
}
|
||||
|
||||
// File operations using unified addFiles helper with persistence
|
||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<File[]> => {
|
||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
@ -98,15 +100,15 @@ function FileContextInner({
|
||||
}));
|
||||
}
|
||||
|
||||
return addedFilesWithIds.map(({ file }) => file);
|
||||
return addedFilesWithIds.map(({ file, id }) => createStirlingFile(file, id));
|
||||
}, [indexedDB, enablePersistence]);
|
||||
|
||||
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<File[]> => {
|
||||
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<StirlingFile[]> => {
|
||||
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
return result.map(({ file }) => file);
|
||||
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||
}, []);
|
||||
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<File[]> => {
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
@ -114,7 +116,7 @@ function FileContextInner({
|
||||
selectFiles(result);
|
||||
}
|
||||
|
||||
return result.map(({ file }) => file);
|
||||
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||
}, []);
|
||||
|
||||
// Action creators
|
||||
@ -122,11 +124,11 @@ function FileContextInner({
|
||||
|
||||
// Helper functions for pinned files
|
||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
|
||||
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch, indexedDB);
|
||||
return consumeFiles(inputFileIds, outputFiles, filesRef, dispatch, indexedDB);
|
||||
}, [indexedDB]);
|
||||
|
||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise<void> => {
|
||||
return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
|
||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
|
||||
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
|
||||
}, [indexedDB]);
|
||||
|
||||
// Helper to find FileId from File object
|
||||
@ -140,24 +142,14 @@ function FileContextInner({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// File-to-ID wrapper functions for pinning
|
||||
const pinFileWrapper = useCallback((file: File) => {
|
||||
const fileId = findFileId(file);
|
||||
if (fileId) {
|
||||
baseActions.pinFile(fileId);
|
||||
} else {
|
||||
console.warn('File not found for pinning:', file.name);
|
||||
}
|
||||
}, [baseActions, findFileId]);
|
||||
// File pinning functions - use StirlingFile directly
|
||||
const pinFileWrapper = useCallback((file: StirlingFile) => {
|
||||
baseActions.pinFile(file.fileId);
|
||||
}, [baseActions]);
|
||||
|
||||
const unpinFileWrapper = useCallback((file: File) => {
|
||||
const fileId = findFileId(file);
|
||||
if (fileId) {
|
||||
baseActions.unpinFile(fileId);
|
||||
} else {
|
||||
console.warn('File not found for unpinning:', file.name);
|
||||
}
|
||||
}, [baseActions, findFileId]);
|
||||
const unpinFileWrapper = useCallback((file: StirlingFile) => {
|
||||
baseActions.unpinFile(file.fileId);
|
||||
}, [baseActions]);
|
||||
|
||||
// Complete actions object
|
||||
const actions = useMemo<FileContextActions>(() => ({
|
||||
@ -178,8 +170,8 @@ function FileContextInner({
|
||||
}
|
||||
}
|
||||
},
|
||||
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
|
||||
lifecycleManager.updateFileRecord(fileId, updates, stateRef),
|
||||
updateStirlingFileStub: (fileId: FileId, updates: Partial<StirlingFileStub>) =>
|
||||
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef),
|
||||
reorderFiles: (orderedFileIds: FileId[]) => {
|
||||
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
|
||||
},
|
||||
@ -303,7 +295,7 @@ export {
|
||||
useFileSelection,
|
||||
useFileManagement,
|
||||
useFileUI,
|
||||
useFileRecord,
|
||||
useStirlingFileStub,
|
||||
useAllFiles,
|
||||
useSelectedFiles,
|
||||
// Primary API hooks for tools
|
||||
|
@ -6,7 +6,7 @@ import { FileId } from '../../types/file';
|
||||
import {
|
||||
FileContextState,
|
||||
FileContextAction,
|
||||
FileRecord
|
||||
StirlingFileStub
|
||||
} from '../../types/fileContext';
|
||||
|
||||
// Initial state
|
||||
@ -29,7 +29,7 @@ export const initialFileContextState: FileContextState = {
|
||||
function processFileSwap(
|
||||
state: FileContextState,
|
||||
filesToRemove: FileId[],
|
||||
filesToAdd: FileRecord[]
|
||||
filesToAdd: StirlingFileStub[]
|
||||
): FileContextState {
|
||||
// Only remove unpinned files
|
||||
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
|
||||
@ -70,11 +70,11 @@ function processFileSwap(
|
||||
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||
switch (action.type) {
|
||||
case 'ADD_FILES': {
|
||||
const { fileRecords } = action.payload;
|
||||
const { stirlingFileStubs } = action.payload;
|
||||
const newIds: FileId[] = [];
|
||||
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
||||
const newById: Record<FileId, StirlingFileStub> = { ...state.files.byId };
|
||||
|
||||
fileRecords.forEach(record => {
|
||||
stirlingFileStubs.forEach(record => {
|
||||
// Only add if not already present (dedupe by stable ID)
|
||||
if (!newById[record.id]) {
|
||||
newIds.push(record.id);
|
||||
@ -233,13 +233,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
}
|
||||
|
||||
case 'CONSUME_FILES': {
|
||||
const { inputFileIds, outputFileRecords } = action.payload;
|
||||
return processFileSwap(state, inputFileIds, outputFileRecords);
|
||||
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
||||
}
|
||||
|
||||
case 'UNDO_CONSUME_FILES': {
|
||||
const { inputFileRecords, outputFileIds } = action.payload;
|
||||
return processFileSwap(state, outputFileIds, inputFileRecords);
|
||||
const { inputStirlingFileStubs, outputFileIds } = action.payload;
|
||||
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
|
||||
}
|
||||
|
||||
case 'RESET_CONTEXT': {
|
||||
|
@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
FileRecord,
|
||||
StirlingFileStub,
|
||||
FileContextAction,
|
||||
FileContextState,
|
||||
toFileRecord,
|
||||
toStirlingFileStub,
|
||||
createFileId,
|
||||
createQuickKey
|
||||
} from '../../types/fileContext';
|
||||
@ -109,8 +109,8 @@ export async function addFiles(
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
const fileRecords: FileRecord[] = [];
|
||||
const addedFiles: AddedFile[] = [];
|
||||
const stirlingFileStubs: StirlingFileStub[] = [];
|
||||
const addedFiles: AddedFile[] = [];
|
||||
|
||||
// Build quickKey lookup from existing files for deduplication
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
@ -163,7 +163,7 @@ export async function addFiles(
|
||||
}
|
||||
|
||||
// Create record with immediate thumbnail and page metadata
|
||||
const record = toFileRecord(file, fileId);
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
@ -184,7 +184,7 @@ export async function addFiles(
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
}
|
||||
break;
|
||||
@ -205,7 +205,7 @@ export async function addFiles(
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
@ -226,7 +226,7 @@ export async function addFiles(
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
}
|
||||
break;
|
||||
@ -254,7 +254,7 @@ export async function addFiles(
|
||||
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
|
||||
// Generate processedFile metadata for stored files
|
||||
let pageCount: number = 1;
|
||||
@ -301,7 +301,7 @@ export async function addFiles(
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
||||
|
||||
}
|
||||
@ -310,9 +310,9 @@ export async function addFiles(
|
||||
}
|
||||
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (fileRecords.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
|
||||
if (stirlingFileStubs.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${stirlingFileStubs.length} files`);
|
||||
}
|
||||
|
||||
return addedFiles;
|
||||
@ -328,7 +328,7 @@ export async function addFiles(
|
||||
async function processFilesIntoRecords(
|
||||
files: File[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): Promise<Array<{ record: FileRecord; file: File; fileId: FileId; thumbnail?: string }>> {
|
||||
): Promise<Array<{ record: StirlingFileStub; file: File; fileId: FileId; thumbnail?: string }>> {
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const fileId = createFileId();
|
||||
@ -347,7 +347,7 @@ async function processFilesIntoRecords(
|
||||
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
|
||||
}
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
}
|
||||
@ -365,10 +365,10 @@ async function processFilesIntoRecords(
|
||||
* Helper function to persist files to IndexedDB
|
||||
*/
|
||||
async function persistFilesToIndexedDB(
|
||||
fileRecords: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
|
||||
stirlingFileStubs: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
|
||||
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
|
||||
): Promise<void> {
|
||||
await Promise.all(fileRecords.map(async ({ file, fileId, thumbnail }) => {
|
||||
await Promise.all(stirlingFileStubs.map(async ({ file, fileId, thumbnail }) => {
|
||||
try {
|
||||
await indexedDB.saveFile(file, fileId, thumbnail);
|
||||
} catch (error) {
|
||||
@ -383,7 +383,6 @@ async function persistFilesToIndexedDB(
|
||||
export async function consumeFiles(
|
||||
inputFileIds: FileId[],
|
||||
outputFiles: File[],
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null
|
||||
@ -391,11 +390,11 @@ export async function consumeFiles(
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||
|
||||
// Process output files with thumbnails and metadata
|
||||
const outputFileRecords = await processFilesIntoRecords(outputFiles, filesRef);
|
||||
const outputStirlingFileStubs = await processFilesIntoRecords(outputFiles, filesRef);
|
||||
|
||||
// Persist output files to IndexedDB if available
|
||||
if (indexedDB) {
|
||||
await persistFilesToIndexedDB(outputFileRecords, indexedDB);
|
||||
await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB);
|
||||
}
|
||||
|
||||
// Dispatch the consume action
|
||||
@ -403,21 +402,21 @@ export async function consumeFiles(
|
||||
type: 'CONSUME_FILES',
|
||||
payload: {
|
||||
inputFileIds,
|
||||
outputFileRecords: outputFileRecords.map(({ record }) => record)
|
||||
outputStirlingFileStubs: outputStirlingFileStubs.map(({ record }) => record)
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputStirlingFileStubs.length} outputs`);
|
||||
|
||||
// Return the output file IDs for undo tracking
|
||||
return outputFileRecords.map(({ fileId }) => fileId);
|
||||
return outputStirlingFileStubs.map(({ fileId }) => fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to restore files to filesRef and manage IndexedDB cleanup
|
||||
*/
|
||||
async function restoreFilesAndCleanup(
|
||||
filesToRestore: Array<{ file: File; record: FileRecord }>,
|
||||
filesToRestore: Array<{ file: File; record: StirlingFileStub }>,
|
||||
fileIdsToRemove: FileId[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||
@ -440,7 +439,7 @@ async function restoreFilesAndCleanup(
|
||||
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Restore the file to filesRef
|
||||
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
|
||||
filesRef.current.set(record.id, file);
|
||||
@ -455,7 +454,7 @@ async function restoreFilesAndCleanup(
|
||||
throw error; // Re-throw to trigger rollback
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Execute all IndexedDB operations
|
||||
await Promise.all(indexedDBPromises);
|
||||
}
|
||||
@ -466,28 +465,28 @@ async function restoreFilesAndCleanup(
|
||||
*/
|
||||
export async function undoConsumeFiles(
|
||||
inputFiles: File[],
|
||||
inputFileRecords: FileRecord[],
|
||||
inputStirlingFileStubs: StirlingFileStub[],
|
||||
outputFileIds: FileId[],
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||
): Promise<void> {
|
||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputFileRecords.length} input files, removing ${outputFileIds.length} output files`);
|
||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputStirlingFileStubs.length} input files, removing ${outputFileIds.length} output files`);
|
||||
|
||||
// Validate inputs
|
||||
if (inputFiles.length !== inputFileRecords.length) {
|
||||
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputFileRecords.length})`);
|
||||
if (inputFiles.length !== inputStirlingFileStubs.length) {
|
||||
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputStirlingFileStubs.length})`);
|
||||
}
|
||||
|
||||
// Create a backup of current filesRef state for rollback
|
||||
const backupFilesRef = new Map(filesRef.current);
|
||||
|
||||
|
||||
try {
|
||||
// Prepare files to restore
|
||||
const filesToRestore = inputFiles.map((file, index) => ({
|
||||
file,
|
||||
record: inputFileRecords[index]
|
||||
record: inputStirlingFileStubs[index]
|
||||
}));
|
||||
|
||||
// Restore input files and clean up output files
|
||||
@ -502,13 +501,13 @@ export async function undoConsumeFiles(
|
||||
dispatch({
|
||||
type: 'UNDO_CONSUME_FILES',
|
||||
payload: {
|
||||
inputFileRecords,
|
||||
inputStirlingFileStubs,
|
||||
outputFileIds
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputFileRecords.length} inputs, removed ${outputFileIds.length} outputs`);
|
||||
|
||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputStirlingFileStubs.length} inputs, removed ${outputFileIds.length} outputs`);
|
||||
|
||||
} catch (error) {
|
||||
// Rollback filesRef to previous state
|
||||
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
FileContextStateValue,
|
||||
FileContextActionsValue
|
||||
} from './contexts';
|
||||
import { FileRecord } from '../../types/fileContext';
|
||||
import { StirlingFileStub, StirlingFile } from '../../types/fileContext';
|
||||
import { FileId } from '../../types/file';
|
||||
|
||||
/**
|
||||
@ -38,13 +38,13 @@ export function useFileActions(): FileContextActionsValue {
|
||||
/**
|
||||
* Hook for current/primary file (first in list)
|
||||
*/
|
||||
export function useCurrentFile(): { file?: File; record?: FileRecord } {
|
||||
export function useCurrentFile(): { file?: File; record?: StirlingFileStub } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
const primaryFileId = state.files.ids[0];
|
||||
return useMemo(() => ({
|
||||
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
|
||||
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined
|
||||
record: primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : undefined
|
||||
}), [primaryFileId, selectors]);
|
||||
}
|
||||
|
||||
@ -87,7 +87,7 @@ export function useFileManagement() {
|
||||
addFiles: actions.addFiles,
|
||||
removeFiles: actions.removeFiles,
|
||||
clearAllFiles: actions.clearAllFiles,
|
||||
updateFileRecord: actions.updateFileRecord,
|
||||
updateStirlingFileStub: actions.updateStirlingFileStub,
|
||||
reorderFiles: actions.reorderFiles
|
||||
}), [actions]);
|
||||
}
|
||||
@ -111,24 +111,24 @@ export function useFileUI() {
|
||||
/**
|
||||
* Hook for specific file by ID (optimized for individual file access)
|
||||
*/
|
||||
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
|
||||
export function useStirlingFileStub(fileId: FileId): { file?: File; record?: StirlingFileStub } {
|
||||
const { selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
file: selectors.getFile(fileId),
|
||||
record: selectors.getFileRecord(fileId)
|
||||
record: selectors.getStirlingFileStub(fileId)
|
||||
}), [fileId, selectors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for all files (use sparingly - causes re-renders on file list changes)
|
||||
*/
|
||||
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||
export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
files: selectors.getFiles(),
|
||||
records: selectors.getFileRecords(),
|
||||
records: selectors.getStirlingFileStubs(),
|
||||
fileIds: state.files.ids
|
||||
}), [state.files.ids, selectors]);
|
||||
}
|
||||
@ -136,12 +136,12 @@ export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds:
|
||||
/**
|
||||
* Hook for selected files (optimized for selection-based UI)
|
||||
*/
|
||||
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||
export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
files: selectors.getSelectedFiles(),
|
||||
records: selectors.getSelectedFileRecords(),
|
||||
records: selectors.getSelectedStirlingFileStubs(),
|
||||
fileIds: state.ui.selectedFileIds
|
||||
}), [state.ui.selectedFileIds, selectors]);
|
||||
}
|
||||
|
@ -4,9 +4,11 @@
|
||||
|
||||
import { FileId } from '../../types/file';
|
||||
import {
|
||||
FileRecord,
|
||||
StirlingFileStub,
|
||||
FileContextState,
|
||||
FileContextSelectors
|
||||
FileContextSelectors,
|
||||
StirlingFile,
|
||||
createStirlingFile
|
||||
} from '../../types/fileContext';
|
||||
|
||||
/**
|
||||
@ -17,16 +19,24 @@ export function createFileSelectors(
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): FileContextSelectors {
|
||||
return {
|
||||
getFile: (id: FileId) => filesRef.current.get(id),
|
||||
getFile: (id: FileId) => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createStirlingFile(file, id) : undefined;
|
||||
},
|
||||
|
||||
getFiles: (ids?: FileId[]) => {
|
||||
const currentIds = ids || stateRef.current.files.ids;
|
||||
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[];
|
||||
return currentIds
|
||||
.map(id => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createStirlingFile(file, id) : undefined;
|
||||
})
|
||||
.filter(Boolean) as StirlingFile[];
|
||||
},
|
||||
|
||||
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
|
||||
getStirlingFileStub: (id: FileId) => stateRef.current.files.byId[id],
|
||||
|
||||
getFileRecords: (ids?: FileId[]) => {
|
||||
getStirlingFileStubs: (ids?: FileId[]) => {
|
||||
const currentIds = ids || stateRef.current.files.ids;
|
||||
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
|
||||
},
|
||||
@ -35,11 +45,14 @@ export function createFileSelectors(
|
||||
|
||||
getSelectedFiles: () => {
|
||||
return stateRef.current.ui.selectedFileIds
|
||||
.map(id => filesRef.current.get(id))
|
||||
.filter(Boolean) as File[];
|
||||
.map(id => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createStirlingFile(file, id) : undefined;
|
||||
})
|
||||
.filter(Boolean) as StirlingFile[];
|
||||
},
|
||||
|
||||
getSelectedFileRecords: () => {
|
||||
getSelectedStirlingFileStubs: () => {
|
||||
return stateRef.current.ui.selectedFileIds
|
||||
.map(id => stateRef.current.files.byId[id])
|
||||
.filter(Boolean);
|
||||
@ -52,26 +65,21 @@ export function createFileSelectors(
|
||||
|
||||
getPinnedFiles: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles)
|
||||
.map(id => filesRef.current.get(id))
|
||||
.filter(Boolean) as File[];
|
||||
.map(id => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createStirlingFile(file, id) : undefined;
|
||||
})
|
||||
.filter(Boolean) as StirlingFile[];
|
||||
},
|
||||
|
||||
getPinnedFileRecords: () => {
|
||||
getPinnedStirlingFileStubs: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles)
|
||||
.map(id => stateRef.current.files.byId[id])
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
isFilePinned: (file: File) => {
|
||||
// Find FileId by matching File object properties
|
||||
const fileId = (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => {
|
||||
const storedFile = filesRef.current.get(id);
|
||||
return storedFile &&
|
||||
storedFile.name === file.name &&
|
||||
storedFile.size === file.size &&
|
||||
storedFile.lastModified === file.lastModified;
|
||||
});
|
||||
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
|
||||
isFilePinned: (file: StirlingFile) => {
|
||||
return stateRef.current.pinnedFiles.has(file.fileId);
|
||||
},
|
||||
|
||||
// Stable signature for effects - prevents unnecessary re-renders
|
||||
@ -90,9 +98,9 @@ export function createFileSelectors(
|
||||
/**
|
||||
* Helper for building quickKey sets for deduplication
|
||||
*/
|
||||
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> {
|
||||
export function buildQuickKeySet(stirlingFileStubs: Record<FileId, StirlingFileStub>): Set<string> {
|
||||
const quickKeys = new Set<string>();
|
||||
Object.values(fileRecords).forEach(record => {
|
||||
Object.values(stirlingFileStubs).forEach(record => {
|
||||
if (record.quickKey) {
|
||||
quickKeys.add(record.quickKey);
|
||||
}
|
||||
@ -119,7 +127,7 @@ export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; siz
|
||||
export function getPrimaryFile(
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): { file?: File; record?: FileRecord } {
|
||||
): { file?: File; record?: StirlingFileStub } {
|
||||
const primaryFileId = stateRef.current.files.ids[0];
|
||||
if (!primaryFileId) return {};
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { FileId } from '../../types/file';
|
||||
import { FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
|
||||
import { FileContextAction, StirlingFileStub, ProcessedFilePage } from '../../types/fileContext';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
@ -166,7 +166,7 @@ export class FileLifecycleManager {
|
||||
/**
|
||||
* Update file record with race condition guards
|
||||
*/
|
||||
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => {
|
||||
updateStirlingFileStub = (fileId: FileId, updates: Partial<StirlingFileStub>, stateRef?: React.MutableRefObject<any>): void => {
|
||||
// Guard against updating removed files (race condition protection)
|
||||
if (!this.filesRef.current.has(fileId)) {
|
||||
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
|
||||
|
@ -347,9 +347,9 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
|
||||
const malformedFiles: Array<{name: string}> = [
|
||||
{ name: 'valid.pdf' },
|
||||
// @ts-ignore - Testing runtime resilience
|
||||
// @ts-expect-error - Testing runtime resilience
|
||||
{ name: null },
|
||||
// @ts-ignore
|
||||
// @ts-expect-error - Testing runtime resilience
|
||||
{ name: undefined }
|
||||
];
|
||||
|
||||
|
@ -4,10 +4,11 @@ import { useEndpointEnabled } from '../../useEndpointConfig';
|
||||
import { BaseToolProps } from '../../../types/tool';
|
||||
import { ToolOperationHook } from './useToolOperation';
|
||||
import { BaseParametersHook } from './useBaseParameters';
|
||||
import { StirlingFile } from '../../../types/fileContext';
|
||||
|
||||
interface BaseToolReturn<TParams> {
|
||||
// File management
|
||||
selectedFiles: File[];
|
||||
selectedFiles: StirlingFile[];
|
||||
|
||||
// Tool-specific hooks
|
||||
params: BaseParametersHook<TParams>;
|
||||
|
@ -6,10 +6,8 @@ import { useToolState, type ProcessingProgress } from './useToolState';
|
||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||
import { useToolResources } from './useToolResources';
|
||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||
import { createOperation } from '../../../utils/toolOperationTracker';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext';
|
||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { FileId } from '../../../types/file';
|
||||
import { FileRecord } from '../../../types/fileContext';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { ProcessingProgress, ResponseHandler };
|
||||
@ -104,7 +102,7 @@ export interface ToolOperationHook<TParams = void> {
|
||||
progress: ProcessingProgress | null;
|
||||
|
||||
// Actions
|
||||
executeOperation: (params: TParams, selectedFiles: File[]) => Promise<void>;
|
||||
executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise<void>;
|
||||
resetResults: () => void;
|
||||
clearError: () => void;
|
||||
cancelOperation: () => void;
|
||||
@ -130,7 +128,7 @@ export const useToolOperation = <TParams>(
|
||||
config: ToolOperationConfig<TParams>
|
||||
): ToolOperationHook<TParams> => {
|
||||
const { t } = useTranslation();
|
||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext();
|
||||
const { addFiles, consumeFiles, undoConsumeFiles, actions: fileActions, selectors } = useFileContext();
|
||||
|
||||
// Composed hooks
|
||||
const { state, actions } = useToolState();
|
||||
@ -140,13 +138,13 @@ export const useToolOperation = <TParams>(
|
||||
// Track last operation for undo functionality
|
||||
const lastOperationRef = useRef<{
|
||||
inputFiles: File[];
|
||||
inputFileRecords: FileRecord[];
|
||||
inputStirlingFileStubs: StirlingFileStub[];
|
||||
outputFileIds: FileId[];
|
||||
} | null>(null);
|
||||
|
||||
const executeOperation = useCallback(async (
|
||||
params: TParams,
|
||||
selectedFiles: File[]
|
||||
selectedFiles: StirlingFile[]
|
||||
): Promise<void> => {
|
||||
// Validation
|
||||
if (selectedFiles.length === 0) {
|
||||
@ -160,9 +158,6 @@ export const useToolOperation = <TParams>(
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup operation tracking
|
||||
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
|
||||
recordOperation(fileId, operation);
|
||||
|
||||
// Reset state
|
||||
actions.setLoading(true);
|
||||
@ -173,8 +168,11 @@ export const useToolOperation = <TParams>(
|
||||
try {
|
||||
let processedFiles: File[];
|
||||
|
||||
// Convert StirlingFile to regular File objects for API processing
|
||||
const validRegularFiles = extractFiles(validFiles);
|
||||
|
||||
switch (config.toolType) {
|
||||
case ToolType.singleFile:
|
||||
case ToolType.singleFile: {
|
||||
// Individual file processing - separate API call per file
|
||||
const apiCallsConfig: ApiCallsConfig<TParams> = {
|
||||
endpoint: config.endpoint,
|
||||
@ -184,17 +182,18 @@ export const useToolOperation = <TParams>(
|
||||
};
|
||||
processedFiles = await processFiles(
|
||||
params,
|
||||
validFiles,
|
||||
validRegularFiles,
|
||||
apiCallsConfig,
|
||||
actions.setProgress,
|
||||
actions.setStatus
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case ToolType.multiFile:
|
||||
case ToolType.multiFile: {
|
||||
// Multi-file processing - single API call with all files
|
||||
actions.setStatus('Processing files...');
|
||||
const formData = config.buildFormData(params, validFiles);
|
||||
const formData = config.buildFormData(params, validRegularFiles);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
@ -202,11 +201,11 @@ export const useToolOperation = <TParams>(
|
||||
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
||||
if (config.responseHandler) {
|
||||
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
||||
processedFiles = await config.responseHandler(response.data, validFiles);
|
||||
processedFiles = await config.responseHandler(response.data, validRegularFiles);
|
||||
} else if (response.data.type === 'application/pdf' ||
|
||||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
||||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
||||
// Single PDF response (e.g. split with merge option) - use original filename
|
||||
const originalFileName = validFiles[0]?.name || 'document.pdf';
|
||||
const originalFileName = validRegularFiles[0]?.name || 'document.pdf';
|
||||
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
|
||||
processedFiles = [singleFile];
|
||||
} else {
|
||||
@ -219,10 +218,11 @@ export const useToolOperation = <TParams>(
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ToolType.custom:
|
||||
actions.setStatus('Processing files...');
|
||||
processedFiles = await config.customProcessor(params, validFiles);
|
||||
processedFiles = await config.customProcessor(params, validRegularFiles);
|
||||
break;
|
||||
}
|
||||
|
||||
@ -242,46 +242,40 @@ export const useToolOperation = <TParams>(
|
||||
|
||||
// Replace input files with processed files (consumeFiles handles pinning)
|
||||
const inputFileIds: FileId[] = [];
|
||||
const inputFileRecords: FileRecord[] = [];
|
||||
const inputStirlingFileStubs: StirlingFileStub[] = [];
|
||||
|
||||
// Build parallel arrays of IDs and records for undo tracking
|
||||
for (const file of validFiles) {
|
||||
const fileId = findFileId(file);
|
||||
if (fileId) {
|
||||
const record = selectors.getFileRecord(fileId);
|
||||
if (record) {
|
||||
inputFileIds.push(fileId);
|
||||
inputFileRecords.push(record);
|
||||
} else {
|
||||
console.warn(`No file record found for file: ${file.name}`);
|
||||
}
|
||||
const fileId = file.fileId;
|
||||
const record = selectors.getStirlingFileStub(fileId);
|
||||
if (record) {
|
||||
inputFileIds.push(fileId);
|
||||
inputStirlingFileStubs.push(record);
|
||||
} else {
|
||||
console.warn(`No file ID found for file: ${file.name}`);
|
||||
console.warn(`No file stub found for file: ${file.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const outputFileIds = await consumeFiles(inputFileIds, processedFiles);
|
||||
|
||||
|
||||
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||
lastOperationRef.current = {
|
||||
inputFiles: validFiles, // Keep original File objects for undo
|
||||
inputFileRecords: inputFileRecords.map(record => ({ ...record })), // Deep copy to avoid reference issues
|
||||
inputFiles: extractFiles(validFiles), // Convert to File objects for undo
|
||||
inputStirlingFileStubs: inputStirlingFileStubs.map(record => ({ ...record })), // Deep copy to avoid reference issues
|
||||
outputFileIds
|
||||
};
|
||||
|
||||
markOperationApplied(fileId, operationId);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||
actions.setError(errorMessage);
|
||||
actions.setStatus('');
|
||||
markOperationFailed(fileId, operationId, errorMessage);
|
||||
} finally {
|
||||
actions.setLoading(false);
|
||||
actions.setProgress(null);
|
||||
}
|
||||
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||
|
||||
const cancelOperation = useCallback(() => {
|
||||
cancelApiCalls();
|
||||
@ -310,10 +304,10 @@ export const useToolOperation = <TParams>(
|
||||
return;
|
||||
}
|
||||
|
||||
const { inputFiles, inputFileRecords, outputFileIds } = lastOperationRef.current;
|
||||
const { inputFiles, inputStirlingFileStubs, outputFileIds } = lastOperationRef.current;
|
||||
|
||||
// Validate that we have data to undo
|
||||
if (inputFiles.length === 0 || inputFileRecords.length === 0) {
|
||||
if (inputFiles.length === 0 || inputStirlingFileStubs.length === 0) {
|
||||
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
|
||||
return;
|
||||
}
|
||||
@ -325,18 +319,19 @@ export const useToolOperation = <TParams>(
|
||||
|
||||
try {
|
||||
// Undo the consume operation
|
||||
await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds);
|
||||
await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
|
||||
|
||||
|
||||
// Clear results and operation tracking
|
||||
resetResults();
|
||||
lastOperationRef.current = null;
|
||||
|
||||
|
||||
// Show success message
|
||||
actions.setStatus(t('undoSuccess', 'Operation undone successfully'));
|
||||
|
||||
|
||||
} catch (error: any) {
|
||||
let errorMessage = extractErrorMessage(error);
|
||||
|
||||
|
||||
// Provide more specific error messages based on error type
|
||||
if (error.message?.includes('Mismatch between input files')) {
|
||||
errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted');
|
||||
@ -345,9 +340,9 @@ export const useToolOperation = <TParams>(
|
||||
} else if (error.name === 'QuotaExceededError') {
|
||||
errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space');
|
||||
}
|
||||
|
||||
|
||||
actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`);
|
||||
|
||||
|
||||
// Don't clear the operation data if undo failed - user might want to try again
|
||||
}
|
||||
}, [undoConsumeFiles, resetResults, actions, t]);
|
||||
|
@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
|
||||
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
import { FileId } from '../types/file';
|
||||
import { FileId } from '../types/fileContext';
|
||||
|
||||
export const useFileManager = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { isFileObject } from '../types/fileContext';
|
||||
|
||||
/**
|
||||
* Hook to convert a File object to { file: File; url: string } format
|
||||
@ -8,8 +9,8 @@ export function useFileWithUrl(file: File | Blob | null): { file: File | Blob; u
|
||||
return useMemo(() => {
|
||||
if (!file) return null;
|
||||
|
||||
// Validate that file is a proper File or Blob object
|
||||
if (!(file instanceof File) && !(file instanceof Blob)) {
|
||||
// Validate that file is a proper File, StirlingFile, or Blob object
|
||||
if (!isFileObject(file) && !(file instanceof Blob)) {
|
||||
console.warn('useFileWithUrl: Expected File or Blob, got:', file);
|
||||
return null;
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
import { FileMetadata } from "../types/file";
|
||||
import { useIndexedDB } from "../contexts/IndexedDBContext";
|
||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||
import { FileId } from "../types/fileContext";
|
||||
|
||||
/**
|
||||
* Calculate optimal scale for thumbnail generation
|
||||
@ -53,7 +54,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
||||
|
||||
// Try to load file from IndexedDB using new context
|
||||
if (file.id && indexedDB) {
|
||||
const loadedFile = await indexedDB.loadFile(file.id);
|
||||
const loadedFile = await indexedDB.loadFile(file.id as FileId);
|
||||
if (!loadedFile) {
|
||||
throw new Error('File not found in IndexedDB');
|
||||
}
|
||||
@ -70,7 +71,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
||||
// Save thumbnail to IndexedDB for persistence
|
||||
if (file.id && indexedDB && thumbnail) {
|
||||
try {
|
||||
await indexedDB.updateThumbnail(file.id, thumbnail);
|
||||
await indexedDB.updateThumbnail(file.id as FileId, thumbnail);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save thumbnail to IndexedDB:', error);
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||
import { createQuickKey } from '../types/fileContext';
|
||||
|
||||
export function usePDFProcessor() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -75,7 +76,7 @@ export function usePDFProcessor() {
|
||||
// Create pages without thumbnails initially - load them lazily
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
originalPageNumber: i,
|
||||
thumbnail: null, // Will be loaded lazily
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||
import { StirlingFile } from '../types/fileContext';
|
||||
|
||||
export interface PdfSignatureDetectionResult {
|
||||
hasDigitalSignatures: boolean;
|
||||
isChecking: boolean;
|
||||
}
|
||||
|
||||
export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionResult => {
|
||||
export const usePdfSignatureDetection = (files: StirlingFile[]): PdfSignatureDetectionResult => {
|
||||
const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
||||
import { createQuickKey } from '../types/fileContext';
|
||||
import { FileId } from '../types/file';
|
||||
|
||||
// Request queue to handle concurrent thumbnail requests
|
||||
@ -71,8 +72,8 @@ async function processRequestQueue() {
|
||||
|
||||
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
|
||||
|
||||
// Use file name as fileId for PDF document caching
|
||||
const fileId = file.name + '_' + file.size + '_' + file.lastModified as FileId;
|
||||
// Use quickKey for PDF document caching (same metadata, consistent format)
|
||||
const fileId = createQuickKey(file) as FileId;
|
||||
|
||||
const results = await thumbnailGenerationService.generateThumbnails(
|
||||
fileId,
|
||||
|
@ -35,8 +35,11 @@ function updatePosthogConsent(){
|
||||
return;
|
||||
}
|
||||
const optIn = (window.CookieConsent as any).acceptedCategory('analytics');
|
||||
optIn?
|
||||
posthog.opt_in_capturing() : posthog.opt_out_capturing();
|
||||
if (optIn) {
|
||||
posthog.opt_in_capturing();
|
||||
} else {
|
||||
posthog.opt_out_capturing();
|
||||
}
|
||||
|
||||
console.log("Updated analytics consent: ", optIn? "opted in" : "opted out");
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { FileHasher } from '../utils/fileHash';
|
||||
import { FileAnalyzer } from './fileAnalyzer';
|
||||
import { ProcessingErrorHandler } from './processingErrorHandler';
|
||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||
import { createQuickKey } from '../types/fileContext';
|
||||
|
||||
export class EnhancedPDFProcessingService {
|
||||
private static instance: EnhancedPDFProcessingService;
|
||||
@ -182,7 +183,7 @@ export class EnhancedPDFProcessingService {
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
|
||||
|
||||
try {
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
@ -201,7 +202,7 @@ export class EnhancedPDFProcessingService {
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
@ -251,7 +252,7 @@ export class EnhancedPDFProcessingService {
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
@ -266,7 +267,7 @@ export class EnhancedPDFProcessingService {
|
||||
// Create placeholder pages for remaining pages
|
||||
for (let i = priorityCount + 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null, // Will be loaded lazily
|
||||
rotation: 0,
|
||||
@ -313,7 +314,7 @@ export class EnhancedPDFProcessingService {
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
@ -334,7 +335,7 @@ export class EnhancedPDFProcessingService {
|
||||
// Create placeholders for remaining pages
|
||||
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
@ -368,7 +369,7 @@ export class EnhancedPDFProcessingService {
|
||||
const pages: PDFPage[] = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
@ -459,11 +460,12 @@ export class EnhancedPDFProcessingService {
|
||||
case 'failed':
|
||||
this.metrics.failedFiles++;
|
||||
break;
|
||||
case 'cacheHit':
|
||||
case 'cacheHit': {
|
||||
// Update cache hit rate
|
||||
const totalAttempts = this.metrics.totalFiles + 1;
|
||||
this.metrics.cacheHitRate = (this.metrics.cacheHitRate * this.metrics.totalFiles + 1) / totalAttempts;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -519,10 +521,7 @@ export class EnhancedPDFProcessingService {
|
||||
this.notifyListeners();
|
||||
|
||||
// Force memory cleanup hint
|
||||
if (typeof window !== 'undefined' && window.gc) {
|
||||
let gc = window.gc;
|
||||
setTimeout(() => gc(), 100);
|
||||
}
|
||||
setTimeout(() => window.gc?.(), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -148,15 +148,17 @@ export class FileAnalyzer {
|
||||
case 'immediate_full':
|
||||
return pageCount * baseTime;
|
||||
|
||||
case 'priority_pages':
|
||||
case 'priority_pages': {
|
||||
// Estimate time for priority pages (first 10)
|
||||
const priorityPages = Math.min(pageCount, 10);
|
||||
return priorityPages * baseTime;
|
||||
}
|
||||
|
||||
case 'progressive_chunked':
|
||||
case 'progressive_chunked': {
|
||||
// Estimate time for first chunk (20 pages)
|
||||
const firstChunk = Math.min(pageCount, 20);
|
||||
return firstChunk * baseTime;
|
||||
}
|
||||
|
||||
default:
|
||||
return pageCount * baseTime;
|
||||
|
@ -73,7 +73,7 @@ class IndexedDBManager {
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
console.log(`Successfully opened ${config.name}`);
|
||||
|
||||
|
||||
// Set up close handler to clean up our references
|
||||
db.onclose = () => {
|
||||
console.log(`Database ${config.name} closed`);
|
||||
@ -87,13 +87,11 @@ class IndexedDBManager {
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = request.result;
|
||||
const oldVersion = event.oldVersion;
|
||||
|
||||
|
||||
console.log(`Upgrading ${config.name} from v${oldVersion} to v${config.version}`);
|
||||
|
||||
// Create or update object stores
|
||||
config.stores.forEach(storeConfig => {
|
||||
let store: IDBObjectStore;
|
||||
|
||||
if (db.objectStoreNames.contains(storeConfig.name)) {
|
||||
// Store exists - for now, just continue (could add migration logic here)
|
||||
console.log(`Object store '${storeConfig.name}' already exists`);
|
||||
@ -109,7 +107,7 @@ class IndexedDBManager {
|
||||
options.autoIncrement = storeConfig.autoIncrement;
|
||||
}
|
||||
|
||||
store = db.createObjectStore(storeConfig.name, options);
|
||||
const store = db.createObjectStore(storeConfig.name, options);
|
||||
console.log(`Created object store '${storeConfig.name}'`);
|
||||
|
||||
// Create indexes
|
||||
@ -168,7 +166,7 @@ class IndexedDBManager {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const deleteRequest = indexedDB.deleteDatabase(name);
|
||||
|
||||
|
||||
deleteRequest.onerror = () => reject(deleteRequest.error);
|
||||
deleteRequest.onsuccess = () => {
|
||||
console.log(`Deleted database: ${name}`);
|
||||
@ -224,4 +222,4 @@ export const DATABASE_CONFIGS = {
|
||||
} as DatabaseConfig
|
||||
} as const;
|
||||
|
||||
export const indexedDBManager = IndexedDBManager.getInstance();
|
||||
export const indexedDBManager = IndexedDBManager.getInstance();
|
||||
|
@ -130,7 +130,7 @@ export class PDFExportService {
|
||||
newDoc.setModificationDate(new Date());
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
return new Blob([pdfBytes], { type: 'application/pdf' });
|
||||
return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
/**
|
||||
@ -176,7 +176,7 @@ export class PDFExportService {
|
||||
newDoc.setModificationDate(new Date());
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
return new Blob([pdfBytes], { type: 'application/pdf' });
|
||||
return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing';
|
||||
import { ProcessingCache } from './processingCache';
|
||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||
import { createQuickKey } from '../types/fileContext';
|
||||
|
||||
export class PDFProcessingService {
|
||||
private static instance: PDFProcessingService;
|
||||
@ -113,7 +114,7 @@ export class PDFProcessingService {
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
|
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* PDF.js Worker Manager - Centralized worker lifecycle management
|
||||
*
|
||||
*
|
||||
* Prevents infinite worker creation by managing PDF.js workers globally
|
||||
* and ensuring proper cleanup when operations complete.
|
||||
*/
|
||||
@ -86,7 +86,7 @@ class PDFWorkerManager {
|
||||
const pdf = await loadingTask.promise;
|
||||
this.activeDocuments.add(pdf);
|
||||
this.workerCount++;
|
||||
|
||||
|
||||
return pdf;
|
||||
} catch (error) {
|
||||
// If document creation fails, make sure to clean up the loading task
|
||||
@ -94,6 +94,7 @@ class PDFWorkerManager {
|
||||
try {
|
||||
loadingTask.destroy();
|
||||
} catch (destroyError) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
@ -125,7 +126,7 @@ class PDFWorkerManager {
|
||||
documentsToDestroy.forEach(pdf => {
|
||||
this.destroyDocument(pdf);
|
||||
});
|
||||
|
||||
|
||||
this.activeDocuments.clear();
|
||||
this.workerCount = 0;
|
||||
}
|
||||
@ -166,9 +167,10 @@ class PDFWorkerManager {
|
||||
try {
|
||||
pdf.destroy();
|
||||
} catch (error) {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.activeDocuments.clear();
|
||||
this.workerCount = 0;
|
||||
}
|
||||
@ -182,4 +184,4 @@ class PDFWorkerManager {
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pdfWorkerManager = PDFWorkerManager.getInstance();
|
||||
export const pdfWorkerManager = PDFWorkerManager.getInstance();
|
||||
|
@ -18,6 +18,8 @@ import { FileContextProvider } from '../../contexts/FileContext';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../i18n/config';
|
||||
import axios from 'axios';
|
||||
import { createTestStirlingFile } from '../utils/testFileHelpers';
|
||||
import { StirlingFile } from '../../types/fileContext';
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios');
|
||||
@ -55,9 +57,9 @@ const createTestFile = (name: string, content: string, type: string): File => {
|
||||
return new File([content], name, { type });
|
||||
};
|
||||
|
||||
const createPDFFile = (): File => {
|
||||
const createPDFFile = (): StirlingFile => {
|
||||
const pdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\ntrailer\n<<\n/Size 2\n/Root 1 0 R\n>>\nstartxref\n0\n%%EOF';
|
||||
return createTestFile('test.pdf', pdfContent, 'application/pdf');
|
||||
return createTestStirlingFile('test.pdf', pdfContent, 'application/pdf');
|
||||
};
|
||||
|
||||
// Test wrapper component
|
||||
@ -162,7 +164,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const testFile = createTestFile('invalid.txt', 'not a pdf', 'text/plain');
|
||||
const testFile = createTestStirlingFile('invalid.txt', 'not a pdf', 'text/plain');
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
@ -426,7 +428,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
const files = [
|
||||
createPDFFile(),
|
||||
createTestFile('test2.pdf', '%PDF-1.4...', 'application/pdf')
|
||||
createTestStirlingFile('test2.pdf', '%PDF-1.4...', 'application/pdf')
|
||||
]
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
@ -527,7 +529,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const corruptedFile = createTestFile('corrupted.pdf', 'not-a-pdf', 'application/pdf');
|
||||
const corruptedFile = createTestStirlingFile('corrupted.pdf', 'not-a-pdf', 'application/pdf');
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
|
@ -14,6 +14,8 @@ import i18n from '../../i18n/config';
|
||||
import axios from 'axios';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
import { FIT_OPTIONS } from '../../constants/convertConstants';
|
||||
import { createTestStirlingFile, createTestFilesWithId } from '../utils/testFileHelpers';
|
||||
import { StirlingFile } from '../../types/fileContext';
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios');
|
||||
@ -81,7 +83,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Create mock DOCX file
|
||||
const docxFile = new File(['docx content'], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
|
||||
const docxFile = createTestStirlingFile('document.docx', 'docx content', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
|
||||
// Test auto-detection
|
||||
act(() => {
|
||||
@ -117,7 +119,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Create mock unknown file
|
||||
const unknownFile = new File(['unknown content'], 'document.xyz', { type: 'application/octet-stream' });
|
||||
const unknownFile = createTestStirlingFile('document.xyz', 'unknown content', 'application/octet-stream');
|
||||
|
||||
// Test auto-detection
|
||||
act(() => {
|
||||
@ -156,11 +158,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Create mock image files
|
||||
const imageFiles = [
|
||||
new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['png content'], 'photo2.png', { type: 'image/png' }),
|
||||
new File(['gif content'], 'photo3.gif', { type: 'image/gif' })
|
||||
];
|
||||
const imageFiles = createTestFilesWithId([
|
||||
{ name: 'photo1.jpg', content: 'jpg content', type: 'image/jpeg' },
|
||||
{ name: 'photo2.png', content: 'png content', type: 'image/png' },
|
||||
{ name: 'photo3.gif', content: 'gif content', type: 'image/gif' }
|
||||
]);
|
||||
|
||||
// Test smart detection for all images
|
||||
act(() => {
|
||||
@ -202,11 +204,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Create mixed file types
|
||||
const mixedFiles = [
|
||||
new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }),
|
||||
new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
||||
new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
|
||||
];
|
||||
const mixedFiles = createTestFilesWithId([
|
||||
{ name: 'document.pdf', content: 'pdf content', type: 'application/pdf' },
|
||||
{ name: 'spreadsheet.xlsx', content: 'docx content', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
||||
{ name: 'presentation.pptx', content: 'pptx content', type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }
|
||||
]);
|
||||
|
||||
// Test smart detection for mixed types
|
||||
act(() => {
|
||||
@ -243,10 +245,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Create mock web files
|
||||
const webFiles = [
|
||||
new File(['<html>content</html>'], 'page1.html', { type: 'text/html' }),
|
||||
new File(['zip content'], 'site.zip', { type: 'application/zip' })
|
||||
];
|
||||
const webFiles = createTestFilesWithId([
|
||||
{ name: 'page1.html', content: '<html>content</html>', type: 'text/html' },
|
||||
{ name: 'site.zip', content: 'zip content', type: 'application/zip' }
|
||||
]);
|
||||
|
||||
// Test smart detection for web files
|
||||
act(() => {
|
||||
@ -288,7 +290,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const htmlFile = new File(['<html>content</html>'], 'page.html', { type: 'text/html' });
|
||||
const htmlFile = createTestStirlingFile('page.html', '<html>content</html>', 'text/html');
|
||||
|
||||
// Set up HTML conversion parameters
|
||||
act(() => {
|
||||
@ -318,7 +320,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const emlFile = new File(['email content'], 'email.eml', { type: 'message/rfc822' });
|
||||
const emlFile = createTestStirlingFile('email.eml', 'email content', 'message/rfc822');
|
||||
|
||||
// Set up email conversion parameters
|
||||
act(() => {
|
||||
@ -355,7 +357,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const pdfFile = new File(['pdf content'], 'document.pdf', { type: 'application/pdf' });
|
||||
const pdfFile = createTestStirlingFile('document.pdf', 'pdf content', 'application/pdf');
|
||||
|
||||
// Set up PDF/A conversion parameters
|
||||
act(() => {
|
||||
@ -392,10 +394,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const imageFiles = [
|
||||
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
|
||||
];
|
||||
const imageFiles = createTestFilesWithId([
|
||||
{ name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
|
||||
{ name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
|
||||
]);
|
||||
|
||||
// Set up image conversion parameters
|
||||
act(() => {
|
||||
@ -432,10 +434,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const imageFiles = [
|
||||
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
|
||||
];
|
||||
const imageFiles = createTestFilesWithId([
|
||||
{ name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
|
||||
{ name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
|
||||
]);
|
||||
|
||||
// Set up for separate processing
|
||||
act(() => {
|
||||
@ -477,10 +479,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('File 2 failed'));
|
||||
|
||||
const mixedFiles = [
|
||||
new File(['file1'], 'doc1.txt', { type: 'text/plain' }),
|
||||
new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' })
|
||||
];
|
||||
const mixedFiles = createTestFilesWithId([
|
||||
{ name: 'doc1.txt', content: 'file1', type: 'text/plain' },
|
||||
{ name: 'doc2.xyz', content: 'file2', type: 'application/octet-stream' }
|
||||
]);
|
||||
|
||||
// Set up for separate processing (mixed smart detection)
|
||||
act(() => {
|
||||
|
28
frontend/src/tests/utils/testFileHelpers.ts
Normal file
28
frontend/src/tests/utils/testFileHelpers.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Test utilities for creating StirlingFile objects in tests
|
||||
*/
|
||||
|
||||
import { StirlingFile, createStirlingFile } from '../../types/fileContext';
|
||||
|
||||
/**
|
||||
* Create a StirlingFile object for testing purposes
|
||||
*/
|
||||
export function createTestStirlingFile(
|
||||
name: string,
|
||||
content: string = 'test content',
|
||||
type: string = 'application/pdf'
|
||||
): StirlingFile {
|
||||
const file = new File([content], name, { type });
|
||||
return createStirlingFile(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple StirlingFile objects for testing
|
||||
*/
|
||||
export function createTestFilesWithId(
|
||||
files: Array<{ name: string; content?: string; type?: string }>
|
||||
): StirlingFile[] {
|
||||
return files.map(({ name, content = 'test content', type = 'application/pdf' }) =>
|
||||
createTestStirlingFile(name, content, type)
|
||||
);
|
||||
}
|
@ -5,6 +5,9 @@
|
||||
import { PageOperation } from './pageEditor';
|
||||
import { FileId, FileMetadata } from './file';
|
||||
|
||||
// Re-export FileId for convenience
|
||||
export type { FileId };
|
||||
|
||||
export type ModeType =
|
||||
| 'viewer'
|
||||
| 'pageEditor'
|
||||
@ -41,25 +44,32 @@ export interface ProcessedFileMetadata {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface FileRecord {
|
||||
id: FileId;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
lastModified: number;
|
||||
quickKey?: string; // Fast deduplication key: name|size|lastModified
|
||||
thumbnailUrl?: string;
|
||||
blobUrl?: string;
|
||||
createdAt?: number;
|
||||
processedFile?: ProcessedFileMetadata;
|
||||
insertAfterPageId?: string; // Page ID after which this file should be inserted
|
||||
isPinned?: boolean;
|
||||
/**
|
||||
* StirlingFileStub - Metadata record for files in the active workbench session
|
||||
*
|
||||
* Contains UI display data and processing state. Actual File objects stored
|
||||
* separately in refs for memory efficiency. Supports multi-tool workflows
|
||||
* where files persist across tool operations.
|
||||
*/
|
||||
export interface StirlingFileStub {
|
||||
id: FileId; // UUID primary key for collision-free operations
|
||||
name: string; // Display name for UI
|
||||
size: number; // File size for progress indicators
|
||||
type: string; // MIME type for format validation
|
||||
lastModified: number; // Original timestamp for deduplication
|
||||
quickKey?: string; // Fast deduplication key: name|size|lastModified
|
||||
thumbnailUrl?: string; // Generated thumbnail blob URL for visual display
|
||||
blobUrl?: string; // File access blob URL for downloads/processing
|
||||
createdAt?: number; // When added to workbench for sorting
|
||||
processedFile?: ProcessedFileMetadata; // PDF page data and processing results
|
||||
insertAfterPageId?: string; // Page ID after which this file should be inserted
|
||||
isPinned?: boolean; // Protected from tool consumption (replace/remove)
|
||||
// Note: File object stored in provider ref, not in state
|
||||
}
|
||||
|
||||
export interface FileContextNormalizedFiles {
|
||||
ids: FileId[];
|
||||
byId: Record<FileId, FileRecord>;
|
||||
byId: Record<FileId, StirlingFileStub>;
|
||||
}
|
||||
|
||||
// Helper functions - UUID-based primary keys (zero collisions, synchronous)
|
||||
@ -82,9 +92,68 @@ export function createQuickKey(file: File): string {
|
||||
return `${file.name}|${file.size}|${file.lastModified}`;
|
||||
}
|
||||
|
||||
// Stirling PDF file with embedded UUID - replaces loose File + FileId parameter passing
|
||||
export interface StirlingFile extends File {
|
||||
readonly fileId: FileId;
|
||||
readonly quickKey: string; // Fast deduplication key: name|size|lastModified
|
||||
}
|
||||
|
||||
// Type guard to check if a File object has an embedded fileId
|
||||
export function isStirlingFile(file: File): file is StirlingFile {
|
||||
return 'fileId' in file && typeof (file as any).fileId === 'string' &&
|
||||
'quickKey' in file && typeof (file as any).quickKey === 'string';
|
||||
}
|
||||
|
||||
// Create a StirlingFile from a regular File object
|
||||
export function createStirlingFile(file: File, id?: FileId): StirlingFile {
|
||||
const fileId = id || createFileId();
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
// Use Object.defineProperty to add properties while preserving the original File object
|
||||
// This maintains proper method binding and avoids "Illegal invocation" errors
|
||||
Object.defineProperty(file, 'fileId', {
|
||||
value: fileId,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false
|
||||
});
|
||||
|
||||
Object.defineProperty(file, 'quickKey', {
|
||||
value: quickKey,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false
|
||||
});
|
||||
|
||||
return file as StirlingFile;
|
||||
}
|
||||
|
||||
// Extract FileIds from StirlingFile array
|
||||
export function extractFileIds(files: StirlingFile[]): FileId[] {
|
||||
return files.map(file => file.fileId);
|
||||
}
|
||||
|
||||
// Extract regular File objects from StirlingFile array
|
||||
export function extractFiles(files: StirlingFile[]): File[] {
|
||||
return files as File[];
|
||||
}
|
||||
|
||||
// Check if an object is a File or StirlingFile (replaces instanceof File checks)
|
||||
export function isFileObject(obj: any): obj is File | StirlingFile {
|
||||
return obj &&
|
||||
typeof obj.name === 'string' &&
|
||||
typeof obj.size === 'number' &&
|
||||
typeof obj.type === 'string' &&
|
||||
typeof obj.lastModified === 'number' &&
|
||||
typeof obj.arrayBuffer === 'function';
|
||||
}
|
||||
|
||||
|
||||
export function toFileRecord(file: File, id?: FileId): FileRecord {
|
||||
|
||||
export function toStirlingFileStub(
|
||||
file: File,
|
||||
id?: FileId
|
||||
): StirlingFileStub {
|
||||
const fileId = id || createFileId();
|
||||
return {
|
||||
id: fileId,
|
||||
@ -97,7 +166,7 @@ export function toFileRecord(file: File, id?: FileId): FileRecord {
|
||||
};
|
||||
}
|
||||
|
||||
export function revokeFileResources(record: FileRecord): void {
|
||||
export function revokeFileResources(record: StirlingFileStub): void {
|
||||
// Only revoke blob: URLs to prevent errors on other schemes
|
||||
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
|
||||
try {
|
||||
@ -171,7 +240,7 @@ export interface FileContextState {
|
||||
// Core file management - lightweight file IDs only
|
||||
files: {
|
||||
ids: FileId[];
|
||||
byId: Record<FileId, FileRecord>;
|
||||
byId: Record<FileId, StirlingFileStub>;
|
||||
};
|
||||
|
||||
// Pinned files - files that won't be consumed by tools
|
||||
@ -190,16 +259,16 @@ export interface FileContextState {
|
||||
// Action types for reducer pattern
|
||||
export type FileContextAction =
|
||||
// File management actions
|
||||
| { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } }
|
||||
| { type: 'ADD_FILES'; payload: { stirlingFileStubs: StirlingFileStub[] } }
|
||||
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
|
||||
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
|
||||
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<StirlingFileStub> } }
|
||||
| { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } }
|
||||
|
||||
// Pinned files actions
|
||||
| { type: 'PIN_FILE'; payload: { fileId: FileId } }
|
||||
| { type: 'UNPIN_FILE'; payload: { fileId: FileId } }
|
||||
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } }
|
||||
| { type: 'UNDO_CONSUME_FILES'; payload: { inputFileRecords: FileRecord[]; outputFileIds: FileId[] } }
|
||||
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputStirlingFileStubs: StirlingFileStub[] } }
|
||||
| { type: 'UNDO_CONSUME_FILES'; payload: { inputStirlingFileStubs: StirlingFileStub[]; outputFileIds: FileId[] } }
|
||||
|
||||
// UI actions
|
||||
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
|
||||
@ -215,22 +284,22 @@ export type FileContextAction =
|
||||
|
||||
export interface FileContextActions {
|
||||
// File management - lightweight actions only
|
||||
addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<File[]>;
|
||||
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>;
|
||||
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise<File[]>;
|
||||
addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<StirlingFile[]>;
|
||||
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<StirlingFile[]>;
|
||||
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise<StirlingFile[]>;
|
||||
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
|
||||
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
|
||||
updateStirlingFileStub: (id: FileId, updates: Partial<StirlingFileStub>) => void;
|
||||
reorderFiles: (orderedFileIds: FileId[]) => void;
|
||||
clearAllFiles: () => Promise<void>;
|
||||
clearAllData: () => Promise<void>;
|
||||
|
||||
// File pinning
|
||||
pinFile: (file: File) => void;
|
||||
unpinFile: (file: File) => void;
|
||||
// File pinning - accepts StirlingFile for safer type checking
|
||||
pinFile: (file: StirlingFile) => void;
|
||||
unpinFile: (file: StirlingFile) => void;
|
||||
|
||||
// File consumption (replace unpinned files with outputs)
|
||||
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileId[]>;
|
||||
undoConsumeFiles: (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]) => Promise<void>;
|
||||
undoConsumeFiles: (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]) => Promise<void>;
|
||||
// Selection management
|
||||
setSelectedFiles: (fileIds: FileId[]) => void;
|
||||
setSelectedPages: (pageNumbers: number[]) => void;
|
||||
@ -253,26 +322,17 @@ export interface FileContextActions {
|
||||
|
||||
// File selectors (separate from actions to avoid re-renders)
|
||||
export interface FileContextSelectors {
|
||||
// File access - no state dependency, uses ref
|
||||
getFile: (id: FileId) => File | undefined;
|
||||
getFiles: (ids?: FileId[]) => File[];
|
||||
|
||||
// Record access - uses normalized state
|
||||
getFileRecord: (id: FileId) => FileRecord | undefined;
|
||||
getFileRecords: (ids?: FileId[]) => FileRecord[];
|
||||
|
||||
// Derived selectors
|
||||
getFile: (id: FileId) => StirlingFile | undefined;
|
||||
getFiles: (ids?: FileId[]) => StirlingFile[];
|
||||
getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined;
|
||||
getStirlingFileStubs: (ids?: FileId[]) => StirlingFileStub[];
|
||||
getAllFileIds: () => FileId[];
|
||||
getSelectedFiles: () => File[];
|
||||
getSelectedFileRecords: () => FileRecord[];
|
||||
|
||||
// Pinned files selectors
|
||||
getSelectedFiles: () => StirlingFile[];
|
||||
getSelectedStirlingFileStubs: () => StirlingFileStub[];
|
||||
getPinnedFileIds: () => FileId[];
|
||||
getPinnedFiles: () => File[];
|
||||
getPinnedFileRecords: () => FileRecord[];
|
||||
isFilePinned: (file: File) => boolean;
|
||||
|
||||
// Stable signature for effect dependencies
|
||||
getPinnedFiles: () => StirlingFile[];
|
||||
getPinnedStirlingFileStubs: () => StirlingFileStub[];
|
||||
isFilePinned: (file: StirlingFile) => boolean;
|
||||
getFilesSignature: () => string;
|
||||
}
|
||||
|
||||
@ -293,6 +353,3 @@ export interface FileContextActionsValue {
|
||||
actions: FileContextActions;
|
||||
dispatch: (action: FileContextAction) => void;
|
||||
}
|
||||
|
||||
// TODO: URL parameter types will be redesigned for new routing system
|
||||
|
||||
|
49
frontend/src/types/fileIdSafety.d.ts
vendored
Normal file
49
frontend/src/types/fileIdSafety.d.ts
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Type safety declarations to prevent file.name/UUID confusion
|
||||
*/
|
||||
|
||||
import { FileId, StirlingFile, OperationType, FileOperation } from './fileContext';
|
||||
|
||||
declare global {
|
||||
namespace FileIdSafety {
|
||||
// Mark functions that should never accept file.name as parameters
|
||||
type SafeFileIdFunction<T extends (...args: any[]) => any> = T extends (...args: infer P) => infer R
|
||||
? P extends readonly [string, ...any[]]
|
||||
? never // Reject string parameters in first position for FileId functions
|
||||
: T
|
||||
: T;
|
||||
|
||||
// Mark functions that should only accept StirlingFile, not regular File
|
||||
type StirlingFileOnlyFunction<T extends (...args: any[]) => any> = T extends (...args: infer P) => infer R
|
||||
? P extends readonly [File, ...any[]]
|
||||
? never // Reject File parameters in first position for StirlingFile functions
|
||||
: T
|
||||
: T;
|
||||
|
||||
// Utility type to enforce StirlingFile usage
|
||||
type RequireStirlingFile<T> = T extends File ? StirlingFile : T;
|
||||
}
|
||||
|
||||
// Extend Window interface for debugging
|
||||
interface Window {
|
||||
__FILE_ID_DEBUG?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// Augment FileContext types to prevent bypassing StirlingFile
|
||||
declare module '../contexts/FileContext' {
|
||||
export interface StrictFileContextActions {
|
||||
pinFile: (file: StirlingFile) => void; // Must be StirlingFile
|
||||
unpinFile: (file: StirlingFile) => void; // Must be StirlingFile
|
||||
addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<StirlingFile[]>; // Returns StirlingFile
|
||||
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<StirlingFile[]>; // Returns StirlingFile
|
||||
}
|
||||
|
||||
export interface StrictFileContextSelectors {
|
||||
getFile: (id: FileId) => StirlingFile | undefined; // Returns StirlingFile
|
||||
getFiles: (ids?: FileId[]) => StirlingFile[]; // Returns StirlingFile[]
|
||||
isFilePinned: (file: StirlingFile) => boolean; // Must be StirlingFile
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
@ -1,7 +1,6 @@
|
||||
// Base parameter interfaces for reusable patterns
|
||||
|
||||
export interface BaseParameters {
|
||||
// Base interface that all tool parameters should extend
|
||||
// Provides a foundation for adding common properties across all tools
|
||||
// Examples of future additions: userId, sessionId, commonFlags, etc.
|
||||
}
|
||||
// Base interface that all tool parameters should extend
|
||||
// Provides a foundation for adding common properties across all tools
|
||||
// Examples of future additions: userId, sessionId, commonFlags, etc.
|
||||
export type BaseParameters = object
|
||||
|
14
frontend/src/utils/fileIdSafety.ts
Normal file
14
frontend/src/utils/fileIdSafety.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Runtime validation utilities for FileId safety
|
||||
*/
|
||||
|
||||
import { FileId } from '../types/fileContext';
|
||||
|
||||
// Validate that a string is a proper FileId (has UUID format)
|
||||
export function isValidFileId(id: string): id is FileId {
|
||||
// Check UUID v4 format: 8-4-4-4-12 hex digits
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(id);
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user