Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/desktop/e2e/backup-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { _electron as electron, expect, test } from "@playwright/test";
import { maximizeWindow } from "./helpers";

test("an automatic backup can be restored as a new page without replacing the current page", async () => {
test.setTimeout(60_000);
const home = mkdtempSync(join(tmpdir(), "lucid-backup-"));
const app = await electron.launch({
args: [join(__dirname, "../out/main/index.js")],
env: { ...process.env, LUCID_HOME: home },
});
const page = await app.firstWindow();
await maximizeWindow(app, page);

try {
const originalId = await page.evaluate(async () => {
const original = await window.lucid.pages.create("Original");
await window.lucid.pages.save({ ...original, title: "Current" });
return original.id;
});
await page.reload();
await expect(page.getByLabel("Page title")).toHaveValue("Current");

await page.getByRole("button", { name: "Backups" }).click();
const dialog = page.getByRole("dialog", { name: "Automatic backups" });
await expect(dialog).toContainText("never replaces this one");
await expect(dialog).toContainText("Original");
await dialog.getByRole("button", { name: "Restore as copy" }).click();

await expect(page.getByLabel("Page title")).toHaveValue("Original (recovered)");
const state = await page.evaluate(
async (id) => ({
original: await window.lucid.pages.load(id),
pages: (await window.lucid.pages.list()).pages,
}),
originalId,
);
expect(state.original.title).toBe("Current");
expect(state.pages.map((item) => item.title).sort()).toEqual([
"Current",
"Original (recovered)",
]);
} finally {
await app.close();
}
});
9 changes: 9 additions & 0 deletions apps/desktop/electron/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ export function registerIpc(): void {
ipcMain.handle(IPC.pagesSave, async (_e, file: unknown) =>
(await library).save(parseLucidFile(file)),
);
ipcMain.handle(IPC.pagesBackups, async (_e, id: unknown) => {
if (typeof id !== "string") throw new Error("invalid page id");
return (await library).backups(id);
});
ipcMain.handle(IPC.pagesRestoreBackup, async (_e, id: unknown, backupId: unknown) => {
if (typeof id !== "string" || typeof backupId !== "string")
throw new Error("invalid backup request");
return (await library).restoreBackup(id, backupId);
});
registerSecureAiIpc(libraryDir());
const lmDeps = async (): Promise<LmDeps> => {
const s = await loadSettings(libraryDir());
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/electron/ipc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export interface PageProblem {
fileName: string;
message: string;
}
export interface PageBackup {
/** Opaque backup name. It is safe to pass back to `restoreBackup`; it is not a path. */
id: string;
createdAt: string;
title: string;
blockCount: number;
}

/** What the File menu asks of the window. */
export type MenuAction = "export-note" | "import-note";
Expand Down Expand Up @@ -263,6 +270,8 @@ export const IPC = {
pagesLoad: "lucid:pages-load",
pagesSave: "lucid:pages-save",
pagesTrash: "lucid:pages-trash",
pagesBackups: "lucid:pages-backups",
pagesRestoreBackup: "lucid:pages-restore-backup",
pdfCompile: "lucid:pdf-compile",
pdfSave: "lucid:pdf-save",
pdfOpen: "lucid:pdf-open",
Expand Down Expand Up @@ -311,6 +320,9 @@ export interface LucidApi {
load(id: string): Promise<LucidFile>;
save(file: LucidFile): Promise<LucidFile>;
trash(id: string): Promise<void>;
backups(id: string): Promise<{ backups: PageBackup[]; problems: PageProblem[] }>;
/** Restores an automatic snapshot as a new page, without replacing the current page. */
restoreBackup(id: string, backupId: string): Promise<LucidFile>;
};
settings: {
get(): Promise<PublicSettings>;
Expand Down
54 changes: 53 additions & 1 deletion apps/desktop/electron/library.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtemp, readFile, readdir, utimes, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, readdir, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LucidFileError } from "@lucid/core";
Expand Down Expand Up @@ -156,6 +156,58 @@ describe("Library: save", () => {
});
});

describe("Library: backup recovery", () => {
it("lists valid snapshots newest first with useful metadata", async () => {
const l = lib({ backupEveryMs: 0 });
const page = await l.create("Original");
await l.save({ ...page, title: "First edit" });
tick(1000);
await l.save({ ...page, title: "Second edit" });

const result = await l.backups(page.id);
expect(result.problems).toEqual([]);
expect(result.backups.map((backup) => backup.title)).toEqual(["First edit", "Original"]);
expect(result.backups[0]).toEqual(
expect.objectContaining({
id: "2026-10-10T10-00-01-000Z.lucid",
createdAt: "2026-10-10T10:00:01.000Z",
blockCount: 0,
}),
);
});

it("restores a snapshot as a new page without changing the current page", async () => {
const l = lib({ backupEveryMs: 0 });
const page = await l.create("Original");
const current = await l.save({ ...page, title: "Current" });
const backup = (await l.backups(page.id)).backups[0];
expect(backup).toBeDefined();

const restored = await l.restoreBackup(page.id, backup?.id ?? "");
expect(restored.id).not.toBe(page.id);
expect(restored.title).toBe("Original (recovered)");
expect((await l.load(page.id)).title).toBe(current.title);
expect((await l.list()).pages).toHaveLength(2);
});

it("reports damaged snapshots without touching them and refuses unknown backup names", async () => {
const l = lib();
const page = await l.create("Original");
const backupDir = join(dir, ".lucid", "backups", page.id);
await mkdir(backupDir, { recursive: true });
const damaged = join(backupDir, "2026-10-10T10-00-00-000Z.lucid");
await writeFile(damaged, "{ broken", "utf8");

const result = await l.backups(page.id);
expect(result.backups).toEqual([]);
expect(result.problems).toHaveLength(1);
expect(await readFile(damaged, "utf8")).toBe("{ broken");
await expect(l.restoreBackup(page.id, "../outside.lucid")).rejects.toThrow(
/no longer available/,
);
});
});

describe("Library: trash and damaged files", () => {
it("moves a page to the Trash instead of deleting it", async () => {
const l = lib();
Expand Down
72 changes: 70 additions & 2 deletions apps/desktop/electron/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ export interface Problem {
message: string;
}

export interface PageBackup {
/** Opaque backup name used to restore this snapshot; never a filesystem path. */
id: string;
createdAt: string;
title: string;
blockCount: number;
}

export interface LibraryOptions {
now?: () => Date;
/** Move a file to the Trash (the app passes Electron's shell.trashItem). Never hard-deletes. */
Expand All @@ -49,7 +57,7 @@ export interface LibraryOptions {

/** Backups are named with their time (`2026-10-10T10-00-00-000Z.lucid`); read it back. Unreadable names count as old. */
function backupTime(fileName: string): number {
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/.exec(fileName);
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z\.lucid$/.exec(fileName);
return m
? Date.parse(`${m[1] ?? ""}T${m[2] ?? ""}:${m[3] ?? ""}:${m[4] ?? ""}.${m[5] ?? ""}Z`)
: 0;
Expand All @@ -73,6 +81,11 @@ export class Library {
return join(this.dir, fileName);
}

private backupDir(id: string): string {
if (!/^[a-z0-9-]+$/i.test(id)) throw new Error("invalid page id");
return join(this.dir, ".lucid", "backups", id);
}

private async ensure(): Promise<void> {
await mkdir(this.dir, { recursive: true });
}
Expand Down Expand Up @@ -150,6 +163,61 @@ export class Library {
return this.read(name);
}

/** Valid automatic snapshots for a page, newest first. Damaged snapshots are reported and left untouched. */
async backups(id: string): Promise<{ backups: PageBackup[]; problems: Problem[] }> {
// A renderer cannot use this endpoint to explore arbitrary backup folders.
if (!(await this.findFileName(id))) throw new Error("That page doesn't exist any more.");
const dir = this.backupDir(id);
let names: string[];
try {
names = (await readdir(dir, { withFileTypes: true }))
.filter((entry) => entry.isFile() && backupTime(entry.name) > 0)
.map((entry) => entry.name)
.sort()
.reverse();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { backups: [], problems: [] };
throw error;
}

const backups: PageBackup[] = [];
const problems: Problem[] = [];
for (const name of names) {
try {
const raw = JSON.parse(await readFile(join(dir, name), "utf8")) as unknown;
const file = parseLucidFile(raw);
registry.validateDoc(file.doc);
if (file.id !== id) throw new Error("This backup belongs to another page.");
backups.push({
id: name,
createdAt: new Date(backupTime(name)).toISOString(),
title: file.title,
blockCount: file.doc.blocks.length,
});
} catch (error) {
problems.push({
fileName: name,
message: error instanceof Error ? error.message : String(error),
});
}
}
return { backups, problems };
}

/** Restores one automatic snapshot as a new page. The current page and its history are never overwritten. */
async restoreBackup(id: string, backupId: string): Promise<LucidFile> {
const available = await this.backups(id);
if (!available.backups.some((backup) => backup.id === backupId))
throw new Error("That backup is no longer available.");
const raw = JSON.parse(await readFile(join(this.backupDir(id), backupId), "utf8")) as unknown;
const snapshot = parseLucidFile(raw);
registry.validateDoc(snapshot.doc);
if (snapshot.id !== id) throw new Error("This backup belongs to another page.");
const existing = new Set((await this.list()).pages.map((page) => page.id));
const { file } = planImport(snapshot, existing);
return this.save({ ...file, title: `${file.title.replace(/ \(copy\)$/, "")} (recovered)` });
}

/** Save a page. The title decides the file name; when it changes the page moves to its new name. */
async save(file: LucidFile): Promise<LucidFile> {
const existing = await this.findFileName(file.id);
Expand Down Expand Up @@ -200,7 +268,7 @@ export class Library {
}

private async backupIfDue(fileName: string, id: string): Promise<void> {
const dir = join(this.dir, ".lucid", "backups", id.replace(/[^a-z0-9-]/gi, ""));
const dir = this.backupDir(id);
await mkdir(dir, { recursive: true });
const names = (await readdir(dir)).filter((n) => n.endsWith(".lucid")).sort();
const last = names.at(-1);
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ const api: LucidApi = {
save: (file) =>
ipcRenderer.invoke(IPC.pagesSave, file) as ReturnType<LucidApi["pages"]["save"]>,
trash: (id) => ipcRenderer.invoke(IPC.pagesTrash, id) as Promise<void>,
backups: (id) =>
ipcRenderer.invoke(IPC.pagesBackups, id) as ReturnType<LucidApi["pages"]["backups"]>,
restoreBackup: (id, backupId) =>
ipcRenderer.invoke(IPC.pagesRestoreBackup, id, backupId) as ReturnType<
LucidApi["pages"]["restoreBackup"]
>,
},
note: {
export: (file, options) =>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ function Shell() {
key={pages.current.id}
file={pages.current}
onSaved={pages.saved}
onRestoreBackup={pages.restoreBackup}
sidebarOpen={sidebarOpen}
onToggleSidebar={toggleSidebar}
/>
Expand Down
Loading
Loading