mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
# Description of Changes The `FileId` type in V2 currently is just defined to be a string. This makes it really easy to accidentally pass strings into things accepting file IDs (such as file names). This PR makes the `FileId` type [an opaque type](https://www.geeksforgeeks.org/typescript/opaque-types-in-typescript/), so it is compatible with things accepting strings (arguably not ideal for this...) but strings are not compatible with it without explicit conversion. The PR also includes changes to use `FileId` consistently throughout the project (everywhere I could find uses of `fileId: string`), so that we have the maximum benefit from the type safety. > [!note] > I've marked quite a few things as `FIX ME` where we're passing names in as IDs. If that is intended behaviour, I'm happy to remove the fix me and insert a cast instead, but they probably need comments explaining why we're using a file name as an ID.
241 lines
5.5 KiB
TypeScript
241 lines
5.5 KiB
TypeScript
/**
|
|
* FileContext reducer - Pure state management for file operations
|
|
*/
|
|
|
|
import { FileId } from '../../types/file';
|
|
import {
|
|
FileContextState,
|
|
FileContextAction,
|
|
FileRecord
|
|
} from '../../types/fileContext';
|
|
|
|
// Initial state
|
|
export const initialFileContextState: FileContextState = {
|
|
files: {
|
|
ids: [],
|
|
byId: {}
|
|
},
|
|
pinnedFiles: new Set(),
|
|
ui: {
|
|
selectedFileIds: [],
|
|
selectedPageNumbers: [],
|
|
isProcessing: false,
|
|
processingProgress: 0,
|
|
hasUnsavedChanges: false
|
|
}
|
|
};
|
|
|
|
// Pure reducer function
|
|
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
|
switch (action.type) {
|
|
case 'ADD_FILES': {
|
|
const { fileRecords } = action.payload;
|
|
const newIds: FileId[] = [];
|
|
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
|
|
|
fileRecords.forEach(record => {
|
|
// Only add if not already present (dedupe by stable ID)
|
|
if (!newById[record.id]) {
|
|
newIds.push(record.id);
|
|
newById[record.id] = record;
|
|
}
|
|
});
|
|
|
|
return {
|
|
...state,
|
|
files: {
|
|
ids: [...state.files.ids, ...newIds],
|
|
byId: newById
|
|
}
|
|
};
|
|
}
|
|
|
|
case 'REMOVE_FILES': {
|
|
const { fileIds } = action.payload;
|
|
const remainingIds = state.files.ids.filter(id => !fileIds.includes(id));
|
|
const newById = { ...state.files.byId };
|
|
|
|
// Remove files from state (resource cleanup handled by lifecycle manager)
|
|
fileIds.forEach(id => {
|
|
delete newById[id];
|
|
});
|
|
|
|
// Clear selections that reference removed files
|
|
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id));
|
|
|
|
return {
|
|
...state,
|
|
files: {
|
|
ids: remainingIds,
|
|
byId: newById
|
|
},
|
|
ui: {
|
|
...state.ui,
|
|
selectedFileIds: validSelectedFileIds
|
|
}
|
|
};
|
|
}
|
|
|
|
case 'UPDATE_FILE_RECORD': {
|
|
const { id, updates } = action.payload;
|
|
const existingRecord = state.files.byId[id];
|
|
|
|
if (!existingRecord) {
|
|
return state; // File doesn't exist, no-op
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
files: {
|
|
...state.files,
|
|
byId: {
|
|
...state.files.byId,
|
|
[id]: {
|
|
...existingRecord,
|
|
...updates
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
case 'REORDER_FILES': {
|
|
const { orderedFileIds } = action.payload;
|
|
|
|
// Validate that all IDs exist in current state
|
|
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
|
|
|
return {
|
|
...state,
|
|
files: {
|
|
...state.files,
|
|
ids: validIds
|
|
}
|
|
};
|
|
}
|
|
|
|
case 'SET_SELECTED_FILES': {
|
|
const { fileIds } = action.payload;
|
|
return {
|
|
...state,
|
|
ui: {
|
|
...state.ui,
|
|
selectedFileIds: fileIds
|
|
}
|
|
};
|
|
}
|
|
|
|
case 'SET_SELECTED_PAGES': {
|
|
const { pageNumbers } = action.payload;
|
|
return {
|
|
...state,
|
|
ui: {
|
|
...state.ui,
|
|
selectedPageNumbers: pageNumbers
|
|
}
|
|
};
|
|
}
|
|
|
|
case 'CLEAR_SELECTIONS': {
|
|
return {
|
|
...state,
|
|
ui: {
|
|
...state.ui,
|
|
selectedFileIds: [],
|
|
selectedPageNumbers: []
|
|
}
|
|
};
|
|
}
|
|
|
|
case 'SET_PROCESSING': {
|
|
const { isProcessing, progress } = action.payload;
|
|
return {
|
|
...state,
|
|
ui: {
|
|
...state.ui,
|
|
isProcessing,
|
|
processingProgress: progress
|
|
}
|
|
};
|
|
}
|
|
|
|
case 'SET_UNSAVED_CHANGES': {
|
|
return {
|
|
...state,
|
|
ui: {
|
|
...state.ui,
|
|
hasUnsavedChanges: action.payload.hasChanges
|
|
}
|
|
};
|
|
}
|
|
|
|
case 'PIN_FILE': {
|
|
const { fileId } = action.payload;
|
|
const newPinnedFiles = new Set(state.pinnedFiles);
|
|
newPinnedFiles.add(fileId);
|
|
|
|
return {
|
|
...state,
|
|
pinnedFiles: newPinnedFiles
|
|
};
|
|
}
|
|
|
|
case 'UNPIN_FILE': {
|
|
const { fileId } = action.payload;
|
|
const newPinnedFiles = new Set(state.pinnedFiles);
|
|
newPinnedFiles.delete(fileId);
|
|
|
|
return {
|
|
...state,
|
|
pinnedFiles: newPinnedFiles
|
|
};
|
|
}
|
|
|
|
case 'CONSUME_FILES': {
|
|
const { inputFileIds, outputFileRecords } = action.payload;
|
|
|
|
// Only remove unpinned input files
|
|
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
|
|
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
|
|
|
|
// Remove unpinned files from state
|
|
const newById = { ...state.files.byId };
|
|
unpinnedInputIds.forEach(id => {
|
|
delete newById[id];
|
|
});
|
|
|
|
// Add output files
|
|
const outputIds: FileId[] = [];
|
|
outputFileRecords.forEach(record => {
|
|
if (!newById[record.id]) {
|
|
outputIds.push(record.id);
|
|
newById[record.id] = record;
|
|
}
|
|
});
|
|
|
|
// Clear selections that reference removed files
|
|
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
|
|
|
|
return {
|
|
...state,
|
|
files: {
|
|
ids: [...remainingIds, ...outputIds],
|
|
byId: newById
|
|
},
|
|
ui: {
|
|
...state.ui,
|
|
selectedFileIds: validSelectedFileIds
|
|
}
|
|
};
|
|
}
|
|
|
|
case 'RESET_CONTEXT': {
|
|
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
|
|
return { ...initialFileContextState };
|
|
}
|
|
|
|
default:
|
|
return state;
|
|
}
|
|
}
|