Feature/v2/filehistory (#4370)

File History

---------

Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
ConnorYoh
2025-09-16 15:08:11 +01:00
committed by GitHub
co-authored by Connor Yoh
parent 8e8b417f5e
commit 190178a471
61 changed files with 2279 additions and 1245 deletions
+27 -30
View File
@@ -1,4 +1,4 @@
import { FileMetadata } from '../types/file';
import { StirlingFileStub } from '../types/fileContext';
import { fileStorage } from '../services/fileStorage';
import { zipFileService } from '../services/zipFileService';
@@ -9,14 +9,14 @@ import { zipFileService } from '../services/zipFileService';
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the blob URL
URL.revokeObjectURL(url);
}
@@ -26,23 +26,23 @@ export function downloadBlob(blob: Blob, filename: string): void {
* @param file - The file object with storage information
* @throws Error if file cannot be retrieved from storage
*/
export async function downloadFileFromStorage(file: FileMetadata): Promise<void> {
export async function downloadFileFromStorage(file: StirlingFileStub): Promise<void> {
const lookupKey = file.id;
const storedFile = await fileStorage.getFile(lookupKey);
if (!storedFile) {
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
if (!stirlingFile) {
throw new Error(`File "${file.name}" not found in storage`);
}
const blob = new Blob([storedFile.data], { type: storedFile.type });
downloadBlob(blob, storedFile.name);
// StirlingFile is already a File object, just download it
downloadBlob(stirlingFile, stirlingFile.name);
}
/**
* Downloads multiple files as individual downloads
* @param files - Array of files to download
*/
export async function downloadMultipleFiles(files: FileMetadata[]): Promise<void> {
export async function downloadMultipleFiles(files: StirlingFileStub[]): Promise<void> {
for (const file of files) {
await downloadFileFromStorage(file);
}
@@ -53,36 +53,33 @@ export async function downloadMultipleFiles(files: FileMetadata[]): Promise<void
* @param files - Array of files to include in ZIP
* @param zipFilename - Optional custom ZIP filename (defaults to timestamped name)
*/
export async function downloadFilesAsZip(files: FileMetadata[], zipFilename?: string): Promise<void> {
export async function downloadFilesAsZip(files: StirlingFileStub[], zipFilename?: string): Promise<void> {
if (files.length === 0) {
throw new Error('No files provided for ZIP download');
}
// Convert stored files to File objects
const fileObjects: File[] = [];
const filesToZip: File[] = [];
for (const fileWithUrl of files) {
const lookupKey = fileWithUrl.id;
const storedFile = await fileStorage.getFile(lookupKey);
if (storedFile) {
const file = new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
fileObjects.push(file);
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
if (stirlingFile) {
// StirlingFile is already a File object!
filesToZip.push(stirlingFile);
}
}
if (fileObjects.length === 0) {
if (filesToZip.length === 0) {
throw new Error('No valid files found in storage for ZIP download');
}
// Generate default filename if not provided
const finalZipFilename = zipFilename ||
const finalZipFilename = zipFilename ||
`files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`;
// Create and download ZIP
const { zipFile } = await zipFileService.createZipFromFiles(fileObjects, finalZipFilename);
const { zipFile } = await zipFileService.createZipFromFiles(filesToZip, finalZipFilename);
downloadBlob(zipFile, finalZipFilename);
}
@@ -94,7 +91,7 @@ export async function downloadFilesAsZip(files: FileMetadata[], zipFilename?: st
* @param options - Download options
*/
export async function downloadFiles(
files: FileMetadata[],
files: StirlingFileStub[],
options: {
forceZip?: boolean;
zipFilename?: string;
@@ -133,8 +130,8 @@ export function downloadFileObject(file: File, filename?: string): void {
* @param mimeType - MIME type (defaults to text/plain)
*/
export function downloadTextAsFile(
content: string,
filename: string,
content: string,
filename: string,
mimeType: string = 'text/plain'
): void {
const blob = new Blob([content], { type: mimeType });
@@ -149,4 +146,4 @@ export function downloadTextAsFile(
export function downloadJsonAsFile(data: any, filename: string): void {
const content = JSON.stringify(data, null, 2);
downloadTextAsFile(content, filename, 'application/json');
}
}
+78
View File
@@ -0,0 +1,78 @@
/**
* File History Utilities
*
* Helper functions for IndexedDB-based file history management.
* Handles file history operations and lineage tracking.
*/
import { StirlingFileStub } from '../types/fileContext';
/**
* Group files by processing branches - each branch ends in a leaf file
* Returns Map<fileId, lineagePath[]> where fileId is the leaf and lineagePath is the path back to original
*/
export function groupFilesByOriginal(StirlingFileStubs: StirlingFileStub[]): Map<string, StirlingFileStub[]> {
const groups = new Map<string, StirlingFileStub[]>();
// Create a map for quick lookups
const fileMap = new Map<string, StirlingFileStub>();
for (const record of StirlingFileStubs) {
fileMap.set(record.id, record);
}
// Find leaf files (files that are not parents of any other files AND have version history)
// Original files (v0) should only be leaves if they have no processed versions at all
const leafFiles = StirlingFileStubs.filter(stub => {
const isParentOfOthers = StirlingFileStubs.some(otherStub => otherStub.parentFileId === stub.id);
const isOriginalOfOthers = StirlingFileStubs.some(otherStub => otherStub.originalFileId === stub.id);
// A file is a leaf if:
// 1. It's not a parent of any other files, AND
// 2. It has processing history (versionNumber > 0) OR it's not referenced as original by others
return !isParentOfOthers && (stub.versionNumber && stub.versionNumber > 0 || !isOriginalOfOthers);
});
// For each leaf file, build its complete lineage path back to original
for (const leafFile of leafFiles) {
const lineagePath: StirlingFileStub[] = [];
let currentFile: StirlingFileStub | undefined = leafFile;
// Trace back through parentFileId chain to build this specific branch
while (currentFile) {
lineagePath.push(currentFile);
// Move to parent file in this branch
let nextFile: StirlingFileStub | undefined = undefined;
if (currentFile.parentFileId) {
nextFile = fileMap.get(currentFile.parentFileId);
} else if (currentFile.originalFileId && currentFile.originalFileId !== currentFile.id) {
// For v1 files, the original file might be referenced by originalFileId
nextFile = fileMap.get(currentFile.originalFileId);
}
// Check for infinite loops before moving to next
if (nextFile && lineagePath.some(file => file.id === nextFile!.id)) {
break;
}
currentFile = nextFile;
}
// Sort lineage with latest version first (leaf at top)
lineagePath.sort((a, b) => (b.versionNumber || 0) - (a.versionNumber || 0));
// Use leaf file ID as the group key - each branch gets its own group
groups.set(leafFile.id, lineagePath);
}
return groups;
}
/**
* Check if a file has version history
*/
export function hasVersionHistory(fileStub: StirlingFileStub): boolean {
return !!(fileStub.originalFileId && fileStub.versionNumber && fileStub.versionNumber > 0);
}
+1 -2
View File
@@ -6,7 +6,7 @@ import { FileOperation } from '../types/fileContext';
*/
export const createOperation = <TParams = void>(
operationType: string,
params: TParams,
_params: TParams,
selectedFiles: File[]
): { operation: FileOperation; operationId: string; fileId: FileId } => {
const operationId = `${operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -20,7 +20,6 @@ export const createOperation = <TParams = void>(
status: 'pending',
metadata: {
originalFileName: selectedFiles[0]?.name,
parameters: params,
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
}
} as any /* FIX ME*/;
+10 -3
View File
@@ -8,11 +8,12 @@ export type ResponseHandler = (blob: Blob, originalFiles: File[]) => Promise<Fil
* - If a tool-specific responseHandler is provided, it is used.
* - If responseHeaders provided and contains Content-Disposition, uses that filename.
* - Otherwise, create a single file using the filePrefix + original name.
* - If filePrefix is empty, preserves the original filename.
*/
export async function processResponse(
blob: Blob,
originalFiles: File[],
filePrefix: string,
filePrefix?: string,
responseHandler?: ResponseHandler,
responseHeaders?: Record<string, any>
): Promise<File[]> {
@@ -36,7 +37,13 @@ export async function processResponse(
// Default behavior: use filePrefix + original name
const original = originalFiles[0]?.name ?? 'result.pdf';
const name = `${filePrefix}${original}`;
// Only add prefix if it's not empty - this preserves original filenames for file history
const name = filePrefix ? `${filePrefix}${original}` : original;
const type = blob.type || 'application/octet-stream';
return [new File([blob], name, { type })];
// File was modified by tool processing - set lastModified to current time
return [new File([blob], name, {
type,
lastModified: Date.now()
})];
}