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
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>
111 lines
3.4 KiB
TypeScript
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;
|
|
}
|