diff --git a/src/app/imex/local-backup/backup-ring.util.spec.ts b/src/app/imex/local-backup/backup-ring.util.spec.ts index 990224baca..cde2e83959 100644 --- a/src/app/imex/local-backup/backup-ring.util.spec.ts +++ b/src/app/imex/local-backup/backup-ring.util.spec.ts @@ -1,4 +1,8 @@ -import { isUsableBackupStr, selectBestBackupStr } from './backup-ring.util'; +import { + isUsableBackupStr, + selectBestBackupStr, + summarizeBackupStr, +} from './backup-ring.util'; const MEANINGFUL = JSON.stringify({ task: { ids: ['t1'], entities: { t1: { id: 't1', title: 'A task' } } }, @@ -6,6 +10,12 @@ const MEANINGFUL = JSON.stringify({ const MEANINGFUL_2 = JSON.stringify({ task: { ids: ['t2'], entities: { t2: { id: 't2', title: 'Another task' } } }, }); +// More data than MEANINGFUL: two tasks + a project + a note. +const MEANINGFUL_BIG = JSON.stringify({ + task: { ids: ['t1', 't2'], entities: {} }, + project: { ids: ['p1'], entities: {} }, + note: { ids: ['n1'], entities: {} }, +}); // Default/initial state: no tasks, no notes, only structural empties. const EMPTY_STATE = JSON.stringify({ task: { ids: [], entities: {} }, @@ -38,11 +48,45 @@ describe('backup-ring.util', () => { }); }); + describe('summarizeBackupStr', () => { + it('returns null for empty / corrupt blobs', () => { + expect(summarizeBackupStr(null)).toBeNull(); + expect(summarizeBackupStr('')).toBeNull(); + expect(summarizeBackupStr('{not json')).toBeNull(); + }); + + it('counts tasks, projects and notes', () => { + expect(summarizeBackupStr(MEANINGFUL_BIG)).toEqual({ + taskCount: 2, + projectCount: 1, + noteCount: 1, + }); + }); + + it('treats missing collections as zero counts', () => { + expect(summarizeBackupStr(MEANINGFUL)).toEqual({ + taskCount: 1, + projectCount: 0, + noteCount: 0, + }); + }); + }); + describe('selectBestBackupStr', () => { - it('prefers a usable primary over the previous generation', () => { + it('prefers a usable primary over an equally-sized previous generation', () => { expect(selectBestBackupStr(MEANINGFUL, MEANINGFUL_2)).toBe(MEANINGFUL); }); + it('prefers the more complete generation when prev holds more data', () => { + // Post-eviction shape: primary holds a degraded 1-task state, prev still + // holds the full backup — restore must surface the full one (#7901). + expect(selectBestBackupStr(MEANINGFUL, MEANINGFUL_BIG)).toBe(MEANINGFUL_BIG); + }); + + it('keeps the primary when it holds at least as much as prev', () => { + expect(selectBestBackupStr(MEANINGFUL_BIG, MEANINGFUL)).toBe(MEANINGFUL_BIG); + }); + it('falls back to the previous generation when the primary is corrupt', () => { expect(selectBestBackupStr('{broken', MEANINGFUL_2)).toBe(MEANINGFUL_2); }); diff --git a/src/app/imex/local-backup/backup-ring.util.ts b/src/app/imex/local-backup/backup-ring.util.ts index 07eb9c9f78..9a6c0f4c83 100644 --- a/src/app/imex/local-backup/backup-ring.util.ts +++ b/src/app/imex/local-backup/backup-ring.util.ts @@ -1,5 +1,40 @@ import { hasMeaningfulStateData } from '../../op-log/validation/has-meaningful-state-data.util'; +const entityCount = (val: unknown): number => { + const ids = (val as { ids?: unknown })?.ids; + return Array.isArray(ids) ? ids.length : 0; +}; + +/** A human-meaningful summary of a backup blob, for restore prompts and ranking. */ +export interface BackupSummary { + taskCount: number; + projectCount: number; + noteCount: number; +} + +/** + * Parses a backup blob and counts the user-visible entities it holds. Returns + * null for empty/corrupt blobs. Used both to show the user what a backup + * contains before they restore it (#7901) and to rank two ring generations. + */ +export const summarizeBackupStr = ( + str: string | null | undefined, +): BackupSummary | null => { + if (!str) { + return null; + } + try { + const s = JSON.parse(str) as Record; + return { + taskCount: entityCount(s.task), + projectCount: entityCount(s.project), + noteCount: entityCount(s.note), + }; + } catch { + return null; + } +}; + /** * A stored backup blob is "usable" only if it is non-empty, parses as JSON, and * actually contains user data. This is the gate for restoring or counting a @@ -19,12 +54,26 @@ export const isUsableBackupStr = (str: string | null | undefined): boolean => { } }; +/** Total user-visible entity weight, used to rank two usable generations. */ +const backupWeight = (str: string | null | undefined): number => { + const s = summarizeBackupStr(str); + return s ? s.taskCount + s.projectCount + s.noteCount : 0; +}; + /** - * Picks the newest usable backup from the two-generation ring: the primary - * (current) slot first, then the promoted previous generation. If neither slot - * is usable, falls back to whichever raw blob exists so the caller can still - * surface or attempt to parse it explicitly. Returns null only when both slots - * are empty. + * Picks the best backup to restore from the two-generation ring. + * + * When both slots are usable, prefers the one carrying MORE data, tie-breaking + * to the primary (newest). This matters after an eviction: if the live store + * boots near-empty and a 5-min backup writes that degraded state to the primary, + * the ring still holds the full copy in `prev` — and restore must surface the + * full copy, not the newer-but-smaller one. Preferring "more complete" is the + * safe default for an explicit recovery action (the caller shows the user the + * counts first, so a deliberate shrink can still be cancelled). + * + * If only one slot is usable, returns it. If neither is usable, falls back to + * whichever raw blob exists so the caller can still surface or parse it; returns + * null only when both slots are empty. * * See issue #7901. */ @@ -32,10 +81,17 @@ export const selectBestBackupStr = ( primary: string | null | undefined, prev: string | null | undefined, ): string | null => { - if (isUsableBackupStr(primary)) { + const isPrimaryUsable = isUsableBackupStr(primary); + const isPrevUsable = isUsableBackupStr(prev); + if (isPrimaryUsable && isPrevUsable) { + return backupWeight(prev) > backupWeight(primary) + ? (prev as string) + : (primary as string); + } + if (isPrimaryUsable) { return primary as string; } - if (isUsableBackupStr(prev)) { + if (isPrevUsable) { return prev as string; } // Neither slot is usable — return any non-empty raw blob so the caller can diff --git a/src/app/imex/local-backup/local-backup.service.spec.ts b/src/app/imex/local-backup/local-backup.service.spec.ts index a0ddf2ae29..7a077ffabb 100644 --- a/src/app/imex/local-backup/local-backup.service.spec.ts +++ b/src/app/imex/local-backup/local-backup.service.spec.ts @@ -10,6 +10,7 @@ import { ArchiveModel } from '../../features/archive/archive.model'; import { initialTimeTrackingState } from '../../features/time-tracking/store/time-tracking.reducer'; import { CapacitorPlatformService } from '../../core/platform/capacitor-platform.service'; import { AppDataComplete } from '../../op-log/model/model-config'; +import { T } from '../../t.const'; const BACKUP_INTERVAL = 5 * 60 * 1000; @@ -320,6 +321,41 @@ describe('LocalBackupService', () => { })); }); + describe('informed mobile restore prompt (#7901)', () => { + type LocalBackupServiceWithPrompt = { + _restoreMobilePromptMsg: (backupData: string) => string; + }; + + it('names the task and project counts when the backup parses', () => { + translateServiceSpy.instant.and.returnValue('msg'); + const backupData = JSON.stringify({ + task: { ids: ['a', 'b'], entities: {} }, + project: { ids: ['p1'], entities: {} }, + }); + + (service as unknown as LocalBackupServiceWithPrompt)._restoreMobilePromptMsg( + backupData, + ); + + expect(translateServiceSpy.instant).toHaveBeenCalledWith( + T.CONFIRM.RESTORE_FILE_BACKUP_MOBILE, + { tasks: 2, projects: 1 }, + ); + }); + + it('falls back to the generic prompt for an unparseable backup', () => { + translateServiceSpy.instant.and.returnValue('msg'); + + (service as unknown as LocalBackupServiceWithPrompt)._restoreMobilePromptMsg( + '{corrupt', + ); + + expect(translateServiceSpy.instant).toHaveBeenCalledWith( + T.CONFIRM.RESTORE_FILE_BACKUP_ANDROID, + ); + }); + }); + describe('import backup', () => { it('should import with force conflict to reset vector clock', async () => { backupServiceSpy.importCompleteBackup.and.resolveTo(); diff --git a/src/app/imex/local-backup/local-backup.service.ts b/src/app/imex/local-backup/local-backup.service.ts index 734f5cdc07..3c0e1866d1 100644 --- a/src/app/imex/local-backup/local-backup.service.ts +++ b/src/app/imex/local-backup/local-backup.service.ts @@ -14,7 +14,7 @@ import { T } from '../../t.const'; import { TranslateService } from '@ngx-translate/core'; import { AppDataComplete } from '../../op-log/model/model-config'; import { hasMeaningfulStateData } from '../../op-log/validation/has-meaningful-state-data.util'; -import { selectBestBackupStr } from './backup-ring.util'; +import { selectBestBackupStr, summarizeBackupStr } from './backup-ring.util'; import { SnackService } from '../../core/snack/snack.service'; import { Log } from '../../core/log'; import { confirmDialog } from '../../util/native-dialogs'; @@ -123,50 +123,57 @@ export class LocalBackupService { return; } - const backupMeta = await this.checkBackupAvailable(); - // ELECTRON - // -------- - if (IS_ELECTRON && typeof backupMeta !== 'boolean') { - if ( - confirmDialog( - this._translateService.instant(T.CONFIRM.RESTORE_FILE_BACKUP, { - dir: backupMeta.folder, - from: new Date(backupMeta.created).toLocaleString(), - }), - ) - ) { - const backupData = await this.loadBackupElectron(backupMeta.path); - Log.log('backupData loaded from Electron backup'); - await this._importBackup(backupData); - } - - // ANDROID - // ------- - } else if (IS_ANDROID_WEB_VIEW && backupMeta === true) { - if ( - confirmDialog( - this._translateService.instant(T.CONFIRM.RESTORE_FILE_BACKUP_ANDROID), - ) - ) { - // loadBackupAndroid already returns parse-ready (newline-escaped) text. - const backupData = await this.loadBackupAndroid(); - Log.log('backupData loaded from Android, length: ' + backupData.length); - await this._importBackup(backupData); - } - - // iOS - // --- - } else if (this._platformService.isIOS() && backupMeta === true) { - if ( - confirmDialog( - this._translateService.instant(T.CONFIRM.RESTORE_FILE_BACKUP_ANDROID), - ) - ) { - const backupData = await this.loadBackupIOS(); - Log.log('iOS backupData loaded'); - await this._importBackup(backupData); + // ELECTRON — has its own rotated meta (folder + date) in the prompt. + if (IS_ELECTRON) { + const backupMeta = await this.checkBackupAvailable(); + if (typeof backupMeta !== 'boolean') { + if ( + confirmDialog( + this._translateService.instant(T.CONFIRM.RESTORE_FILE_BACKUP, { + dir: backupMeta.folder, + from: new Date(backupMeta.created).toLocaleString(), + }), + ) + ) { + const backupData = await this.loadBackupElectron(backupMeta.path); + Log.log('backupData loaded from Electron backup'); + await this._importBackup(backupData); + } } + return; } + + // MOBILE (Android / iOS) — load the best ring generation first so the prompt + // can tell the user what they would restore (#7901). Loading is cheap and + // lets a blind "discard my data?" dialog become an informed one — they should + // never dismiss the only copy of their data without seeing it exists. + const backupData = IS_ANDROID_WEB_VIEW + ? await this.loadBackupAndroid() + : await this.loadBackupIOS(); + if (!backupData) { + // Nothing usable to restore — stay silent rather than prompt for nothing. + return; + } + if (confirmDialog(this._restoreMobilePromptMsg(backupData))) { + Log.log('mobile backupData loaded, length: ' + backupData.length); + await this._importBackup(backupData); + } + } + + /** + * Builds the mobile restore prompt. When the backup parses, it names the task + * and project counts so the user can judge what they would restore; otherwise + * falls back to the generic prompt. + */ + private _restoreMobilePromptMsg(backupData: string): string { + const summary = summarizeBackupStr(backupData); + if (!summary) { + return this._translateService.instant(T.CONFIRM.RESTORE_FILE_BACKUP_ANDROID); + } + return this._translateService.instant(T.CONFIRM.RESTORE_FILE_BACKUP_MOBILE, { + tasks: summary.taskCount, + projects: summary.projectCount, + }); } private async _backup(): Promise { diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 3912717396..c28c1fdd5a 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -25,6 +25,7 @@ const T = { RELOAD_AFTER_IDB_ERROR: 'CONFIRM.RELOAD_AFTER_IDB_ERROR', RESTORE_FILE_BACKUP: 'CONFIRM.RESTORE_FILE_BACKUP', RESTORE_FILE_BACKUP_ANDROID: 'CONFIRM.RESTORE_FILE_BACKUP_ANDROID', + RESTORE_FILE_BACKUP_MOBILE: 'CONFIRM.RESTORE_FILE_BACKUP_MOBILE', RESTORE_STRAY_BACKUP: 'CONFIRM.RESTORE_STRAY_BACKUP', }, DATETIME_SCHEDULE: { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 17a6a22d3b..c096f804cd 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -25,6 +25,7 @@ "RELOAD_AFTER_IDB_ERROR": "Database Error - App Will Restart\n\nSuper Productivity cannot save data! This is usually caused by:\n• Low disk space (most common)\n• App update in background\n• Linux Snap users: run 'snap set core experimental.refresh-app-awareness=true'\n\nYour recent changes may not have been saved. Please free up disk space if low. The app will restart after you close this dialog.", "RESTORE_FILE_BACKUP": "There seems to be NO DATA, but there are backups available at \"{{dir}}\". Do you want to restore the latest backup from {{from}}?", "RESTORE_FILE_BACKUP_ANDROID": "There seems to be NO DATA, but there is a backup available. Do you want to load it?", + "RESTORE_FILE_BACKUP_MOBILE": "There seems to be NO DATA, but a backup with {{tasks}} tasks and {{projects}} projects is available. Do you want to restore it?", "RESTORE_STRAY_BACKUP": "During the last sync, there may have been an error. Do you want to restore the last backup?" }, "DATETIME_SCHEDULE": {