feat(local-backup): restore fuller generation, show contents (#7901)

Two restore-path improvements (issue #7901 item 4):

- selectBestBackupStr now prefers the ring generation carrying MORE
  data when both slots are usable (tie -> primary). After an eviction
  the live store can boot near-empty and a 5-min backup may write that
  degraded state to the primary; the full copy survives in prev, and
  restore must surface the full one rather than the newer-but-smaller.

- The mobile restore prompt loads the backup first and names its task
  and project counts (new RESTORE_FILE_BACKUP_MOBILE string), so a user
  never blindly discards the only copy of their data. Falls back to the
  generic prompt for an unparseable blob.

Note: a 'defer backups until the restore decision resolves' guard was
considered but is already structurally guaranteed -- init() (the only
thing starting the 5-min timer) runs after the synchronous restore
confirm in _initBackups, so the timer cannot fire before the decision.
This commit is contained in:
Johannes Millan 2026-06-01 21:16:40 +02:00
parent 4ff0e6f27b
commit d1e03e550a
6 changed files with 197 additions and 52 deletions

View file

@ -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);
});

View file

@ -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<string, unknown>;
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

View file

@ -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();

View file

@ -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<void> {

View file

@ -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: {

View file

@ -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": {