diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 4e70c3e3..7cd053fa 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -99,6 +99,8 @@ export abstract class BaseAppService implements AppService { canCustomizeRootDir = false; distChannel = 'readest' as DistChannel; + protected CURRENT_MIGRATION_VERSION = 20251124; + protected abstract fs: FileSystem; protected abstract resolvePath(fp: string, base: BaseDir): ResolvedPath; @@ -107,6 +109,16 @@ export abstract class BaseAppService implements AppService { abstract selectDirectory(mode: SelectDirectoryMode): Promise; abstract selectFiles(name: string, extensions: string[]): Promise; + protected async runMigrations(lastMigrationVersion: number): Promise { + if (lastMigrationVersion < 20251124) { + try { + await this.migrate20251124(); + } catch (error) { + console.error('Error migrating to version 20251124:', error); + } + } + } + async prepareBooksDir() { this.localBooksDir = await this.fs.getPrefix('Books'); } @@ -181,40 +193,39 @@ export abstract class BaseAppService implements AppService { } async loadSettings(): Promise { - let settings: SystemSettings; + const defaultSettings: SystemSettings = { + ...DEFAULT_SYSTEM_SETTINGS, + version: SYSTEM_SETTINGS_VERSION, + localBooksDir: await this.fs.getPrefix('Books'), + koreaderSyncDeviceId: uuidv4(), + globalReadSettings: { + ...DEFAULT_READSETTINGS, + ...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}), + }, + globalViewSettings: this.getDefaultViewSettings(), + } as SystemSettings; - try { - await this.fs.exists(SETTINGS_FILENAME, 'Settings'); - const txt = await this.fs.readFile(SETTINGS_FILENAME, 'Settings', 'text'); - settings = JSON.parse(txt as string); - const version = settings.version ?? 0; - if (this.isAppDataSandbox || version < SYSTEM_SETTINGS_VERSION) { - settings.version = SYSTEM_SETTINGS_VERSION; - } - settings = { ...DEFAULT_SYSTEM_SETTINGS, ...settings }; - settings.globalReadSettings = { ...DEFAULT_READSETTINGS, ...settings.globalReadSettings }; - settings.globalViewSettings = { - ...this.getDefaultViewSettings(), - ...settings.globalViewSettings, - }; + let settings = await this.safeLoadJSON( + SETTINGS_FILENAME, + 'Settings', + defaultSettings, + ); - settings.localBooksDir = await this.fs.getPrefix('Books'); - if (!settings.kosync.deviceId) { - settings.kosync.deviceId = uuidv4(); - await this.saveSettings(settings); - } - } catch { - settings = { - ...DEFAULT_SYSTEM_SETTINGS, - version: SYSTEM_SETTINGS_VERSION, - localBooksDir: await this.fs.getPrefix('Books'), - koreaderSyncDeviceId: uuidv4(), - globalReadSettings: { - ...DEFAULT_READSETTINGS, - ...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}), - }, - globalViewSettings: this.getDefaultViewSettings(), - } as SystemSettings; + const version = settings.version ?? 0; + if (this.isAppDataSandbox || version < SYSTEM_SETTINGS_VERSION) { + settings.version = SYSTEM_SETTINGS_VERSION; + } + settings = { ...DEFAULT_SYSTEM_SETTINGS, ...settings }; + settings.globalReadSettings = { ...DEFAULT_READSETTINGS, ...settings.globalReadSettings }; + settings.globalViewSettings = { + ...this.getDefaultViewSettings(), + ...settings.globalViewSettings, + }; + + settings.localBooksDir = await this.fs.getPrefix('Books'); + + if (!settings.kosync.deviceId) { + settings.kosync.deviceId = uuidv4(); await this.saveSettings(settings); } @@ -223,7 +234,7 @@ export abstract class BaseAppService implements AppService { } async saveSettings(settings: SystemSettings): Promise { - await this.fs.writeFile(SETTINGS_FILENAME, 'Settings', JSON.stringify(settings)); + await this.safeSaveJSON(SETTINGS_FILENAME, 'Settings', settings); } async importFont(file?: string | File): Promise { @@ -716,49 +727,15 @@ export abstract class BaseAppService implements AppService { : this.getCoverImageUrl(book); } - private async loadJSONFile( - path: string, - base: BaseDir, - ): Promise<{ success: boolean; data?: unknown; error?: unknown }> { - try { - const txt = await this.fs.readFile(path, base, 'text'); - if (!txt || typeof txt !== 'string' || txt.trim().length === 0) { - return { success: false, error: 'File is empty or invalid' }; - } - try { - const data = JSON.parse(txt as string); - return { success: true, data }; - } catch (parseError) { - return { success: false, error: `JSON parse error: ${parseError}` }; - } - } catch (error) { - return { success: false, error }; - } - } - async loadLibraryBooks(): Promise { console.log('Loading library books...'); - let books: Book[] = []; const libraryFilename = getLibraryFilename(); - const backupFilename = getLibraryBackupFilename(); if (!(await this.fs.exists('', 'Books'))) { await this.fs.createDir('', 'Books', true); } - const mainResult = await this.loadJSONFile(libraryFilename, 'Books'); - if (mainResult.success) { - books = mainResult.data as Book[]; - } else { - const backupResult = await this.loadJSONFile(backupFilename, 'Books'); - if (backupResult.success) { - books = backupResult.data as Book[]; - console.warn('Loaded library from backup file:', backupFilename); - } else { - await this.fs.writeFile(libraryFilename, 'Books', '[]'); - await this.fs.writeFile(backupFilename, 'Books', '[]'); - } - } + const books = await this.safeLoadJSON(libraryFilename, 'Books', []); await Promise.all( books.map(async (book) => { @@ -773,19 +750,7 @@ export abstract class BaseAppService implements AppService { async saveLibraryBooks(books: Book[]): Promise { const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest); - const jsonData = JSON.stringify(libraryBooks, null, 2); - const libraryFilename = getLibraryFilename(); - const backupFilename = getLibraryBackupFilename(); - - const saveResults = await Promise.allSettled([ - this.fs.writeFile(backupFilename, 'Books', jsonData), - this.fs.writeFile(libraryFilename, 'Books', jsonData), - ]); - const backupSuccess = saveResults[0].status === 'fulfilled'; - const mainSuccess = saveResults[1].status === 'fulfilled'; - if (!backupSuccess || !mainSuccess) { - throw new Error('Failed to save library books'); - } + await this.safeSaveJSON(getLibraryFilename(), 'Books', libraryBooks); } private imageToArrayBuffer(imageUrl?: string, imageFile?: string): Promise { @@ -824,4 +789,103 @@ export abstract class BaseAppService implements AppService { await this.fs.writeFile(getCoverFilename(book), 'Books', arrayBuffer); } } + + private async loadJSONFile( + path: string, + base: BaseDir, + ): Promise<{ success: boolean; data?: unknown; error?: unknown }> { + try { + const txt = await this.fs.readFile(path, base, 'text'); + if (!txt || typeof txt !== 'string' || txt.trim().length === 0) { + return { success: false, error: 'File is empty or invalid' }; + } + try { + const data = JSON.parse(txt as string); + return { success: true, data }; + } catch (parseError) { + return { success: false, error: `JSON parse error: ${parseError}` }; + } + } catch (error) { + return { success: false, error }; + } + } + + /** + * Safely loads a JSON file with automatic backup fallback. + * If the main file is corrupted, attempts to load from backup. + * @param filename - The name of the file to load (without .bak extension) + * @param base - The base directory + * @param defaultValue - Default value to return if both files fail + */ + private async safeLoadJSON(filename: string, base: BaseDir, defaultValue: T): Promise { + const backupFilename = `${filename}.bak`; + + // Try loading main file + const mainResult = await this.loadJSONFile(filename, base); + if (mainResult.success) { + return mainResult.data as T; + } + + console.warn(`Failed to load ${filename}, attempting backup...`, mainResult.error); + + // Try loading backup file + const backupResult = await this.loadJSONFile(backupFilename, base); + if (backupResult.success) { + console.warn(`Loaded from backup: ${backupFilename}`); + // Restore the main file from backup + try { + const backupData = JSON.stringify(backupResult.data, null, 2); + await this.fs.writeFile(filename, base, backupData); + console.log(`Restored ${filename} from backup`); + } catch (error) { + console.error(`Failed to restore ${filename} from backup:`, error); + } + return backupResult.data as T; + } + + console.error(`Both ${filename} and ${backupFilename} failed to load`); + return defaultValue; + } + + /** + * Safely saves a JSON file with atomic write using backup strategy. + * Strategy: write to backup first, then to main file. + * This ensures at least one valid copy exists at all times. + * @param filename - The name of the file to save (without .bak extension) + * @param base - The base directory + * @param data - The data to save + */ + private async safeSaveJSON(filename: string, base: BaseDir, data: unknown): Promise { + const backupFilename = `${filename}.bak`; + const jsonData = JSON.stringify(data, null, 2); + + // Strategy: Always write to backup first, then to main file + // This ensures we always have at least one valid copy + try { + // Step 1: Write to backup file + await this.fs.writeFile(backupFilename, base, jsonData); + + // Step 2: Write to main file + await this.fs.writeFile(filename, base, jsonData); + } catch (error) { + console.error(`Failed to save ${filename}:`, error); + throw new Error(`Failed to save ${filename}: ${error}`); + } + } + + private async migrate20251124(): Promise { + console.log('Running migration for version 20251124 to rename the backup library file...'); + const oldBackupFilename = getLibraryBackupFilename(); + const newBackupFilename = `${getLibraryFilename()}.bak`; + if (await this.fs.exists(oldBackupFilename, 'Books')) { + try { + const content = await this.fs.readFile(oldBackupFilename, 'Books', 'text'); + await this.fs.writeFile(newBackupFilename, 'Books', content); + await this.fs.removeFile(oldBackupFilename, 'Books'); + console.log('Migration to rename backup library file completed successfully.'); + } catch (error) { + console.error('Error during migration to rename backup library file:', error); + } + } + } } diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 34b93bfd..2f853a35 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -401,13 +401,13 @@ export class NativeAppService extends BaseAppService { await this.runMigrations(); } - private CURRENT_MIGRATION_VERSION = 20251029; - - private async runMigrations() { + override async runMigrations() { try { const settings = await this.loadSettings(); const lastMigrationVersion = settings.migrationVersion || 0; + await super.runMigrations(lastMigrationVersion); + if (lastMigrationVersion < 20251029) { try { await this.migrate20251029(); diff --git a/apps/readest-app/src/services/webAppService.ts b/apps/readest-app/src/services/webAppService.ts index 46362a43..9e23bf40 100644 --- a/apps/readest-app/src/services/webAppService.ts +++ b/apps/readest-app/src/services/webAppService.ts @@ -226,6 +226,25 @@ export class WebAppService extends BaseAppService { override async init() { await this.loadSettings(); await this.prepareBooksDir(); + await this.runMigrations(); + } + + override async runMigrations() { + try { + const settings = await this.loadSettings(); + const lastMigrationVersion = settings.migrationVersion || 0; + + await super.runMigrations(lastMigrationVersion); + + if (lastMigrationVersion < this.CURRENT_MIGRATION_VERSION) { + await this.saveSettings({ + ...settings, + migrationVersion: this.CURRENT_MIGRATION_VERSION, + }); + } + } catch (error) { + console.error('Failed to run migrations:', error); + } } override resolvePath(fp: string, base: BaseDir): ResolvedPath {