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
18 changes: 16 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 时可能把一条笔记的内容上传覆盖另一条;同名时改为加 `_<id>` 后缀,并自动修复旧版本留下的错误状态
- 子目录(云端文件夹)里的笔记导出后图片引用是 `assets/...`,Markdown 查看器中无法显示;改为按目录深度写 `../assets/` 等相对前缀,旧文件在下次同步时自动修复
- 云端笔记 `setting.data` 里未登记附件的图片在导出时丢图;现在保留为 `minote://image/` 引用,仍可正常回写
- 附件下载失败时 `sync` 会删掉已写好的正文、反复重试;改为正文照常落盘并在 `data.errors` 中报告附件失败
- `export` 附件下载失败的笔记会被记入缓存、之后永远跳过;改为失败不进缓存,下次重试
- 在已有 `export` 产物的目录上首次 `sync --mode two-way` 会把所有笔记在云端重复创建一份
- `local-first` 模式或交互选择「以本地为准」时,「云端已删、本地已改」的笔记未能在云端重建
- 生成同步计划后云端或本地又被修改时,执行阶段会覆盖掉这次修改
- `--json` 模式下同步单条失败完全不可见

### Security

Expand All @@ -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)

Expand Down
13 changes: 12 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 `_<noteId>` 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
Expand Down Expand Up @@ -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.

---

Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 目录后重跑 |
| 适用场景 | 一次性备份/快照 | 持续同步 |
Expand All @@ -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
Expand Down Expand Up @@ -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。

---

Expand Down
5 changes: 5 additions & 0 deletions skills/mi-note-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> --help` 查阅选项(--help 含输出约定、模式说明、示例)。

## 内容格式
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,自动转回小米格式。
Expand Down Expand Up @@ -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 # 预览将发生什么
Expand Down
3 changes: 2 additions & 1 deletion src/commands/export.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,6 +22,7 @@ export async function exportCommand(opts: ExportOptions): Promise<void> {
success(result, () => {
// 过程已打印汇总
});
if (result.errors.length > 0) process.exitCode = EXIT_PARTIAL_FAILURE;
} catch (err) {
fail(err);
}
Expand Down
84 changes: 56 additions & 28 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -44,6 +54,19 @@ const SCENARIO_DESC: Record<string, string> = {
"local-deleted-remote-clean": "本地已删除",
};

/** 动作的人类可读说明(冲突询问时展示每个选项的实际后果) */
const ACTION_DESC: Record<SyncAction, string> = {
skip: "不做处理",
"update-local": "用云端内容覆盖本地文件",
"update-remote": "用本地内容覆盖云端笔记",
"create-local": "从云端重新生成本地文件",
"create-remote": "在云端新建笔记",
"delete-local": "删除本地文件",
"delete-remote": "把云端笔记移到回收站",
"drop-state": "清理同步记录",
conflict: "待定",
};

/** sync 主命令 */
export async function syncCommand(opts: SyncOptions): Promise<void> {
try {
Expand All @@ -59,14 +82,19 @@ export async function syncCommand(opts: SyncOptions): Promise<void> {
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,
isJsonMode(),
);

if (plan.length === 0) {
// 首次同步认领了已有本地文件等情况:虽无动作,但基线需要落盘
if (stateChanged && !opts.dryRun) {
state.lastSync = Date.now();
await saveState(outputDir, state);
}
success({ outputDir, mode, changes: 0 }, () => {
logInfo("✅ 已是最新,无需同步");
});
Expand Down Expand Up @@ -109,7 +137,14 @@ export async function syncCommand(opts: SyncOptions): Promise<void> {
}
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);
}
Expand Down Expand Up @@ -191,37 +226,30 @@ function planSummary(plan: SyncPlanItem[]) {
/** 构造交互式冲突解决器 */
function makeInteractiveResolver() {
return async (item: SyncPlanItem): Promise<SyncAction> => {
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<string> {
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))}`;
}
17 changes: 14 additions & 3 deletions src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,22 +58,33 @@ export function fail(error: unknown): never {
process.exit(1);
}

/** 命令行 y/N 确认提示(问题走 stderr,不污染 stdout) */
export function confirm(question: string): Promise<boolean> {
/** 读取一行用户输入(问题走 stderr,不污染 stdout) */
export function prompt(question: string): Promise<string> {
return new Promise((resolve) => {
process.stderr.write(question);
const stdin = process.stdin;
stdin.setEncoding("utf-8");
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<boolean> {
return (await prompt(question)).toLowerCase() === "y";
}

/**
* 部分条目失败时的退出码:结果仍按 ok:true 输出(data.errors 列出失败项),
* 但以非零码退出,便于脚本用 `&&` / `$?` 感知。整体失败走 fail(),退出码为 1。
*/
export const EXIT_PARTIAL_FAILURE = 2;

/**
* 从 stdin 读取全部输入(用于 create/update 从管道接收内容)。
* 若 stdin 是 TTY(无管道输入)返回 null。
Expand Down
41 changes: 35 additions & 6 deletions src/sync-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<Scenario, SyncAction>> = {
"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<Record<Scenario, SyncAction>> = {
"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";
}
Loading
Loading