mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
4ff0e6f27b
commit
d1e03e550a
6 changed files with 197 additions and 52 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue