From d8968c8d12391102e0e6d976f690a83551eef610 Mon Sep 17 00:00:00 2001 From: ceynri Date: Fri, 2 Oct 2026 16:43:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E7=9A=84=E6=95=B0=E6=8D=AE=E5=AE=89=E5=85=A8=E4=B8=8E?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=B1=82=E6=AD=A3=E7=A1=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同名笔记加 id 后缀避免互相覆盖;首次 sync 认领已有 export 文件; 删除只进回收站;部分失败写入 data.errors 并以退出码 2 结束; 本地子目录映射云端文件夹,标题取文件名;过期计划跳过不覆盖; export 增量缓存;附件下载失败不再毁掉正文。 --- CHANGELOG.md | 18 +- README.en.md | 13 +- README.md | 13 +- skills/mi-note-cli/SKILL.md | 5 + src/cli.ts | 2 + src/commands/export.ts | 3 +- src/commands/sync.ts | 84 +++-- src/output.ts | 17 +- src/sync-diff.ts | 41 ++- src/sync.ts | 615 ++++++++++++++++++++++++++++++------ src/text-diff.ts | 76 +++++ src/types.ts | 2 + tests/sync-diff.test.ts | 2 +- tests/sync.test.ts | 506 +++++++++++++++++++++++++++++ tests/text-diff.test.ts | 29 ++ 15 files changed, 1295 insertions(+), 131 deletions(-) create mode 100644 src/text-diff.ts create mode 100644 tests/sync.test.ts create mode 100644 tests/text-diff.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ef5a7a9..02dd02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,18 +8,31 @@ ### Added -- 引用本地图片文件(`![](./photo.jpg)`)在 `create` / `update` 时自动上传,并登记为笔记附件,无需先运行 `upload-image` +- 引用本地图片文件(`![](./photo.jpg)`)在 `create` / `update` / `sync` 上行时自动上传,并登记为笔记附件,无需先运行 `upload-image` +- 交互式处理同步冲突时可按 `d` 查看云端与本地的差异 +- 在本地子目录新建的笔记同步时建到同名云端文件夹,不存在则自动创建;云端标题取文件名 +- `export` 增量缓存(放在全局缓存目录):云端未变且本地未被改动的笔记不再逐条拉详情,`--force` 全量重来 ### Changed +- 同步删除云端笔记改为移到回收站,不再永久删除 +- `export` / `sync` 部分条目失败时在 `data.errors` 列出失败项,并以退出码 2 结束 - `--limit` 必须是正整数,非法值直接报错 -- 找不到可用浏览器时提示安装 Chrome 或运行 `npx playwright install chromium` - `create` / `update` 从 `--file` 读取内容时,相对图片路径基于该文件所在目录 +- 找不到可用浏览器时提示安装 Chrome 或运行 `npx playwright install chromium` ### Fixed - 本地修改带图笔记后同步上行,或 `get` → 修改 → `update` 时,图片被写成纯文本导致云端丢图;无法回写的附件引用现在会报错而不是静默丢失 +- 同名笔记落盘到同一个文件,`export` 时互相覆盖,`sync` 时可能把一条笔记的内容上传覆盖另一条;同名时改为加 `_` 后缀,并自动修复旧版本留下的错误状态 +- 子目录(云端文件夹)里的笔记导出后图片引用是 `assets/...`,Markdown 查看器中无法显示;改为按目录深度写 `../assets/` 等相对前缀,旧文件在下次同步时自动修复 - 云端笔记 `setting.data` 里未登记附件的图片在导出时丢图;现在保留为 `minote://image/` 引用,仍可正常回写 +- 附件下载失败时 `sync` 会删掉已写好的正文、反复重试;改为正文照常落盘并在 `data.errors` 中报告附件失败 +- `export` 附件下载失败的笔记会被记入缓存、之后永远跳过;改为失败不进缓存,下次重试 +- 在已有 `export` 产物的目录上首次 `sync --mode two-way` 会把所有笔记在云端重复创建一份 +- `local-first` 模式或交互选择「以本地为准」时,「云端已删、本地已改」的笔记未能在云端重建 +- 生成同步计划后云端或本地又被修改时,执行阶段会覆盖掉这次修改 +- `--json` 模式下同步单条失败完全不可见 ### Security @@ -28,6 +41,7 @@ ### Internal - 新增 GitHub Actions CI(类型检查、单测、构建) +- 新增同步执行层单测(内存假客户端) ## [0.3.1] - 2026-06-22 [[compare]](https://github.com/ceynri/mi-note-cli/compare/v0.3.0...v0.3.1) diff --git a/README.en.md b/README.en.md index 8433e53..878631b 100644 --- a/README.en.md +++ b/README.en.md @@ -79,7 +79,7 @@ All commands support the global `--json` flag, producing `{ ok, data }` / `{ ok: | | `export` | `sync --mode cloud-first` | |---|---|---| | Direction | Cloud → local only | Can be bidirectional (cloud-first = download only) | -| State file | None, pure download | Creates `.mi-note-cli.state.json` to track baseline | +| State file | None (incremental cache lives in the global cache dir, safe to delete) | Creates `.mi-note-cli.state.json` to track baseline | | Deletes local files | No (local files persist even if deleted on cloud) | Yes (cloud is authoritative, removes local files for deleted notes) | | Full re-download | `--force` | Delete the output dir and re-run | | Use case | One-time backup / snapshot | Ongoing sync | @@ -99,6 +99,16 @@ Conflicts (both-changed / one-side-deleted-other-changed): `cloud-first`/`local- Use `sync init` to set a default mode interactively; then `sync` can omit `--mode`. `sync --dry-run` previews without executing. +Data-safety rules during sync: + +- **Same-named notes never overwrite each other**: when several notes map to the same file name in a folder, one keeps the name and the rest get an `_` suffix (`export` always lets the earliest-created note keep the plain name). +- **Existing files are adopted first**: the first `sync` over a directory that already has Markdown files (e.g. from `export`) treats a file at a note's target path as that note — identical content is recorded as in sync, differing content becomes a conflict; nothing is uploaded twice. +- **Deletions go to the recycle bin**: deleting a local file moves the cloud note to the recycle bin (restorable on the web for 30 days); sync never purges. +- **Attachments are never corrupted**: image references in local notes are restored to the original images on upload; references to local image files are uploaded automatically. An image inline with text, a missing file, or audio/video attachments make that item fail and get skipped instead of turning attachments into plain text. +- **Local subdirectories map to cloud folders**: new files in a subdirectory are created in the cloud folder with the same name, which is created if missing; the file name becomes the cloud title. +- **Stale plans are not applied**: if either side changes after the plan is built (e.g. while you answer a prompt), that item is skipped and you're asked to re-run sync. +- When resolving a conflict interactively, press `d` to see the cloud vs. local diff. + ## Embedding Images ```bash @@ -206,6 +216,7 @@ The project is in early stages — for any conversion oddities, missing features 1. Always pass `--json` for parseable output. 2. In non-interactive contexts (no TTY), an unauthenticated call fails immediately instead of hanging on browser login — have a human run `login` once first. 3. For destructive actions pass `-y`; in non-interactive sync, real conflicts are skipped without touching data. +4. When some items of `export` / `sync` fail, the output is still `ok: true` with the failures listed in `data.errors`, and the process exits with code 2. Only a total failure yields `ok: false` and exit code 1. --- diff --git a/README.md b/README.md index 29f0423..5eca97d 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ mi-note-cli sync -o ./notes --mode two-way # 5. 与本地双向同步 | | `export` | `sync --mode cloud-first` | |---|---|---| | 方向 | 仅云→本地 | 可双向(cloud-first 时仅下行) | -| 状态文件 | 无,纯下载 | 创建 `.mi-note-cli.state.json` 跟踪基线 | +| 状态文件 | 无(增量缓存在全局缓存目录,可删) | 创建 `.mi-note-cli.state.json` 跟踪基线 | | 删本地文件 | 不会(云端删了本地保留) | 会(云端为准,删掉云端已不存在的本地文件) | | 全量重下 | `--force` | 删 output 目录后重跑 | | 适用场景 | 一次性备份/快照 | 持续同步 | @@ -99,6 +99,16 @@ mi-note-cli sync -o ./notes --mode two-way # 5. 与本地双向同步 用 `sync init` 交互设置默认模式,之后 `sync` 可省略 `--mode`。`sync --dry-run` 只预览不执行。 +同步中的数据安全约定: + +- **同名笔记不互相覆盖**:同一文件夹下落盘文件名相同的笔记,一条保留原名,其余加 `_<笔记id>` 后缀(`export` 固定由创建最早的保留原名)。 +- **已有文件先认领**:在已有 Markdown 的目录(例如先 `export` 过)上首次 `sync`,落盘位置已有同名文件时视为同一篇笔记:内容一致直接记为已同步,不一致按冲突处理,不会重复上传。 +- **删除只进回收站**:本地删掉文件同步到云端时,云端笔记移到回收站(30 天内可在网页端恢复),不会永久删除。 +- **附件不会被写坏**:本地笔记中的图片引用在上行时还原为原图片;引用本地图片文件会自动上传;图片与文字同行、找不到文件、或包含音频/视频附件时,该条会报错跳过,而不是把附件变成纯文本。 +- **本地子目录对应云端文件夹**:在子目录里新建的文件会建到同名云端文件夹,没有则自动创建;云端标题取文件名。 +- **过期计划不执行**:生成计划后(例如交互询问期间)任一端又被改动,该条跳过并提示重新同步。 +- 交互询问冲突时按 `d` 可查看云端与本地的差异。 + ## 在笔记中插入图片 ```bash @@ -206,6 +216,7 @@ ${YYYY}-${MM}-${DD}[_${title}] 1. 始终加 `--json`,输出可直接解析。 2. 非交互环境(无 TTY)未登录会立即返回错误而非卡浏览器登录,请先人工 `login` 一次。 3. 删除等危险操作显式加 `-y`;sync 在非交互下真冲突会跳过不动数据。 +4. `export` / `sync` 部分条目失败时仍输出 `ok: true`,失败项列在 `data.errors`,进程退出码为 2;整体失败才是 `ok: false`、退出码 1。 --- diff --git a/skills/mi-note-cli/SKILL.md b/skills/mi-note-cli/SKILL.md index 238e2b1..9a3f789 100644 --- a/skills/mi-note-cli/SKILL.md +++ b/skills/mi-note-cli/SKILL.md @@ -95,6 +95,11 @@ npx mi-note-cli sync status --json # 查看配置与各目录 基于「上次同步基线 / 云端现状 / 本地现状」三方对比。冲突时:有优先方的模式自动解决;`two-way`/`manual` 在非交互环境**跳过并报告,绝不擅自删数据**(需交互或 `-y`)。 +- 同步删除云端笔记只移到回收站,不会永久删除。 +- 本地新建笔记的云端标题取文件名;文件所在子目录对应同名云端文件夹。 +- 同名笔记落盘时,除一条外其余文件名带 `_<笔记id>` 后缀;按 id 定位笔记时以状态文件和 `get` 为准,不要凭文件名推断。 +- 部分条目失败时仍返回 `ok: true`,失败项在 `data.errors`,退出码为 2。每次同步后都要检查 `data.errors` 和 `data.conflicts`,向用户报告。 + 遇到其他场景,优先 `npx mi-note-cli --help` 查阅选项(--help 含输出约定、模式说明、示例)。 ## 内容格式 diff --git a/src/cli.ts b/src/cli.ts index fc2587e..24f16db 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -56,6 +56,7 @@ program.addHelpText( 输出约定: 全局 --json 下,所有命令统一输出 JSON:成功 {"ok":true,"data":...},失败 {"ok":false,"error":"..."}。 失败时进程以非零码退出。日志/进度走 stderr,结构化结果走 stdout,可安全用管道解析。 + export / sync 部分条目失败时仍输出 ok:true,失败项在 data.errors,退出码为 2。 笔记内容格式: 读取(get)默认把小米笔记转成 Markdown;写入(create/update)接受 Markdown,自动转回小米格式。 @@ -239,6 +240,7 @@ const sync = program cloud-first/local-first 已声明优先方,冲突按优先方自动解决; two-way/manual 遇到「双改」「一端删另一端改」等真冲突会停下:交互式询问, 非交互(--json/无 TTY)则跳过该条并在结果里报告,绝不擅自删数据。 + 交互询问时可按 d 查看云端与本地的差异。同步删除云端笔记只移到回收站。 示例: mi-note-cli sync -o ./notes --dry-run # 预览将发生什么 diff --git a/src/commands/export.ts b/src/commands/export.ts index e54a693..cef60d1 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -1,7 +1,7 @@ import { getClient } from "./shared.js"; import { exportNotes } from "../sync.js"; import { resolveOutputDir } from "../config.js"; -import { isJsonMode, success, fail } from "../output.js"; +import { isJsonMode, success, fail, EXIT_PARTIAL_FAILURE } from "../output.js"; interface ExportOptions { output?: string; @@ -22,6 +22,7 @@ export async function exportCommand(opts: ExportOptions): Promise { success(result, () => { // 过程已打印汇总 }); + if (result.errors.length > 0) process.exitCode = EXIT_PARTIAL_FAILURE; } catch (err) { fail(err); } diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 7ea451c..4061721 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -10,12 +10,22 @@ import { setSyncMode, loadUserConfig, loadState, + saveState, getUserConfigPath, getStatePath, ALL_MODES, } from "../config.js"; -import { isJsonMode, success, logInfo, fail } from "../output.js"; +import { + isJsonMode, + success, + logInfo, + fail, + prompt, + EXIT_PARTIAL_FAILURE, +} from "../output.js"; import { createInterface } from "node:readline"; +import { actionForSide } from "../sync-diff.js"; +import { diffLines, formatDiff } from "../text-diff.js"; import type { SyncMode } from "../types.js"; import type { SyncAction } from "../sync-diff.js"; @@ -44,6 +54,19 @@ const SCENARIO_DESC: Record = { "local-deleted-remote-clean": "本地已删除", }; +/** 动作的人类可读说明(冲突询问时展示每个选项的实际后果) */ +const ACTION_DESC: Record = { + skip: "不做处理", + "update-local": "用云端内容覆盖本地文件", + "update-remote": "用本地内容覆盖云端笔记", + "create-local": "从云端重新生成本地文件", + "create-remote": "在云端新建笔记", + "delete-local": "删除本地文件", + "delete-remote": "把云端笔记移到回收站", + "drop-state": "清理同步记录", + conflict: "待定", +}; + /** sync 主命令 */ export async function syncCommand(opts: SyncOptions): Promise { try { @@ -59,7 +82,7 @@ export async function syncCommand(opts: SyncOptions): Promise { const client = await getClient(); logInfo(`🔁 同步模式:${mode}(${MODE_DESC[mode]})`); - const { plan, state, folders } = await buildSyncPlan( + const { plan, state, folders, stateChanged } = await buildSyncPlan( client, outputDir, mode, @@ -67,6 +90,11 @@ export async function syncCommand(opts: SyncOptions): Promise { ); if (plan.length === 0) { + // 首次同步认领了已有本地文件等情况:虽无动作,但基线需要落盘 + if (stateChanged && !opts.dryRun) { + state.lastSync = Date.now(); + await saveState(outputDir, state); + } success({ outputDir, mode, changes: 0 }, () => { logInfo("✅ 已是最新,无需同步"); }); @@ -109,7 +137,14 @@ export async function syncCommand(opts: SyncOptions): Promise { } logInfo(" 可在交互式终端重跑 sync 逐条处理,或指定 --mode 决定优先方。"); } + if (result.errors.length > 0) { + logInfo(`\n❌ ${result.errors.length} 条处理失败:`); + for (const e of result.errors) { + logInfo(` [${e.id}] ${e.subject} — ${e.action}: ${e.error}`); + } + } }); + if (result.errors.length > 0) process.exitCode = EXIT_PARTIAL_FAILURE; } catch (err) { fail(err); } @@ -191,37 +226,30 @@ function planSummary(plan: SyncPlanItem[]) { /** 构造交互式冲突解决器 */ function makeInteractiveResolver() { return async (item: SyncPlanItem): Promise => { + const localAction = actionForSide(item.scenario, "local"); + const remoteAction = actionForSide(item.scenario, "remote"); logInfo(`\n⚠️ 冲突 [${item.id}] ${item.subject}:${describe(item)}`); logInfo(" 选择处理方式:"); - logInfo(" (l) 以本地为准(上行覆盖云端)"); - logInfo(" (r) 以云端为准(下行覆盖本地)"); + logInfo(` (l) 以本地为准:${ACTION_DESC[localAction]}`); + logInfo(` (r) 以云端为准:${ACTION_DESC[remoteAction]}`); + logInfo(" (d) 查看差异"); logInfo(" (s) 跳过"); - const ans = (await confirmChoice(" 你的选择 [l/r/s,默认 s]: ")).toLowerCase(); - if (ans === "l") { - return item.scenario === "local-deleted-remote-changed" - ? "delete-remote" - : "update-remote"; - } - if (ans === "r") { - return item.scenario === "remote-deleted-local-changed" - ? "delete-local" - : "update-local"; + while (true) { + const ans = (await prompt(" 你的选择 [l/r/d/s,默认 s]: ")).toLowerCase(); + if (ans === "d") { + logInfo(renderConflictDiff(item)); + continue; + } + if (ans === "l") return localAction; + if (ans === "r") return remoteAction; + return "skip"; } - return "skip"; }; } -/** 读取单行选择(复用 stdin) */ -function confirmChoice(question: string): Promise { - return new Promise((resolve) => { - process.stderr.write(question); - const onData = (data: string): void => { - process.stdin.pause(); - process.stdin.off("data", onData); - resolve(data.trim()); - }; - process.stdin.resume(); - process.stdin.setEncoding("utf-8"); - process.stdin.on("data", onData); - }); +function renderConflictDiff(item: SyncPlanItem): string { + const header = " --- 云端\n +++ 本地"; + if (item.remoteMarkdown === undefined) return `${header}\n (云端不存在,本地内容如下)\n${item.localMarkdown ?? ""}`; + if (item.localMarkdown === undefined) return `${header}\n (本地不存在,云端内容如下)\n${item.remoteMarkdown}`; + return `${header}\n${formatDiff(diffLines(item.remoteMarkdown, item.localMarkdown))}`; } diff --git a/src/output.ts b/src/output.ts index 0de8818..6285319 100644 --- a/src/output.ts +++ b/src/output.ts @@ -58,8 +58,8 @@ export function fail(error: unknown): never { process.exit(1); } -/** 命令行 y/N 确认提示(问题走 stderr,不污染 stdout) */ -export function confirm(question: string): Promise { +/** 读取一行用户输入(问题走 stderr,不污染 stdout) */ +export function prompt(question: string): Promise { return new Promise((resolve) => { process.stderr.write(question); const stdin = process.stdin; @@ -67,13 +67,24 @@ export function confirm(question: string): Promise { const onData = (data: string) => { stdin.pause(); stdin.off("data", onData); - resolve(data.trim().toLowerCase() === "y"); + resolve(data.trim()); }; stdin.resume(); stdin.on("data", onData); }); } +/** 命令行 y/N 确认提示 */ +export async function confirm(question: string): Promise { + return (await prompt(question)).toLowerCase() === "y"; +} + +/** + * 部分条目失败时的退出码:结果仍按 ok:true 输出(data.errors 列出失败项), + * 但以非零码退出,便于脚本用 `&&` / `$?` 感知。整体失败走 fail(),退出码为 1。 + */ +export const EXIT_PARTIAL_FAILURE = 2; + /** * 从 stdin 读取全部输入(用于 create/update 从管道接收内容)。 * 若 stdin 是 TTY(无管道输入)返回 null。 diff --git a/src/sync-diff.ts b/src/sync-diff.ts index c25d505..6ab9d56 100644 --- a/src/sync-diff.ts +++ b/src/sync-diff.ts @@ -159,16 +159,45 @@ export function decide(scenario: Scenario, mode: SyncMode): SyncAction { function resolveConflict(scenario: Scenario, mode: SyncMode): SyncAction { switch (mode) { case "cloud-first": - // 云端优先 - if (scenario === "remote-deleted-local-changed") return "delete-local"; // 云端已删→本地也删 - return "update-local"; // both-changed / local-deleted-remote-changed → 取云端 + return actionForSide(scenario, "remote"); case "local-first": - // 本地优先 - if (scenario === "local-deleted-remote-changed") return "delete-remote"; // 本地已删→云端也删 - return "update-remote"; // both-changed / remote-deleted-local-changed → 取本地 + return actionForSide(scenario, "local"); case "two-way": case "manual": default: return "conflict"; // 交由调用方:交互询问或非交互跳过报告 } } + +const LOCAL_WINS: Partial> = { + "local-changed": "update-remote", + "both-changed": "update-remote", + "local-new": "create-remote", + // 云端已删:本地为准只能在云端重建 + "remote-deleted-local-clean": "create-remote", + "remote-deleted-local-changed": "create-remote", + "local-deleted-remote-clean": "delete-remote", + "local-deleted-remote-changed": "delete-remote", +}; + +const REMOTE_WINS: Partial> = { + "remote-changed": "update-local", + "both-changed": "update-local", + "remote-new": "create-local", + "local-deleted-remote-clean": "create-local", + "local-deleted-remote-changed": "update-local", + "remote-deleted-local-clean": "delete-local", + "remote-deleted-local-changed": "delete-local", +}; + +/** + * 以某一侧为准时该场景应执行的动作;该侧没有可传播的内容时返回 skip + * (例如本地新增文件「以云端为准」不删本地,只是不上传)。 + */ +export function actionForSide( + scenario: Scenario, + side: "local" | "remote", +): SyncAction { + const table = side === "local" ? LOCAL_WINS : REMOTE_WINS; + return table[scenario] ?? "skip"; +} diff --git a/src/sync.ts b/src/sync.ts index 09f29f7..47354d2 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,16 +1,26 @@ import { readFile, writeFile, rm, readdir } from "node:fs/promises"; import { createHash } from "node:crypto"; -import { join, resolve as resolvePath } from "node:path"; -import { MiNoteClient } from "./client.js"; +import { createRequire } from "node:module"; +import { basename, dirname, join, relative, sep, resolve as resolvePath } from "node:path"; +import type { MiNoteClient } from "./client.js"; import { parseNoteEntry, xmlToMarkdown, markdownToXml, extractSnippet, getNoteFilePath, + buildImageMap, } from "./converter.js"; +import { uploadLocalImages, withAttachments } from "./images.js"; import { buildExtraInfoString } from "./note.js"; -import { randomDelay, ensureFileDir, fileExists, ensureDir } from "./utils.js"; +import { + randomDelay, + ensureFileDir, + fileExists, + ensureDir, + sanitizeFileName, + getCacheDir, +} from "./utils.js"; import { logInfo } from "./output.js"; import { loadUserConfig, @@ -21,6 +31,7 @@ import { } from "./config.js"; import { classify, decide } from "./sync-diff.js"; import type { + ParsedNote, RawNoteEntry, RawFolderEntry, SyncState, @@ -31,46 +42,184 @@ import type { import type { Scenario, SyncAction } from "./sync-diff.js"; const SAVE_INTERVAL = 10; +const TOOL_VERSION = ( + createRequire(import.meta.url)("../package.json") as { version: string } +).version; + +/** export / sync 实际用到的客户端能力(便于测试注入假实现) */ +export type SyncClient = Pick< + MiNoteClient, + | "getAllNotes" + | "getNote" + | "createNote" + | "updateNote" + | "deleteNote" + | "createFolder" + | "downloadFile" + | "uploadImage" +>; export function computeHash(content: string): string { return createHash("sha256").update(content, "utf-8").digest("hex"); } +// ============================================================ +// 落盘路径:同名笔记去重 +// ============================================================ + +/** 路径比较键:macOS / Windows 默认文件系统大小写不敏感 */ +function pathKey(p: string): string { + const abs = resolvePath(p); + return process.platform === "darwin" || process.platform === "win32" + ? abs.toLowerCase() + : abs; +} + +/** + * 笔记文件里附件引用的前缀:附件统一存放在 `/assets/`, + * 子目录(云端文件夹)里的笔记需要 `../assets/` 才能被 Markdown 查看器正确解析。 + */ +export function assetPrefixFor(filePath: string, outputDir: string): string { + const rel = relative(dirname(filePath), join(outputDir, "assets")); + return `${rel.split(sep).join("/")}/`; +} + +/** 下载笔记附件到 `/assets/`(已存在的跳过),返回下载失败的文件名 */ +async function downloadAttachments( + client: SyncClient, + files: ParsedNote["files"], + outputDir: string, +): Promise { + const failed: string[] = []; + for (const f of files) { + if (!(await client.downloadFile(f.fileId, join(outputDir, "assets", f.name)))) { + failed.push(f.name); + } + } + return failed; +} + +function attachmentError(failed: string[]): string { + return `附件下载失败:${failed.join("、")}(正文已写入)`; +} + +/** 同名冲突时的备用路径:`_.md`(id 唯一,结果稳定) */ +export function withIdSuffix(filePath: string, id: string): string { + const base = filePath.endsWith(".md") ? filePath.slice(0, -3) : filePath; + return `${base}_${id}.md`; +} + +/** + * 为一批笔记分配互不冲突的落盘路径(export 用,无状态故需确定性): + * 同一路径下创建最早的笔记保留原名,其余加 id 后缀。 + */ +export function assignUniquePaths( + items: { id: string; createDate?: number; desired: string }[], +): Map { + const groups = new Map(); + for (const it of items) { + const key = pathKey(it.desired); + const group = groups.get(key); + if (group) group.push(it); + else groups.set(key, [it]); + } + const result = new Map(); + for (const group of groups.values()) { + group.sort( + (a, b) => + (a.createDate ?? 0) - (b.createDate ?? 0) || a.id.localeCompare(b.id), + ); + group.forEach((it, i) => { + result.set(it.id, i === 0 ? it.desired : withIdSuffix(it.desired, it.id)); + }); + } + return result; +} + // ============================================================ // export:纯单向 云→本地 快照。永不修改云端,不依赖/不写同步状态。 // ============================================================ +export interface ExportError { + id: string; + error: string; +} + export interface ExportResult { total: number; written: number; skipped: number; empty: number; failed: number; + errors: ExportError[]; outputDir: string; } +/** + * export 增量缓存(放在全局缓存目录,不写进导出目录)。只用来跳过「拉详情」,删掉即全量重来。 + * 某条笔记命中缓存需同时满足:云端 modifyDate 与所在文件夹(id + 名称)未变、 + * 上次写出的文件仍在且内容未被改动;工具版本或文件名模板变化时整份作废。 + */ +interface ExportCacheEntry { + modifyDate?: number; + folderId: string; + folderName: string; + empty?: boolean; + createDate?: number; + /** 去重前的期望路径(相对 output),参与本次去重分配 */ + desired?: string; + /** 实际写出的路径(相对 output) */ + filePath?: string; + hash?: string; +} + +interface ExportCache { + version: string; + template: string | null; + notes: Record; +} + +/** 某个导出目录对应的增量缓存文件路径 */ +export function getExportCachePath(outputDir: string): string { + const key = createHash("sha1").update(resolvePath(outputDir)).digest("hex").slice(0, 16); + return join(getCacheDir(), "export", `${key}.json`); +} + +async function loadExportCache( + cachePath: string, + template: string | null, +): Promise> { + try { + const cache = JSON.parse(await readFile(cachePath, "utf-8")) as ExportCache; + if (cache.version !== TOOL_VERSION || cache.template !== template) return {}; + return cache.notes ?? {}; + } catch { + return {}; + } +} + /** * 导出全部笔记为本地 Markdown(单向、只读云端)。 * * - 拉取云端全部笔记 → 转 Markdown → 写本地(按文件夹组织) * - 下载附件到 assets/ - * - force=false 时跳过内容未变的笔记(按本地文件哈希比对,纯本地判断) + * - 增量:云端未变且本地文件未被改动的笔记直接跳过(见 ExportCacheEntry);force=true 时全量重来 + * - 同名笔记不互相覆盖:创建最早的保留原名,其余加 `_` 后缀 * - 不删除任何云端笔记;不因本地缺失而回写云端 */ export async function exportNotes( - client: MiNoteClient, + client: SyncClient, outputDir: string, force = false, quiet = false, + cachePath = getExportCachePath(outputDir), ): Promise { const log = (msg: string): void => { if (!quiet) logInfo(msg); }; log("📂 开始导出..."); - // 文件名模板(可空):循环外取一次,避免重复 IO - const cfg = await loadUserConfig(); - const fileNameTemplate = cfg.fileNameTemplate; + const template = (await loadUserConfig()).fileNameTemplate ?? null; const { entries, folders } = await client.getAllNotes(200, (count) => { if (!quiet) process.stderr.write(`\r📋 已获取 ${count} 条笔记...`); @@ -80,52 +229,132 @@ export async function exportNotes( await ensureDir(outputDir); await ensureDir(join(outputDir, "assets")); + const cache = force ? {} : await loadExportCache(cachePath, template); + const nextCache: Record = {}; + const rel = (p: string): string => toOutputRel(p, outputDir); + const abs = (p: string): string => toOutputAbs(p, outputDir); + let written = 0; let skipped = 0; let emptyCount = 0; - let failed = 0; + const errors: ExportError[] = []; + const recordError = (id: string, err: unknown): void => { + const message = (err as Error).message; + errors.push({ id, error: message }); + if (!quiet) process.stderr.write(`\n❌ 导出失败 [${id}]: ${message}\n`); + }; + // 先拉齐全部详情再分配路径:同名去重需要看到整批笔记才能确定性地决定谁保留原名 + const fresh: { note: ParsedNote; markdown: string; key: ExportCacheEntry }[] = []; + const cached: { id: string; entry: ExportCacheEntry }[] = []; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; + const id = String(entry.id); if (!quiet) { const pct = (((i + 1) / entries.length) * 100).toFixed(1); process.stderr.write(`\r⏳ 导出中... ${i + 1}/${entries.length} (${pct}%)`); } + const folderId = String(entry.folderId ?? "0"); + const key: ExportCacheEntry = { + modifyDate: entry.modifyDate, + folderId, + folderName: folders[folderId]?.subject ?? "", + }; try { - const detail = await client.getNote(entry.id); - const note = parseNoteEntry(detail); - const markdown = xmlToMarkdown(note.content, note.files); + const hit = cache[id]; + if ( + hit && + hit.modifyDate === key.modifyDate && + hit.folderId === key.folderId && + hit.folderName === key.folderName + ) { + if (hit.empty) { + emptyCount++; + nextCache[id] = hit; + continue; + } + const file = hit.filePath ? abs(hit.filePath) : undefined; + if ( + file && + hit.desired && + (await fileExists(file)) && + computeHash(await readFile(file, "utf-8")) === hit.hash + ) { + cached.push({ id, entry: hit }); + continue; + } + } + const note = parseNoteEntry(await client.getNote(id)); + await randomDelay(300); + const desired = getNoteFilePath(note, folders, outputDir, template ?? undefined); + const markdown = xmlToMarkdown(note.content, note.files, assetPrefixFor(desired, outputDir)); if (!markdown.trim()) { emptyCount++; + nextCache[id] = { ...key, empty: true }; continue; } + fresh.push({ note, markdown, key: { ...key, createDate: note.createDate, desired: rel(desired) } }); + } catch (err) { + recordError(id, err); + } + } - const filePath = getNoteFilePath(note, folders, outputDir, fileNameTemplate); - // 增量:本地已存在且内容相同则跳过 - if (!force && (await fileExists(filePath))) { - const existing = await readFile(filePath, "utf-8"); - if (computeHash(existing) === computeHash(markdown)) { - skipped++; - continue; - } - } + const paths = assignUniquePaths([ + ...fresh.map(({ note, key }) => ({ id: note.id, createDate: key.createDate, desired: abs(key.desired!) })), + ...cached.map(({ id, entry }) => ({ id, createDate: entry.createDate, desired: abs(entry.desired!) })), + ]); - await ensureFileDir(filePath); - await writeFile(filePath, markdown, "utf-8"); - for (const file of note.files) { - await client.downloadFile(file.fileId, join(outputDir, "assets", file.name)); + // 命中缓存的笔记:内容已确认与上次写出的一致,仅在去重结果变化时挪到新路径 + for (const { id, entry } of cached) { + try { + const target = paths.get(id)!; + const file = abs(entry.filePath!); + if (pathKey(target) === pathKey(file)) { + skipped++; + } else { + // 去重分配结果变了(同名笔记集合变化):挪到新路径。 + // 旧文件内容与缓存校验过的哈希一致、是我们上次写出的产物,可安全删除, + // 留在原地反而会被后续 sync 当成「本地新增」重复上传。 + await ensureFileDir(target); + await writeFile(target, await readFile(file, "utf-8"), "utf-8"); + await rm(file, { force: true }); + written++; } - written++; - await randomDelay(300); + nextCache[id] = { ...entry, filePath: rel(target) }; } catch (err) { - failed++; - if (!quiet) { - process.stderr.write(`\n❌ 导出失败 [${entry.id}]: ${(err as Error).message}\n`); + recordError(id, err); + } + } + + for (const { note, markdown, key } of fresh) { + try { + const filePath = paths.get(note.id)!; + const unchanged = + !force && + (await fileExists(filePath)) && + computeHash(await readFile(filePath, "utf-8")) === computeHash(markdown); + if (unchanged) { + skipped++; + } else { + await ensureFileDir(filePath); + await writeFile(filePath, markdown, "utf-8"); + written++; } + // 正文未变也补齐附件:上次下载失败的借此重试(已存在的文件不会重复下载)。 + // 有失败则不写入缓存,下次导出会重新检查这条笔记。 + const failedFiles = await downloadAttachments(client, note.files, outputDir); + if (failedFiles.length > 0) throw new Error(attachmentError(failedFiles)); + nextCache[note.id] = { ...key, filePath: rel(filePath), hash: computeHash(markdown) }; + } catch (err) { + recordError(note.id, err); } } + const nextFile: ExportCache = { version: TOOL_VERSION, template, notes: nextCache }; + await ensureFileDir(cachePath); + await writeFile(cachePath, JSON.stringify(nextFile), "utf-8"); + if (!quiet) { process.stderr.write("\n"); log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -134,12 +363,20 @@ export async function exportNotes( 写入: ${written} 未修改: ${skipped} 空笔记: ${emptyCount} - 失败: ${failed} + 失败: ${errors.length} 输出目录: ${outputDir} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); } - return { total: entries.length, written, skipped, empty: emptyCount, failed, outputDir }; + return { + total: entries.length, + written, + skipped, + empty: emptyCount, + failed: errors.length, + errors, + outputDir, + }; } // ============================================================ @@ -162,10 +399,18 @@ export interface SyncPlanItem { remoteMarkdown?: string; } +export interface SyncError { + id: string; + subject: string; + action: SyncAction; + error: string; +} + export interface SyncResult { total: number; applied: Record; conflicts: SyncPlanItem[]; + errors: SyncError[]; outputDir: string; mode: SyncMode; dryRun: boolean; @@ -178,10 +423,12 @@ export type ConflictResolver = ( /** * 构建同步计划:对每条笔记做 3-way diff 并按 mode 决策动作。 - * 纯读取,不产生副作用,便于 dry-run 与测试。 + * 不触碰云端与本地文件;只在内存中修正 state(认领、去重),由调用方决定是否落盘。 + * + * @returns stateChanged 为 true 时即使 plan 为空也应保存 state */ export async function buildSyncPlan( - client: MiNoteClient, + client: SyncClient, outputDir: string, mode: SyncMode, quiet = false, @@ -189,8 +436,11 @@ export async function buildSyncPlan( plan: SyncPlanItem[]; state: SyncState; folders: Record; + stateChanged: boolean; }> { const state = await loadState(outputDir); + const fileNameTemplate = (await loadUserConfig()).fileNameTemplate; + let stateChanged = await dedupeStatePaths(state, outputDir); const { entries, folders } = await client.getAllNotes(200, (count) => { if (!quiet) process.stderr.write(`\r📋 已获取 ${count} 条笔记...`); @@ -200,6 +450,12 @@ export async function buildSyncPlan( const remoteById = new Map(); for (const e of entries) remoteById.set(String(e.id), e); + // 已被某条笔记占用的本地路径:state 记录的 + 本次认领的 + const trackedPaths = new Set(); + for (const n of Object.values(state.notes)) { + if (n.filePath) trackedPaths.add(pathKey(toOutputAbs(n.filePath, outputDir))); + } + // 收集所有涉及的 id:云端的 ∪ 状态里记录过的 const allIds = new Set([ ...remoteById.keys(), @@ -215,8 +471,7 @@ export async function buildSyncPlan( // 先读本地(不依赖网络)。state 里 filePath 为相对 output 的相对路径,读写时转绝对。 let localMarkdown: string | undefined; let localHash: string | undefined; - const relFilePath = base?.filePath ?? null; - const filePath = relFilePath ? toOutputAbs(relFilePath, outputDir) : null; + let filePath = base?.filePath ? toOutputAbs(base.filePath, outputDir) : null; if (filePath && (await fileExists(filePath))) { localMarkdown = await readFile(filePath, "utf-8"); localHash = computeHash(localMarkdown); @@ -236,28 +491,69 @@ export async function buildSyncPlan( let remoteMarkdown: string | undefined; let remoteHash: string | undefined; let remoteDetail: RawNoteEntry | undefined; + let remoteNote: ParsedNote | undefined; + let desired: string | undefined; if (remote) { remoteDetail = await client.getNote(id); - const note = parseNoteEntry(remoteDetail); - remoteMarkdown = xmlToMarkdown(note.content, note.files); + remoteNote = parseNoteEntry(remoteDetail); + desired = getNoteFilePath(remoteNote, folders, outputDir, fileNameTemplate); + remoteMarkdown = xmlToMarkdown( + remoteNote.content, + remoteNote.files, + assetPrefixFor(desired, outputDir), + ); remoteHash = computeHash(remoteMarkdown); } - const scenario = classify({ - baseHash: base?.baseHash, - remoteHash, - localHash, - }); + // 旧版本在子目录笔记里把附件写成 `assets/`(正确应为 `../assets/`)。 + // 本地内容若只差这个前缀,不能当成云端改动,否则会误报冲突。 + const legacyHash = (): string | undefined => + remoteNote && desired && assetPrefixFor(desired, outputDir) !== "assets/" + ? computeHash(xmlToMarkdown(remoteNote.content, remoteNote.files, "assets/")) + : undefined; + + // 认领:尚无基线的云端笔记,若其落盘位置已有未被跟踪的本地文件(如先 export 过), + // 视为同一笔记参与 3-way 比较,而不是把它当成「本地新增」再上传一份重复笔记。 + let baseHash = base?.baseHash; + if (!base && remoteNote && desired) { + const key = pathKey(desired); + if (!trackedPaths.has(key) && (await fileExists(desired))) { + trackedPaths.add(key); + filePath = desired; + localMarkdown = await readFile(desired, "utf-8"); + localHash = computeHash(localMarkdown); + // 与云端只差旧前缀:以本地为基线,按「仅云端变化」刷新文件 + if (localHash !== remoteHash && legacyHash() === localHash) { + baseHash = localHash; + } + } + } + + let scenario = classify({ baseHash, remoteHash, localHash }); + if (scenario === "both-changed" && baseHash && legacyHash() === baseHash) { + scenario = "local-changed"; // 云端内容其实没变,只是引用前缀换了写法 + } const action = decide(scenario, mode); - if (scenario === "in-sync") continue; // 无需动作不入计划 + if (scenario === "in-sync") { + if (!base && remoteDetail && remoteNote && filePath && remoteHash) { + state.notes[id] = { + id, + subject: remoteNote.subject, + filePath: toOutputRel(filePath, outputDir), + folderId: remoteNote.folderId, + baseHash: remoteHash, + localHash: remoteHash, + remoteModify: remoteDetail.modifyDate, + }; + stateChanged = true; + } + continue; + } plan.push({ id, - subject: - (remoteDetail && parseNoteEntry(remoteDetail).subject) || - base?.subject || - id, + subject: remoteNote?.subject || base?.subject || id, scenario, action, remote: remoteDetail ?? remote, @@ -267,16 +563,10 @@ export async function buildSyncPlan( }); } - // 检测「本地新增文件」(#6):输出目录下未被状态记录的 .md 文件。 - // 这些文件既不在云端 id 也不在状态 id 中,需单独扫描才能被上行同步发现。 - // state 里 filePath 为相对 output 的相对路径,比对前统一转绝对。 - const trackedPaths = new Set(); - for (const n of Object.values(state.notes)) { - if (n.filePath) trackedPaths.add(toOutputAbs(n.filePath, outputDir)); - } + // 检测「本地新增文件」(#6):输出目录下未被状态记录、也未被认领的 .md 文件。 const localFiles = await scanMarkdownFiles(outputDir); for (const fp of localFiles) { - if (trackedPaths.has(resolvePath(fp))) continue; // 已跟踪 + if (trackedPaths.has(pathKey(fp))) continue; const localMarkdown = await readFile(fp, "utf-8"); if (!localMarkdown.trim()) continue; // 空文件忽略 // 无 base、无 remote、有 local → classify 为 local-new @@ -285,7 +575,7 @@ export async function buildSyncPlan( // 用文件路径作为计划项的临时标识(尚无云端 id) plan.push({ id: `local:${fp}`, - subject: firstLine(localMarkdown), + subject: titleFromPath(fp, `local:${fp}`), scenario, action, filePath: fp, @@ -293,7 +583,41 @@ export async function buildSyncPlan( }); } - return { plan, state, folders }; + return { plan, state, folders, stateChanged }; +} + +/** + * 修复多条笔记记录指向同一本地文件的状态(旧版本同名笔记互相覆盖所致): + * 仅保留基线与文件当前内容一致的那条,其余移出状态,按云端新增重新落盘(届时会加 id 后缀)。 + * 都对不上时全部移出,文件作为未跟踪文件参与认领/冲突判定,不擅自归属。 + */ +async function dedupeStatePaths( + state: SyncState, + outputDir: string, +): Promise { + const groups = new Map(); + for (const n of Object.values(state.notes)) { + if (!n.filePath) continue; + const key = pathKey(toOutputAbs(n.filePath, outputDir)); + const group = groups.get(key); + if (group) group.push(n); + else groups.set(key, [n]); + } + + let changed = false; + for (const group of groups.values()) { + if (group.length < 2) continue; + const abs = toOutputAbs(group[0].filePath!, outputDir); + const hash = (await fileExists(abs)) + ? computeHash(await readFile(abs, "utf-8")) + : undefined; + const owner = hash ? group.find((n) => n.baseHash === hash) : undefined; + for (const n of group) { + if (n !== owner) delete state.notes[n.id]; + } + changed = true; + } + return changed; } /** @@ -301,7 +625,7 @@ export async function buildSyncPlan( * @param resolveConflict 遇到 action==="conflict" 时调用;不提供则跳过并计入冲突列表 */ export async function executeSyncPlan( - client: MiNoteClient, + client: SyncClient, outputDir: string, mode: SyncMode, plan: SyncPlanItem[], @@ -310,7 +634,6 @@ export async function executeSyncPlan( opts: { dryRun?: boolean; quiet?: boolean; resolveConflict?: ConflictResolver } = {}, ): Promise { const { dryRun = false, quiet = false, resolveConflict } = opts; - // 文件名模板从用户配置读一次(执行期不变) const fileNameTemplate = (await loadUserConfig()).fileNameTemplate; const applied: Record = { skip: 0, @@ -324,6 +647,7 @@ export async function executeSyncPlan( conflict: 0, }; const conflicts: SyncPlanItem[] = []; + const errors: SyncError[] = []; let processed = 0; for (const item of plan) { @@ -345,7 +669,10 @@ export async function executeSyncPlan( } try { - await applyAction(client, outputDir, action, item, state, folders, fileNameTemplate); + const warning = await applyAction( + client, outputDir, action, item, state, folders, fileNameTemplate, + ); + if (warning) errors.push({ id: item.id, subject: item.subject, action, error: warning }); applied[action]++; processed++; if (processed % SAVE_INTERVAL === 0) { @@ -353,10 +680,10 @@ export async function executeSyncPlan( await saveState(outputDir, state); } } catch (err) { + const message = (err as Error).message; + errors.push({ id: item.id, subject: item.subject, action, error: message }); if (!quiet) { - process.stderr.write( - `\n❌ 处理失败 [${item.id}] ${action}: ${(err as Error).message}\n`, - ); + process.stderr.write(`\n❌ 处理失败 [${item.id}] ${action}: ${message}\n`); } } } @@ -370,22 +697,86 @@ export async function executeSyncPlan( total: plan.length, applied, conflicts, + errors, outputDir, mode, dryRun, }; } -/** 执行单个动作并更新状态 */ +/** + * 计划生成后本地文件又被改动(如交互询问期间用户编辑了文件)时中止该条, + * 避免用过期的计划覆盖/删除新内容。 + */ +async function assertLocalUnchanged(item: SyncPlanItem): Promise { + const current = + item.filePath && (await fileExists(item.filePath)) + ? await readFile(item.filePath, "utf-8") + : undefined; + if (current !== item.localMarkdown) { + throw new Error("本地文件在生成同步计划后又被修改,已跳过,请重新运行 sync"); + } +} + +/** 选一个不会覆盖其他笔记文件的落盘路径 */ +async function claimFilePath( + desired: string, + item: SyncPlanItem, + state: SyncState, + outputDir: string, +): Promise { + const key = pathKey(desired); + if (item.filePath && pathKey(item.filePath) === key) return desired; + const heldByOther = Object.values(state.notes).some( + (n) => + n.id !== item.id && + n.filePath !== null && + pathKey(toOutputAbs(n.filePath, outputDir)) === key, + ); + if (!heldByOther && !(await fileExists(desired))) return desired; + return withIdSuffix(desired, item.id); +} + +/** + * 本地文件所在一级子目录 → 云端文件夹 id。 + * 优先沿用同目录下已同步笔记所在的文件夹(云端文件夹改名后本地目录名会过时), + * 其次按名称匹配,都没有才新建。 + */ +async function resolveFolderId( + client: SyncClient, + filePath: string | null, + outputDir: string, + folders: Record, + state: SyncState, +): Promise { + if (!filePath) return "0"; + const parts = relative(outputDir, filePath).split(sep); + if (parts.length < 2) return "0"; + const dirName = parts[0]; + const dirKey = pathKey(join(outputDir, dirName)); + for (const n of Object.values(state.notes)) { + if (!n.folderId || !n.filePath || !folders[n.folderId]) continue; + if (pathKey(dirname(toOutputAbs(n.filePath, outputDir))) === dirKey) return n.folderId; + } + for (const [id, folder] of Object.entries(folders)) { + if (sanitizeFileName(folder.subject || "") === dirName) return id; + } + const created = await client.createFolder(dirName); + const id = String(created.id); + folders[id] = created; + return id; +} + +/** 执行单个动作并更新状态;动作已生效但有需要报告的问题时返回说明 */ async function applyAction( - client: MiNoteClient, + client: SyncClient, outputDir: string, action: SyncAction, item: SyncPlanItem, state: SyncState, folders: Record, fileNameTemplate: string | undefined, -): Promise { +): Promise { const id = item.id; switch (action) { case "skip": @@ -395,54 +786,74 @@ async function applyAction( case "create-local": { // 下行:用云端覆盖/创建本地 if (!item.remote || item.remoteMarkdown === undefined) return; + await assertLocalUnchanged(item); const note = parseNoteEntry(item.remote); - const filePath = getNoteFilePath(note, folders, outputDir, fileNameTemplate); // 绝对路径 - // 路径变化时清理旧文件(state 里 filePath 为相对 output,转绝对再比对/删除) - const oldRel = state.notes[id]?.filePath; - const oldPath = oldRel ? toOutputAbs(oldRel, outputDir) : null; - if (oldPath && oldPath !== filePath && (await fileExists(oldPath))) { - await rm(oldPath, { force: true }); - } + const desired = getNoteFilePath(note, folders, outputDir, fileNameTemplate); + const filePath = await claimFilePath(desired, item, state, outputDir); await ensureFileDir(filePath); await writeFile(filePath, item.remoteMarkdown, "utf-8"); - for (const f of note.files) { - await client.downloadFile(f.fileId, join(outputDir, "assets", f.name)); - } state.notes[id] = { id, subject: note.subject, filePath: toOutputRel(filePath, outputDir), + folderId: note.folderId, baseHash: computeHash(item.remoteMarkdown), localHash: computeHash(item.remoteMarkdown), remoteModify: item.remote.modifyDate, }; - return; + // 路径变化(改名/换文件夹)时清理旧文件 + if ( + item.filePath && + pathKey(item.filePath) !== pathKey(filePath) && + (await fileExists(item.filePath)) + ) { + await rm(item.filePath, { force: true }); + } + // 附件失败不回滚正文:一个坏附件不应让整篇笔记永远同步不下来 + const failedFiles = await downloadAttachments(client, note.files, outputDir); + return failedFiles.length > 0 ? attachmentError(failedFiles) : undefined; } case "update-remote": case "create-remote": { // 上行:用本地内容更新/创建云端。item.filePath 为绝对路径,存状态前转相对 output。 if (item.localMarkdown === undefined) return; + await assertLocalUnchanged(item); const relPath = item.filePath ? toOutputRel(item.filePath, outputDir) : null; - const xml = markdownToXml(item.localMarkdown); + const prefix = item.filePath ? assetPrefixFor(item.filePath, outputDir) : "assets/"; + const baseDirs = item.filePath ? [dirname(item.filePath), outputDir] : [outputDir]; const now = Date.now(); if (action === "create-remote") { + const { imageMap, uploaded } = await uploadLocalImages( + client, + item.localMarkdown, + new Map(), + baseDirs, + ); + const xml = markdownToXml(item.localMarkdown, imageMap); + const folderId = await resolveFolderId(client, item.filePath, outputDir, folders, state); + const title = item.filePath + ? titleFromPath(item.filePath, id) + : firstLine(item.localMarkdown); const created = await client.createNote({ colorId: 0, - folderId: "0", + folderId, createDate: now, modifyDate: now, content: xml, alertDate: 0, - setting: { themeId: 0, stickyTime: 0, version: 0 }, - extraInfo: buildExtraInfoString(firstLine(item.localMarkdown)), + setting: withAttachments(undefined, uploaded), + extraInfo: buildExtraInfoString(title), snippet: extractSnippet(xml), }); const newId = String(created.id); + // 云端已删后重建会换新 id,旧记录必须移除,否则两条记录指向同一文件 + delete state.notes[id]; state.notes[newId] = { id: newId, - subject: firstLine(item.localMarkdown), + subject: title, filePath: relPath, + folderId, baseHash: computeHash(item.localMarkdown), localHash: computeHash(item.localMarkdown), remoteModify: created.modifyDate, @@ -450,6 +861,21 @@ async function applyAction( } else { // update-remote:先取最新 entry 拿 tag,改 content 再提交 const current = await client.getNote(id); + // 计划生成后云端又被改过:直接提交会用新 tag 覆盖掉这次改动 + if ( + item.remote?.modifyDate !== undefined && + current.modifyDate !== item.remote.modifyDate + ) { + throw new Error("云端笔记在生成同步计划后又被修改,已跳过,请重新运行 sync"); + } + const currentNote = parseNoteEntry(current); + const { imageMap, uploaded } = await uploadLocalImages( + client, + item.localMarkdown, + buildImageMap(currentNote.files, prefix, "assets/"), + baseDirs, + ); + const xml = markdownToXml(item.localMarkdown, imageMap); const entry: WriteNoteEntry = { id, tag: current.tag, @@ -458,7 +884,7 @@ async function applyAction( modifyDate: now, colorId: current.colorId ?? 0, content: xml, - setting: current.setting ?? { themeId: 0, stickyTime: 0, version: 0 }, + setting: withAttachments(current.setting, uploaded), folderId: String(current.folderId ?? "0"), alertDate: current.alertDate ?? 0, extraInfo: @@ -471,8 +897,9 @@ async function applyAction( const updated = await client.updateNote(id, entry); state.notes[id] = { id, - subject: state.notes[id]?.subject ?? id, + subject: state.notes[id]?.subject ?? currentNote.subject, filePath: relPath, + folderId: currentNote.folderId, baseHash: computeHash(item.localMarkdown), localHash: computeHash(item.localMarkdown), remoteModify: updated.modifyDate, @@ -482,6 +909,7 @@ async function applyAction( } case "delete-local": { + await assertLocalUnchanged(item); if (item.filePath && (await fileExists(item.filePath))) { await rm(item.filePath, { force: true }); } @@ -490,9 +918,10 @@ async function applyAction( } case "delete-remote": { - if (item.remote?.tag) { - await client.deleteNote(id, item.remote.tag, true); - } + // 同步里的删除只移到回收站:本地误删文件时仍可在云端恢复 + const tag = item.remote?.tag; + if (!tag) throw new Error("云端笔记缺少 tag,无法删除"); + await client.deleteNote(id, tag, false); delete state.notes[id]; return; } @@ -506,6 +935,16 @@ async function applyAction( } } +/** 本地文件名作为云端标题;去掉同名去重时加的 `_` 后缀 */ +function titleFromPath(filePath: string, id: string): string { + let name = basename(filePath, ".md"); + const suffix = `_${id}`; + if (!id.startsWith("local:") && name.endsWith(suffix) && name.length > suffix.length) { + name = name.slice(0, -suffix.length); + } + return name.trim() || "未命名"; +} + function firstLine(md: string): string { return ( md diff --git a/src/text-diff.ts b/src/text-diff.ts new file mode 100644 index 0000000..26e7122 --- /dev/null +++ b/src/text-diff.ts @@ -0,0 +1,76 @@ +/** 行级 diff(LCS),用于同步冲突时对比两侧内容 */ + +export interface DiffLine { + op: " " | "-" | "+"; + text: string; +} + +/** 超过该规模(行数乘积)时不做 LCS,退化为整体替换,避免内存与耗时失控 */ +const MAX_CELLS = 4_000_000; + +export function diffLines(before: string, after: string): DiffLine[] { + const a = before.split("\n"); + const b = after.split("\n"); + const n = a.length; + const m = b.length; + if (n * m > MAX_CELLS) { + return [ + ...a.map((text) => ({ op: "-" as const, text })), + ...b.map((text) => ({ op: "+" as const, text })), + ]; + } + + // lcs[i][j] = a[i..] 与 b[j..] 的最长公共子序列长度 + const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + lcs[i][j] = + a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + + const out: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + out.push({ op: " ", text: a[i] }); + i++; + j++; + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + out.push({ op: "-", text: a[i++] }); + } else { + out.push({ op: "+", text: b[j++] }); + } + } + while (i < n) out.push({ op: "-", text: a[i++] }); + while (j < m) out.push({ op: "+", text: b[j++] }); + return out; +} + +/** 渲染为带 -/+ 前缀的文本;未改动的长段只保留前后 context 行 */ +export function formatDiff(lines: DiffLine[], context = 3): string { + const changed = lines.map((l) => l.op !== " "); + const keep = lines.map((_, idx) => { + for (let k = Math.max(0, idx - context); k <= Math.min(lines.length - 1, idx + context); k++) { + if (changed[k]) return true; + } + return false; + }); + + const out: string[] = []; + let skipped = 0; + lines.forEach((l, idx) => { + if (!keep[idx]) { + skipped++; + return; + } + if (skipped > 0) { + out.push(` … 省略 ${skipped} 行未改动内容`); + skipped = 0; + } + out.push(`${l.op} ${l.text}`); + }); + if (skipped > 0) out.push(` … 省略 ${skipped} 行未改动内容`); + return out.join("\n"); +} diff --git a/src/types.ts b/src/types.ts index 4f556f0..0bf7000 100644 --- a/src/types.ts +++ b/src/types.ts @@ -229,6 +229,8 @@ export interface SyncNoteState { id: string; subject: string; filePath: string | null; + /** 上次同步时笔记所在的云端文件夹 id(用于把本地同目录新文件建到同一文件夹) */ + folderId?: string; baseHash?: string; localHash?: string; remoteModify?: number; diff --git a/tests/sync-diff.test.ts b/tests/sync-diff.test.ts index 14d443f..3f6099d 100644 --- a/tests/sync-diff.test.ts +++ b/tests/sync-diff.test.ts @@ -127,7 +127,7 @@ const EXPECT: Record> = { "two-way": "conflict", manual: "conflict", }, "remote-deleted-local-changed": { - "cloud-first": "delete-local", "local-first": "update-remote", + "cloud-first": "delete-local", "local-first": "create-remote", "two-way": "conflict", manual: "conflict", }, "local-deleted-remote-changed": { diff --git a/tests/sync.test.ts b/tests/sync.test.ts new file mode 100644 index 0000000..31354ca --- /dev/null +++ b/tests/sync.test.ts @@ -0,0 +1,506 @@ +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile, readFile, readdir, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildSyncPlan, + executeSyncPlan, + exportNotes, + computeHash, + type SyncClient, +} from "../src/sync.ts"; +import { loadState, saveState } from "../src/config.ts"; +import { fileExists } from "../src/utils.ts"; +import type { + RawNoteEntry, + RawFolderEntry, + SyncMode, + WriteNoteEntry, +} from "../src/types.ts"; + +/** + * src/sync.ts 执行层测试:用内存假客户端代替小米 API,零网络依赖。 + * 通过 MI_NOTE_CLI_CONFIG_DIR 把用户配置隔离到临时目录。 + */ + +let root: string; +let outputDir: string; +let cachePath: string; +let prevEnv: string | undefined; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "mi-note-cli-synctest-")); + outputDir = join(root, "notes"); + cachePath = join(root, "export-cache.json"); + prevEnv = process.env.MI_NOTE_CLI_CONFIG_DIR; + process.env.MI_NOTE_CLI_CONFIG_DIR = root; +}); + +afterEach(async () => { + if (prevEnv === undefined) delete process.env.MI_NOTE_CLI_CONFIG_DIR; + else process.env.MI_NOTE_CLI_CONFIG_DIR = prevEnv; + await rm(root, { recursive: true, force: true }); +}); + +const IMAGE_ID = "1234.abcdEFG"; + +class FakeClient implements SyncClient { + notes = new Map(); + folders: Record = {}; + updates: { id: string; entry: WriteNoteEntry }[] = []; + creates: WriteNoteEntry[] = []; + deletes: { id: string; tag?: string; purge: boolean }[] = []; + createdFolders: string[] = []; + uploads: string[] = []; + getNoteCalls: string[] = []; + failDownloads = false; + private nextId = 9000; + private clock = 1_700_000_000_000; + + add(id: string, title: string, content: string, extra: Partial = {}): RawNoteEntry { + const entry: RawNoteEntry = { + id, + tag: `tag-${id}-1`, + status: "normal", + folderId: "0", + createDate: this.tick(), + modifyDate: this.tick(), + content, + extraInfo: JSON.stringify({ title }), + ...extra, + }; + this.notes.set(id, entry); + return entry; + } + + touch(id: string): void { + this.notes.get(id)!.modifyDate = this.tick(); + } + + private tick(): number { + this.clock += 1000; + return this.clock; + } + + async getAllNotes() { + return { + entries: [...this.notes.values()].map((n) => ({ ...n })), + folders: { ...this.folders }, + syncTag: "", + }; + } + + async getNote(id: string | number): Promise { + this.getNoteCalls.push(String(id)); + const n = this.notes.get(String(id)); + if (!n) throw new Error(`not found: ${id}`); + return structuredClone(n); + } + + async createNote(entry: WriteNoteEntry): Promise { + this.creates.push(entry); + const id = String(this.nextId++); + const created: RawNoteEntry = { ...entry, id, tag: `tag-${id}-1`, status: "normal" }; + this.notes.set(id, created); + return created; + } + + async updateNote(id: string, entry: WriteNoteEntry): Promise { + this.updates.push({ id, entry }); + const updated: RawNoteEntry = { ...this.notes.get(id), ...entry, id, modifyDate: this.tick() }; + this.notes.set(id, updated); + return updated; + } + + async deleteNote(id: string, tag?: string, purge = false): Promise { + this.deletes.push({ id, tag, purge }); + this.notes.delete(id); + } + + async createFolder(subject: string): Promise { + this.createdFolders.push(subject); + const folder: RawFolderEntry = { id: String(this.nextId++), subject, type: "folder" }; + this.folders[String(folder.id)] = folder; + return folder; + } + + async downloadFile(): Promise { + return !this.failDownloads; + } + + async uploadImage(_buffer: Uint8Array, params: { filename: string; mimeType: string }) { + this.uploads.push(params.filename); + return { fileId: `up.${params.filename}`, digest: `d-${params.filename}` }; + } +} + +async function sync(client: FakeClient, mode: SyncMode) { + const { plan, state, folders } = await buildSyncPlan(client, outputDir, mode, true); + return executeSyncPlan(client, outputDir, mode, plan, state, folders, { quiet: true }); +} + +async function listMd(dir: string): Promise { + return (await readdir(dir)).filter((f) => f.endsWith(".md")).sort(); +} + +test("sync: 同名笔记下行到不同文件,互不覆盖", async () => { + const client = new FakeClient(); + client.add("1", "购物清单", '牛奶'); + client.add("2", "购物清单", '鸡蛋'); + + const result = await sync(client, "cloud-first"); + assert.equal(result.errors.length, 0); + assert.deepEqual(await listMd(outputDir), ["购物清单.md", "购物清单_2.md"]); + + const state = await loadState(outputDir); + assert.notEqual(state.notes["1"].filePath, state.notes["2"].filePath); + + // 再同步一次不应产生任何动作(尤其不能把一条的内容当成另一条的本地修改上传) + const again = await buildSyncPlan(client, outputDir, "two-way", true); + assert.equal(again.plan.length, 0); +}); + +test("sync: 在 export 产物上首次同步时认领已有文件,不重复上传", async () => { + const client = new FakeClient(); + client.add("1", "周报", '本周进展'); + client.add("2", "想法", '一个点子'); + await exportNotes(client, outputDir, false, true, cachePath); + + const { plan, state, stateChanged } = await buildSyncPlan(client, outputDir, "two-way", true); + assert.equal(plan.length, 0, "内容一致的文件应被认领为已同步"); + assert.equal(stateChanged, true); + assert.equal(state.notes["1"].filePath, "周报.md"); + assert.equal(client.creates.length, 0); +}); + +test("sync: 认领时内容不一致按冲突处理,非交互不改动任何数据", async () => { + const client = new FakeClient(); + client.add("1", "周报", '本周进展'); + await exportNotes(client, outputDir, false, true, cachePath); + await writeFile(join(outputDir, "周报.md"), "本周进展\n\n本地补充", "utf-8"); + + const result = await sync(client, "two-way"); + assert.equal(result.conflicts.length, 1); + assert.equal(result.conflicts[0].scenario, "both-changed"); + assert.equal(client.creates.length + client.updates.length, 0); + assert.equal(await readFile(join(outputDir, "周报.md"), "utf-8"), "本周进展\n\n本地补充"); +}); + +test("sync: 本地编辑带图笔记后上行,云端图片保留", async () => { + const client = new FakeClient(); + client.add( + "1", + "旅行", + `出发\n`, + { setting: { data: [{ fileId: IMAGE_ID, mimeType: "image/jpeg" }] } }, + ); + await sync(client, "cloud-first"); + + const file = join(outputDir, "旅行.md"); + await writeFile(file, (await readFile(file, "utf-8")) + "\n\n到达", "utf-8"); + const result = await sync(client, "two-way"); + + assert.equal(result.errors.length, 0); + assert.equal(client.updates.length, 1); + const xml = client.updates[0].entry.content; + assert.match(xml, new RegExp(` { + const client = new FakeClient(); + client.add("1", "旅行", '出发'); + await sync(client, "cloud-first"); + + await writeFile(join(outputDir, "旅行.md"), "出发\n\n![](./photo.png)", "utf-8"); + const result = await sync(client, "two-way"); + + assert.equal(client.updates.length, 0); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0].action, "update-remote"); + assert.match(result.errors[0].error, /photo\.png/); +}); + +test("sync: 本地删除文件后云端只移到回收站", async () => { + const client = new FakeClient(); + client.add("1", "待办", '买菜'); + await sync(client, "cloud-first"); + + await rm(join(outputDir, "待办.md")); + await sync(client, "two-way"); + + assert.deepEqual(client.deletes, [{ id: "1", tag: "tag-1-1", purge: false }]); +}); + +test("sync: 子目录中的本地新文件建到同名云端文件夹,缺失时自动创建", async () => { + const client = new FakeClient(); + client.folders["f1"] = { id: "f1", subject: "工作", type: "folder" }; + await mkdir(join(outputDir, "工作"), { recursive: true }); + await mkdir(join(outputDir, "生活"), { recursive: true }); + await writeFile(join(outputDir, "工作", "a.md"), "会议纪要", "utf-8"); + await writeFile(join(outputDir, "生活", "b.md"), "菜谱", "utf-8"); + + const result = await sync(client, "two-way"); + assert.equal(result.errors.length, 0); + + const byContent = (text: string) => + client.creates.find((e) => e.content.includes(text))!; + assert.equal(byContent("会议纪要").folderId, "f1"); + assert.deepEqual(client.createdFolders, ["生活"]); + assert.equal(byContent("菜谱").folderId, String(Object.values(client.folders).find((f) => f.subject === "生活")!.id)); +}); + +test("sync: 生成计划后云端又被修改时放弃上行", async () => { + const client = new FakeClient(); + client.add("1", "草稿", 'v1'); + await sync(client, "cloud-first"); + await writeFile(join(outputDir, "草稿.md"), "v1 本地改", "utf-8"); + + const { plan, state, folders } = await buildSyncPlan(client, outputDir, "two-way", true); + assert.equal(plan[0].action, "update-remote"); + client.touch("1"); // 模拟另一台设备在此期间改了云端 + const result = await executeSyncPlan(client, outputDir, "two-way", plan, state, folders, { quiet: true }); + + assert.equal(client.updates.length, 0); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0].error, /云端/); +}); + +test("sync: 生成计划后本地文件又被修改时不覆盖本地", async () => { + const client = new FakeClient(); + client.add("1", "草稿", 'v1'); + await sync(client, "cloud-first"); + client.notes.get("1")!.content = 'v2'; + client.touch("1"); + + const { plan, state, folders } = await buildSyncPlan(client, outputDir, "two-way", true); + assert.equal(plan[0].action, "update-local"); + await writeFile(join(outputDir, "草稿.md"), "用户刚写的", "utf-8"); + const result = await executeSyncPlan(client, outputDir, "two-way", plan, state, folders, { quiet: true }); + + assert.equal(result.errors.length, 1); + assert.equal(await readFile(join(outputDir, "草稿.md"), "utf-8"), "用户刚写的"); +}); + +test("sync: 修复旧版本同名笔记共用一个文件的状态", async () => { + const client = new FakeClient(); + const a = client.add("A", "日记", 'A 的内容'); + const b = client.add("B", "日记", 'B 的内容'); + await mkdir(outputDir, { recursive: true }); + await writeFile(join(outputDir, "日记.md"), "B 的内容", "utf-8"); + const hashB = computeHash("B 的内容"); + await saveState(outputDir, { + lastSync: 1, + folders: {}, + notes: { + A: { id: "A", subject: "日记", filePath: "日记.md", baseHash: computeHash("A 的内容"), localHash: computeHash("A 的内容"), remoteModify: a.modifyDate }, + B: { id: "B", subject: "日记", filePath: "日记.md", baseHash: hashB, localHash: hashB, remoteModify: b.modifyDate }, + }, + }); + + const result = await sync(client, "two-way"); + + assert.equal(client.updates.length, 0, "不能把 B 的内容上传覆盖 A"); + assert.equal(result.applied["create-local"], 1); + assert.equal(await readFile(join(outputDir, "日记.md"), "utf-8"), "B 的内容"); + assert.equal(await readFile(join(outputDir, "日记_A.md"), "utf-8"), "A 的内容"); +}); + +test("sync: local-first 下云端已删本地已改 → 云端重建,旧记录移除", async () => { + const client = new FakeClient(); + client.add("1", "计划", '原文'); + await sync(client, "cloud-first"); + client.notes.delete("1"); + await writeFile(join(outputDir, "计划.md"), "原文\n\n补充", "utf-8"); + + const result = await sync(client, "local-first"); + assert.equal(result.errors.length, 0); + assert.equal(client.creates.length, 1); + + const state = await loadState(outputDir); + assert.equal(state.notes["1"], undefined); + const ids = Object.keys(state.notes); + assert.equal(ids.length, 1); + assert.equal(state.notes[ids[0]].filePath, "计划.md"); +}); + +test("export: 同名笔记确定性去重,创建最早的保留原名", async () => { + const client = new FakeClient(); + client.add("20", "会议", '第一次', { createDate: 1 }); + client.add("10", "会议", '第二次', { createDate: 2 }); + + const result = await exportNotes(client, outputDir, false, true, cachePath); + assert.equal(result.written, 2); + assert.equal(await readFile(join(outputDir, "会议.md"), "utf-8"), "第一次"); + assert.equal(await readFile(join(outputDir, "会议_10.md"), "utf-8"), "第二次"); +}); + +test("export: 单条失败记入 errors,其余照常导出", async () => { + const client = new FakeClient(); + client.add("1", "好的", 'ok'); + client.add("2", "坏的", 'boom'); + const getNote = client.getNote.bind(client); + client.getNote = async (id) => { + if (String(id) === "2") throw new Error("网络错误"); + return getNote(id); + }; + + const result = await exportNotes(client, outputDir, false, true, cachePath); + assert.equal(result.written, 1); + assert.deepEqual(result.errors, [{ id: "2", error: "网络错误" }]); + assert.equal(await fileExists(join(outputDir, "好的.md")), true); +}); + +test("sync: 子目录笔记的附件引用写成 ../assets/,旧版 assets/ 写法认领时不报冲突", async () => { + const client = new FakeClient(); + client.folders["f1"] = { id: "f1", subject: "旅行", type: "folder" }; + const note = client.add( + "1", + "京都", + ``, + { folderId: "f1", setting: { data: [{ fileId: IMAGE_ID, mimeType: "image/jpeg" }] } }, + ); + const { files } = (await import("../src/converter.ts")).parseNoteEntry(note); + const name = files[0].name; + + // 旧版本导出的文件:子目录里写的是 assets/ + await mkdir(join(outputDir, "旅行"), { recursive: true }); + await writeFile(join(outputDir, "旅行", "京都.md"), `![${name}](assets/${name})`, "utf-8"); + + const { plan } = await buildSyncPlan(client, outputDir, "two-way", true); + assert.equal(plan.length, 1); + assert.equal(plan[0].scenario, "remote-changed"); + const result = await sync(client, "two-way"); + assert.equal(result.errors.length, 0); + assert.equal( + await readFile(join(outputDir, "旅行", "京都.md"), "utf-8"), + `![${name}](../assets/${name})`, + ); +}); + +test("sync: 本地新增的图片自动上传并登记到笔记附件", async () => { + const client = new FakeClient(); + client.add("1", "旅行", '出发'); + await sync(client, "cloud-first"); + + await writeFile(join(outputDir, "pic.png"), "fake-png"); + await writeFile(join(outputDir, "旅行.md"), "出发\n\n![](./pic.png)", "utf-8"); + const result = await sync(client, "two-way"); + + assert.equal(result.errors.length, 0); + assert.deepEqual(client.uploads, ["pic.png"]); + const { entry } = client.updates[0]; + assert.match(entry.content, / { + const client = new FakeClient(); + client.add("1", "旅行", '出发'); + await sync(client, "cloud-first"); + + await writeFile(join(outputDir, "pic.png"), "fake-png"); + await writeFile(join(outputDir, "旅行.md"), "![](./pic.png)\n\n[🔊 a.mp3](assets/a.mp3)", "utf-8"); + const result = await sync(client, "two-way"); + + assert.equal(result.errors.length, 1); + assert.deepEqual(client.uploads, []); +}); + +test("sync: 本地新文件以文件名为云端标题", async () => { + const client = new FakeClient(); + await mkdir(outputDir, { recursive: true }); + await writeFile(join(outputDir, "读书笔记.md"), "# 第一章\n\n正文", "utf-8"); + await sync(client, "two-way"); + + assert.equal(JSON.parse(client.creates[0].extraInfo!).title, "读书笔记"); +}); + +test("sync: 云端文件夹改名后,旧目录里的新文件仍建到原文件夹", async () => { + const client = new FakeClient(); + client.folders["f1"] = { id: "f1", subject: "工作", type: "folder" }; + client.add("1", "周报", '进展', { folderId: "f1" }); + await sync(client, "cloud-first"); + + client.folders["f1"].subject = "工作归档"; // 云端改名,笔记本身没变,本地目录仍叫「工作」 + await writeFile(join(outputDir, "工作", "新想法.md"), "点子", "utf-8"); + await sync(client, "two-way"); + + assert.deepEqual(client.createdFolders, []); + assert.equal(client.creates[0].folderId, "f1"); +}); + +test("sync: 附件下载失败时正文照常同步,失败在结果中报告", async () => { + const client = new FakeClient(); + client.add("1", "旅行", ``, { + setting: { data: [{ fileId: IMAGE_ID, mimeType: "image/jpeg" }] }, + }); + client.failDownloads = true; + const result = await sync(client, "cloud-first"); + assert.equal(result.applied["create-local"], 1); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0].error, /附件下载失败/); + assert.equal(await fileExists(join(outputDir, "旅行.md")), true); +}); + +test("export: 增量跳过未变笔记,云端修改、本地改动、文件夹改名时重新拉取", async () => { + const client = new FakeClient(); + client.folders["f1"] = { id: "f1", subject: "工作", type: "folder" }; + client.add("1", "周报", 'v1', { folderId: "f1" }); + client.add("2", "想法", '点子'); + await exportNotes(client, outputDir, false, true, cachePath); + + client.getNoteCalls = []; + const second = await exportNotes(client, outputDir, false, true, cachePath); + assert.deepEqual(client.getNoteCalls, [], "未变的笔记不应再拉详情"); + assert.equal(second.skipped, 2); + + client.notes.get("1")!.content = 'v2'; + client.touch("1"); + await writeFile(join(outputDir, "想法.md"), "本地乱改", "utf-8"); + client.getNoteCalls = []; + await exportNotes(client, outputDir, false, true, cachePath); + assert.deepEqual(client.getNoteCalls.sort(), ["1", "2"]); + assert.equal(await readFile(join(outputDir, "工作", "周报.md"), "utf-8"), "v2"); + assert.equal(await readFile(join(outputDir, "想法.md"), "utf-8"), "点子"); + + client.folders["f1"].subject = "归档"; + client.getNoteCalls = []; + await exportNotes(client, outputDir, false, true, cachePath); + assert.deepEqual(client.getNoteCalls, ["1"]); + assert.equal(await readFile(join(outputDir, "归档", "周报.md"), "utf-8"), "v2"); + + client.getNoteCalls = []; + await exportNotes(client, outputDir, true, true, cachePath); + assert.deepEqual(client.getNoteCalls.sort(), ["1", "2"], "--force 忽略缓存"); +}); + +test("export: 附件下载失败的笔记不进缓存,下次重试", async () => { + const client = new FakeClient(); + client.add("1", "旅行", ``, { + setting: { data: [{ fileId: IMAGE_ID, mimeType: "image/jpeg" }] }, + }); + client.failDownloads = true; + const first = await exportNotes(client, outputDir, false, true, cachePath); + assert.equal(first.errors.length, 1); + + client.failDownloads = false; + client.getNoteCalls = []; + const second = await exportNotes(client, outputDir, false, true, cachePath); + assert.deepEqual(client.getNoteCalls, ["1"]); + assert.equal(second.errors.length, 0); +}); + +test("export: 同名笔记减少后,旧的去重文件名文件被清理", async () => { + const client = new FakeClient(); + client.add("20", "会议", '第一次', { createDate: 1 }); + client.add("10", "会议", '第二次', { createDate: 2 }); + await exportNotes(client, outputDir, false, true, cachePath); + assert.deepEqual(await listMd(outputDir), ["会议.md", "会议_10.md"]); + + client.notes.delete("20"); + await exportNotes(client, outputDir, false, true, cachePath); + assert.deepEqual(await listMd(outputDir), ["会议.md"]); + assert.equal(await readFile(join(outputDir, "会议.md"), "utf-8"), "第二次"); +}); diff --git a/tests/text-diff.test.ts b/tests/text-diff.test.ts new file mode 100644 index 0000000..099113c --- /dev/null +++ b/tests/text-diff.test.ts @@ -0,0 +1,29 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { diffLines, formatDiff } from "../src/text-diff.ts"; + +test("diffLines: 相同内容全为上下文行", () => { + assert.deepEqual(diffLines("a\nb", "a\nb"), [ + { op: " ", text: "a" }, + { op: " ", text: "b" }, + ]); +}); + +test("diffLines: 标出增删行", () => { + assert.deepEqual(diffLines("a\nb\nc", "a\nx\nc"), [ + { op: " ", text: "a" }, + { op: "-", text: "b" }, + { op: "+", text: "x" }, + { op: " ", text: "c" }, + ]); +}); + +test("formatDiff: 折叠远离改动的未变行", () => { + const before = Array.from({ length: 20 }, (_, i) => `l${i}`).join("\n"); + const after = before.replace("l10", "changed"); + const out = formatDiff(diffLines(before, after), 1); + assert.equal( + out, + [" … 省略 9 行未改动内容", " l9", "- l10", "+ changed", " l11", " … 省略 8 行未改动内容"].join("\n"), + ); +});