Feature/v2/toggle_for_auto_unzip (#4584)

## default 
<img width="1012" height="627"
alt="{BF57458D-50A6-4057-94F1-D6AB4628EFD8}"
src="https://github.com/user-attachments/assets/85e550ab-0aed-4341-be95-d5d3bc7146db"
/>

## disabled
<img width="1141" height="620"
alt="{140DB87B-05CF-4E0E-A14A-ED15075BD2EE}"
src="https://github.com/user-attachments/assets/e0f56e84-fb9d-4787-b5cb-ba7c5a54b1e1"
/>

## unzip options
<img width="530" height="255"
alt="{482CE185-73D5-4D90-91BB-B9305C711391}"
src="https://github.com/user-attachments/assets/609b18ee-4eae-4cee-afc1-5db01f9d1088"
/>
<img width="579" height="473"
alt="{4DFCA96D-792D-4370-8C62-4BA42C9F1A5F}"
src="https://github.com/user-attachments/assets/c67fa4af-04ef-41df-9420-65ce4247e25b"
/>

## pop up and maintains version metadata
<img width="1071" height="1220"
alt="{7F2A785C-5717-4A79-9D45-74BDA46DF273}"
src="https://github.com/user-attachments/assets/9374cd2a-b7e5-46c4-a722-e141ab42f0de"
/>

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
ConnorYoh
2025-10-06 12:29:38 +01:00
committed by GitHub
parent be7e79be55
commit ab6edd3196
17 changed files with 661 additions and 104 deletions

View File

@@ -314,6 +314,15 @@ export const DATABASE_CONFIGS = {
}]
} as DatabaseConfig,
PREFERENCES: {
name: 'stirling-pdf-preferences',
version: 1,
stores: [{
name: 'preferences',
keyPath: 'key'
}]
} as DatabaseConfig,
} as const;
export const indexedDBManager = IndexedDBManager.getInstance();

View File

@@ -0,0 +1,129 @@
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
export interface UserPreferences {
autoUnzip: boolean;
autoUnzipFileLimit: number;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
autoUnzip: true,
autoUnzipFileLimit: 4,
};
class PreferencesService {
private db: IDBDatabase | null = null;
async initialize(): Promise<void> {
this.db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.PREFERENCES);
}
private ensureDatabase(): IDBDatabase {
if (!this.db) {
throw new Error('PreferencesService not initialized. Call initialize() first.');
}
return this.db;
}
async getPreference<K extends keyof UserPreferences>(
key: K
): Promise<UserPreferences[K]> {
const db = this.ensureDatabase();
return new Promise((resolve) => {
const transaction = db.transaction(['preferences'], 'readonly');
const store = transaction.objectStore('preferences');
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
if (result && result.value !== undefined) {
resolve(result.value);
} else {
// Return default value if preference not found
resolve(DEFAULT_PREFERENCES[key]);
}
};
request.onerror = () => {
console.error('Error reading preference:', key, request.error);
// Return default value on error
resolve(DEFAULT_PREFERENCES[key]);
};
});
}
async setPreference<K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
): Promise<void> {
const db = this.ensureDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['preferences'], 'readwrite');
const store = transaction.objectStore('preferences');
const request = store.put({ key, value });
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
console.error('Error writing preference:', key, request.error);
reject(request.error);
};
});
}
async getAllPreferences(): Promise<UserPreferences> {
const db = this.ensureDatabase();
return new Promise((resolve) => {
const transaction = db.transaction(['preferences'], 'readonly');
const store = transaction.objectStore('preferences');
const request = store.getAll();
request.onsuccess = () => {
const storedPrefs: Partial<UserPreferences> = {};
const results = request.result;
for (const item of results) {
if (item.key && item.value !== undefined) {
storedPrefs[item.key as keyof UserPreferences] = item.value;
}
}
// Merge with defaults to ensure all preferences exist
resolve({
...DEFAULT_PREFERENCES,
...storedPrefs,
});
};
request.onerror = () => {
console.error('Error reading all preferences:', request.error);
// Return defaults on error
resolve({ ...DEFAULT_PREFERENCES });
};
});
}
async clearAllPreferences(): Promise<void> {
const db = this.ensureDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['preferences'], 'readwrite');
const store = transaction.objectStore('preferences');
const request = store.clear();
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
}
}
export const preferencesService = new PreferencesService();

View File

@@ -1,4 +1,7 @@
import JSZip, { JSZipObject } from 'jszip';
import { StirlingFileStub, createStirlingFile } from '../types/fileContext';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
import { fileStorage } from './fileStorage';
// Undocumented interface in JSZip for JSZipObject._data
interface CompressedObject {
@@ -41,6 +44,15 @@ export class ZipFileService {
private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit
private readonly supportedExtensions = ['.pdf'];
// ZIP file validation constants
private static readonly VALID_ZIP_TYPES = [
'application/zip',
'application/x-zip-compressed',
'application/x-zip',
'application/octet-stream' // Some browsers use this for ZIP files
];
private static readonly VALID_ZIP_EXTENSIONS = ['.zip'];
/**
* Validate a ZIP file without extracting it
*/
@@ -238,23 +250,27 @@ export class ZipFileService {
/**
* Check if a file is a ZIP file based on type and extension
*/
private isZipFile(file: File): boolean {
const validTypes = [
'application/zip',
'application/x-zip-compressed',
'application/x-zip',
'application/octet-stream' // Some browsers use this for ZIP files
];
const validExtensions = ['.zip'];
const hasValidType = validTypes.includes(file.type);
const hasValidExtension = validExtensions.some(ext =>
public isZipFile(file: File): boolean {
const hasValidType = ZipFileService.VALID_ZIP_TYPES.includes(file.type);
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some(ext =>
file.name.toLowerCase().endsWith(ext)
);
return hasValidType || hasValidExtension;
}
/**
* Check if a StirlingFileStub represents a ZIP file (for UI checks without loading full file)
*/
public isZipFileStub(stub: StirlingFileStub): boolean {
const hasValidType = stub.type && ZipFileService.VALID_ZIP_TYPES.includes(stub.type);
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some(ext =>
stub.name.toLowerCase().endsWith(ext)
);
return hasValidType || hasValidExtension;
}
/**
* Check if a filename indicates a PDF file
*/
@@ -309,33 +325,44 @@ export class ZipFileService {
}
/**
* Get file extension from filename
* Determine if a ZIP file should be extracted based on user preferences
*
* @param zipBlob - The ZIP file to check
* @param autoUnzip - User preference for auto-unzipping
* @param autoUnzipFileLimit - Maximum number of files to auto-extract
* @param skipAutoUnzip - Bypass preference check (for automation)
* @returns true if the ZIP should be extracted, false otherwise
*/
private getFileExtension(filename: string): string {
return filename.substring(filename.lastIndexOf('.')).toLowerCase();
}
/**
* Check if ZIP file contains password protection
*/
private async isPasswordProtected(file: File): Promise<boolean> {
async shouldUnzip(
zipBlob: Blob | File,
autoUnzip: boolean,
autoUnzipFileLimit: number,
skipAutoUnzip: boolean = false
): Promise<boolean> {
try {
const zip = new JSZip();
await zip.loadAsync(file);
// Check if any files are encrypted
for (const [_filename, zipEntry] of Object.entries(zip.files)) {
if (zipEntry.options?.compression === 'STORE' && getData(zipEntry)?.compressedSize === 0) {
// This might indicate encryption, but JSZip doesn't provide direct encryption detection
// We'll handle this in the extraction phase
}
// Automation always extracts
if (skipAutoUnzip) {
return true;
}
return false; // JSZip will throw an error if password is required
// Check if auto-unzip is enabled
if (!autoUnzip) {
return false;
}
// Load ZIP and count files
const zip = new JSZip();
const zipContents = await zip.loadAsync(zipBlob);
// Count non-directory entries
const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length;
// Only extract if within limit
return fileCount <= autoUnzipFileLimit;
} catch (error) {
// If we can't load the ZIP, it might be password protected
const errorMessage = error instanceof Error ? error.message : '';
return errorMessage.includes('password') || errorMessage.includes('encrypted');
console.error('Error checking shouldUnzip:', error);
// On error, default to not extracting (safer)
return false;
}
}
@@ -457,6 +484,79 @@ export class ZipFileService {
return mimeTypes[ext || ''] || 'application/octet-stream';
}
/**
* Extract PDF files from ZIP and store them in IndexedDB with preserved history metadata
* Used by both FileManager and FileEditor to avoid code duplication
*
* @param zipFile - The ZIP file to extract from
* @param zipStub - The StirlingFileStub for the ZIP (contains metadata to preserve)
* @returns Object with success status, extracted stubs, and any errors
*/
async extractAndStoreFilesWithHistory(
zipFile: File,
zipStub: StirlingFileStub
): Promise<{ success: boolean; extractedStubs: StirlingFileStub[]; errors: string[] }> {
const result = {
success: false,
extractedStubs: [] as StirlingFileStub[],
errors: [] as string[]
};
try {
// Extract PDF files from ZIP
const extractionResult = await this.extractPdfFiles(zipFile);
if (!extractionResult.success || extractionResult.extractedFiles.length === 0) {
result.errors = extractionResult.errors;
return result;
}
// Process each extracted file
for (const extractedFile of extractionResult.extractedFiles) {
try {
// Generate thumbnail
const thumbnail = await generateThumbnailForFile(extractedFile);
// Create StirlingFile
const newStirlingFile = createStirlingFile(extractedFile);
// Create StirlingFileStub with ZIP's history metadata
const stub: StirlingFileStub = {
id: newStirlingFile.fileId,
name: extractedFile.name,
size: extractedFile.size,
type: extractedFile.type,
lastModified: extractedFile.lastModified,
quickKey: newStirlingFile.quickKey,
createdAt: Date.now(),
isLeaf: true,
// Preserve ZIP's history - unzipping is NOT a tool operation
originalFileId: zipStub.originalFileId,
parentFileId: zipStub.parentFileId,
versionNumber: zipStub.versionNumber,
toolHistory: zipStub.toolHistory || [],
thumbnailUrl: thumbnail
};
// Store in IndexedDB
await fileStorage.storeStirlingFile(newStirlingFile, stub);
result.extractedStubs.push(stub);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to process "${extractedFile.name}": ${errorMessage}`);
}
}
result.success = result.extractedStubs.length > 0;
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to extract ZIP file: ${errorMessage}`);
return result;
}
}
}
// Export singleton instance