Files
plainleaf/client/components/plainleaf_backup.ts
T
wushenghua 9e43ae824a
CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s
feat: complete task planning and account management
Add Markdown-native planning, repeat rules, subtasks, reminders, transactional backup restore, Chinese account management, and the account-managed NAS migration configuration.\n\nCo-Authored-By: Codex <noreply@anthropic.com>
2026-08-12 20:22:33 +08:00

111 lines
3.4 KiB
TypeScript

export const plainleafBackupFormat = "plainleaf-markdown-backup-v1";
export type PlainleafMarkdownBackup = {
format: typeof plainleafBackupFormat;
exportedAt: string;
files: Array<{ path: string; text: string }>;
};
export type PlainleafMarkdownStorage = {
listPaths: () => Promise<string[]>;
read: (path: string) => Promise<string>;
write: (path: string, text: string) => Promise<void>;
remove: (path: string) => Promise<void>;
};
export function createPlainleafBackup(
files: Array<{ path: string; text: string }>,
exportedAt = new Date().toISOString(),
): PlainleafMarkdownBackup {
return { format: plainleafBackupFormat, exportedAt, files };
}
export function parsePlainleafBackup(source: string): PlainleafMarkdownBackup {
let value: unknown;
try {
value = JSON.parse(source);
} catch {
throw new Error("备份包不是有效的 JSON 文件");
}
const backup = value as Partial<PlainleafMarkdownBackup>;
if (backup.format !== plainleafBackupFormat || !Array.isArray(backup.files)) {
throw new Error("不是有效的 Plainleaf Markdown 备份包");
}
const paths = new Set<string>();
const files = backup.files.map((entry) => {
const parts =
entry && typeof entry.path === "string" ? entry.path.split("/") : [];
if (
!entry ||
typeof entry.path !== "string" ||
typeof entry.text !== "string" ||
!entry.path.toLocaleLowerCase().endsWith(".md") ||
entry.path.startsWith("/") ||
entry.path.includes("\\") ||
entry.path.includes("\0") ||
parts.some((part) => !part || part === "." || part === "..")
) {
throw new Error("备份包中包含无效的 Markdown 路径");
}
if (paths.has(entry.path)) {
throw new Error(`备份包中包含重复路径:${entry.path}`);
}
paths.add(entry.path);
return { path: entry.path, text: entry.text };
});
return {
format: plainleafBackupFormat,
exportedAt:
typeof backup.exportedAt === "string"
? backup.exportedAt
: new Date(0).toISOString(),
files,
};
}
export async function restorePlainleafBackup(
backup: PlainleafMarkdownBackup,
storage: PlainleafMarkdownStorage,
): Promise<number> {
if (!backup.files.length) {
throw new Error("备份包中没有 Markdown 文件");
}
const existing = new Set(await storage.listPaths());
const snapshots = await Promise.all(
backup.files.map(async (entry) => ({
path: entry.path,
existed: existing.has(entry.path),
text: existing.has(entry.path) ? await storage.read(entry.path) : "",
})),
);
const written: typeof snapshots = [];
try {
for (let index = 0; index < backup.files.length; index++) {
const entry = backup.files[index];
await storage.write(entry.path, entry.text);
written.push(snapshots[index]);
}
} catch (error) {
const rollbackErrors: unknown[] = [];
for (const snapshot of [...written].reverse()) {
try {
if (snapshot.existed) {
await storage.write(snapshot.path, snapshot.text);
} else {
await storage.remove(snapshot.path);
}
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (rollbackErrors.length) {
throw new Error(
"恢复失败且自动回滚未完全成功,请立即使用原备份核对文件",
{ cause: error },
);
}
throw error;
}
return backup.files.length;
}