From 86eb016f7304daa69b39abce5b2fb81cd53d06e8 Mon Sep 17 00:00:00 2001 From: ceynri Date: Fri, 2 Oct 2026 16:43:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BE=E7=89=87=E5=BE=80=E8=BF=94?= =?UTF-8?q?=E8=BF=98=E5=8E=9F=EF=BC=8C=E5=B9=B6=E8=87=AA=E5=8A=A8=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E6=9C=AC=E5=9C=B0=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create/update 把 Markdown 里的附件引用还原成云端 ;无法回写的引用直接报错。 本地路径图片自动上传并登记到笔记附件。相对路径基于 --file 所在目录。 --- CHANGELOG.md | 10 ++++ README.en.md | 10 +++- README.md | 10 +++- TODO.md | 2 +- skills/mi-note-cli/SKILL.md | 2 + src/cli.ts | 3 +- src/commands/create.ts | 13 +++-- src/commands/shared.ts | 8 +++ src/commands/update.ts | 28 +++++++++-- src/converter.ts | 84 ++++++++++++++++++++++++++++++- src/images.ts | 98 +++++++++++++++++++++++++++++++++++++ tests/converter.test.ts | 57 +++++++++++++++++++++ 12 files changed, 310 insertions(+), 15 deletions(-) create mode 100644 src/images.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fc223fd..ef5a7a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,20 @@ ## [Unreleased] [[compare]](https://github.com/ceynri/mi-note-cli/compare/v0.3.1...HEAD) +### Added + +- 引用本地图片文件(`![](./photo.jpg)`)在 `create` / `update` 时自动上传,并登记为笔记附件,无需先运行 `upload-image` + ### Changed - `--limit` 必须是正整数,非法值直接报错 - 找不到可用浏览器时提示安装 Chrome 或运行 `npx playwright install chromium` +- `create` / `update` 从 `--file` 读取内容时,相对图片路径基于该文件所在目录 + +### Fixed + +- 本地修改带图笔记后同步上行,或 `get` → 修改 → `update` 时,图片被写成纯文本导致云端丢图;无法回写的附件引用现在会报错而不是静默丢失 +- 云端笔记 `setting.data` 里未登记附件的图片在导出时丢图;现在保留为 `minote://image/` 引用,仍可正常回写 ### Security diff --git a/README.en.md b/README.en.md index ae38bf1..8433e53 100644 --- a/README.en.md +++ b/README.en.md @@ -108,7 +108,13 @@ mi-note-cli create --title "With image" --content "See: ![图片](minote://image/xxxxxx)" ``` -The tool auto-converts `minote://image/{fileId}` into Xiaomi's image markup. +The tool auto-converts `minote://image/{fileId}` into Xiaomi's image markup. You can also reference a local image file directly; it is uploaded automatically on write: + +```bash +mi-note-cli create --file ./notes/trip.md # ![](./photo.jpg) inside it gets uploaded +``` + +Markdown printed by `get` / `sync` can be edited and passed straight back to `update`: its image references are restored to the note's existing images. Images must sit on their own line; relative paths resolve against the Markdown file's directory (or the working directory for `--content` / stdin). ## Data Directories @@ -181,7 +187,7 @@ When `fileNameTemplate` is unset, the behavior is equivalent to `${subject}` — > The project is in early stages with limited real-world usage, and the converter may have known or unknown gaps. Validate on a small subset first for important notes, and keep manual backups of critical content — feedback on incorrect conversions is very welcome. -**Stably supported** (covered by both unit and integration round-trip tests): headings (H1–H3), ordered / unordered lists (including multi-level nesting and paragraph-broken numbering), checkboxes, blockquotes, horizontal rules, bold `**bold**` / italic `*italic*` / strikethrough `~~strike~~` / underline `...`, links, inline code, paragraphs, image attachments. +**Stably supported** (covered by both unit and integration round-trip tests): headings (H1–H3), ordered / unordered lists (including multi-level nesting and paragraph-broken numbering), checkboxes, blockquotes, horizontal rules, bold `**bold**` / italic `*italic*` / strikethrough `~~strike~~` / underline `...`, links, inline code, paragraphs, image attachments. Audio/video attachments are export-only; notes containing them cannot be written back after local edits. **Currently unsupported**: tables, footnotes, definition lists, fenced code-block language tags, Setext-style headings (`===` / `---`), inline HTML other than ``. These will be silently dropped or kept as literal text during conversion. diff --git a/README.md b/README.md index 04f52d6..29f0423 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,13 @@ mi-note-cli create --title "带图" --content "看图: ![图片](minote://image/xxxxxx)" ``` -工具会自动把 `minote://image/{fileId}` 转换为小米笔记的图片标记。 +工具会自动把 `minote://image/{fileId}` 转换为小米笔记的图片标记。也可以直接引用本地图片文件,写入时会自动上传: + +```bash +mi-note-cli create --file ./notes/旅行.md # 其中的 ![](./photo.jpg) 会自动上传 +``` + +`get` / `sync` 输出的 Markdown 可以直接修改后交给 `update`,其中的图片引用会还原为笔记原有的图片。图片需独占一行;相对路径基于 Markdown 文件所在目录(`--content` / 标准输入时基于当前目录)。 ## 数据目录 @@ -181,7 +187,7 @@ ${YYYY}-${MM}-${DD}[_${title}] > 项目刚起步,实际使用样本还不多,转换器可能存在已知或未知的漏洞。重要笔记建议先在小范围验证,重要内容做手动备份;遇到不对的转换欢迎反馈。 -**已稳定支持**(单元/集成 round-trip 双向往返测试覆盖):标题(H1–H3)、有序/无序列表(含多级嵌套、跨段续号)、复选框、引用、分割线、加粗 `**bold**` / 斜体 `*italic*` / 删除线 `~~strike~~` / 下划线 `...`、链接、行内代码、段落、图片附件。 +**已稳定支持**(单元/集成 round-trip 双向往返测试覆盖):标题(H1–H3)、有序/无序列表(含多级嵌套、跨段续号)、复选框、引用、分割线、加粗 `**bold**` / 斜体 `*italic*` / 删除线 `~~strike~~` / 下划线 `...`、链接、行内代码、段落、图片附件。音频/视频附件只支持导出,含这类附件的笔记在本地修改后无法回写。 **当前不支持**:表格、脚注、定义列表、围栏代码块语言标识、Setext 风格标题(`===` / `---`)、`` 之外的内嵌 HTML。这些语法在转换时会被悄悄丢弃或保留为字面文本。 diff --git a/TODO.md b/TODO.md index ac9ea21..9e1512e 100644 --- a/TODO.md +++ b/TODO.md @@ -30,4 +30,4 @@ - `mi-note-cli sync diagnose` 子命令:抽样若干笔记做 `local md → markdownToXml → upload → fetch → xmlToMarkdown → md`,对比首尾是否相等,列出哪些笔记/语法在漂移 - 或者 sync 时检测到非预期的 baseHash 漂移自动告警(而不是默默走覆盖路径) -为什么暂缓:当前已支持语法实测无损(夹具单元 + 集成 round-trip 通过),漂移仅可能发生在不支持的语法上,README 已显式列出白名单与风险。等真出现漂移再做工具化排查。 +为什么暂缓:图片附件曾经是已支持语法里的漂移点(上行时未还原成 ``),已修复并加了上行前的附件守护,无法回写的附件会直接报错而不是静默丢失。其余已支持语法有夹具单元 + 集成 round-trip 覆盖。等再出现漂移再做工具化排查。 diff --git a/skills/mi-note-cli/SKILL.md b/skills/mi-note-cli/SKILL.md index 90a7a04..238e2b1 100644 --- a/skills/mi-note-cli/SKILL.md +++ b/skills/mi-note-cli/SKILL.md @@ -65,6 +65,8 @@ npx mi-note-cli upload-image ./photo.png --json # 2. 把该 Markdown 放进 create/update 的 --content 中即可 ``` +`get` / `sync` 输出里的图片引用可以原样保留交给 `update`,会还原为原图片;引用本地图片文件(相对路径基于 Markdown 文件所在目录)会自动上传,无需先调 `upload-image`。图片需独占一行。内容里有无法回写的附件(找不到的图片文件、音频/视频)时命令会报错,不会把附件写成纯文本。 + ### 导出(单向云→本地) ```bash diff --git a/src/cli.ts b/src/cli.ts index f1ce5d1..fc2587e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -129,7 +129,8 @@ program mi-note-cli create --title "标题" --content "# 正文\\n\\n- 列表项" echo "# 来自管道" | mi-note-cli create --title 管道笔记 mi-note-cli create --file ./note.md --folder 123 --json - 先用 upload-image 拿到 ![](minote://image/),放进 --content 即可插图。 + 插图:upload-image 后把 ![](minote://image/) 放进内容;或直接写本地路径 ![](path.jpg) 自动上传。 + --file 里的相对图片路径基于该文件所在目录;--content / 管道时基于当前目录。 `, ); diff --git a/src/commands/create.ts b/src/commands/create.ts index 484ee4e..1917f31 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -1,5 +1,6 @@ -import { getClient, resolveContent } from "./shared.js"; +import { getClient, resolveContent, contentBaseDir } from "./shared.js"; import { markdownToXml, extractSnippet } from "../converter.js"; +import { uploadLocalImages, withAttachments } from "../images.js"; import { buildExtraInfoString } from "../note.js"; import { success, logInfo, fail } from "../output.js"; import type { WriteNoteEntry } from "../types.js"; @@ -31,8 +32,14 @@ export async function createCommand(opts: CreateOptions): Promise { } const client = await getClient(); + const { imageMap, uploaded } = await uploadLocalImages( + client, + content, + new Map(), + [contentBaseDir(opts)], + ); const now = Date.now(); - const xmlContent = markdownToXml(content); + const xmlContent = markdownToXml(content, imageMap); const entry: WriteNoteEntry = { colorId, @@ -41,7 +48,7 @@ export async function createCommand(opts: CreateOptions): Promise { modifyDate: now, content: xmlContent, alertDate: 0, - setting: { themeId: 0, stickyTime: 0, version: 0 }, + setting: withAttachments(undefined, uploaded), extraInfo: buildExtraInfoString(opts.title), snippet: extractSnippet(xmlContent), }; diff --git a/src/commands/shared.ts b/src/commands/shared.ts index fe21c97..d991963 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; import { ensureAuth, peekOrRefreshAuth, refreshAuth } from "../auth.js"; import { MiNoteClient } from "../client.js"; import { readStdin, isJsonMode } from "../output.js"; @@ -47,6 +48,13 @@ export async function resolveContent(opts: { return await readStdin(); } +/** 内容里本地图片相对路径的基准目录:--file 时为文件所在目录,否则为当前目录 */ +export function contentBaseDir(opts: { file?: string; content?: string }): string { + return opts.content === undefined && opts.file + ? dirname(resolve(opts.file)) + : process.cwd(); +} + /** 解析 --limit:必须是正整数;未提供时返回 fallback */ export function parseLimit(raw: string | undefined, fallback: number): number { if (raw === undefined) return fallback; diff --git a/src/commands/update.ts b/src/commands/update.ts index aafc86b..44b534b 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -1,5 +1,11 @@ -import { getClient, resolveContent } from "./shared.js"; -import { markdownToXml, extractSnippet } from "../converter.js"; +import { getClient, resolveContent, contentBaseDir } from "./shared.js"; +import { + markdownToXml, + extractSnippet, + parseNoteEntry, + buildImageMap, +} from "../converter.js"; +import { uploadLocalImages, withAttachments } from "../images.js"; import { buildExtraInfoString } from "../note.js"; import { success, logInfo, fail } from "../output.js"; import type { WriteNoteEntry } from "../types.js"; @@ -33,8 +39,20 @@ export async function updateCommand( const current = await client.getNote(id); const newContent = await resolveContent(opts); - const xmlContent = - newContent !== null ? markdownToXml(newContent) : current.content ?? ""; + let xmlContent = current.content ?? ""; + let setting = current.setting ?? { themeId: 0, stickyTime: 0, version: 0 }; + if (newContent !== null) { + // get / sync 写出的附件引用按当前笔记附件还原,内容可原样 get → 改 → update;新的本地图片自动上传 + const known = buildImageMap(parseNoteEntry(current).files, "assets/", "../assets/"); + const { imageMap, uploaded } = await uploadLocalImages( + client, + newContent, + known, + [contentBaseDir(opts)], + ); + xmlContent = markdownToXml(newContent, imageMap); + setting = withAttachments(setting, uploaded); + } const now = Date.now(); const entry: WriteNoteEntry = { @@ -45,7 +63,7 @@ export async function updateCommand( modifyDate: now, colorId: colorOverride ?? current.colorId ?? 0, content: xmlContent, - setting: current.setting ?? { themeId: 0, stickyTime: 0, version: 0 }, + setting, folderId: opts.folder ?? String(current.folderId ?? "0"), alertDate: current.alertDate ?? 0, extraInfo: buildExtraInfoString(opts.title, normalizeExtraInfo(current.extraInfo)), diff --git a/src/converter.ts b/src/converter.ts index b28f6ac..e76def2 100644 --- a/src/converter.ts +++ b/src/converter.ts @@ -172,6 +172,11 @@ export function xmlToMarkdown( // 预处理:替换附件标记 text = replaceAttachments(text, files, assetPrefix); + // 未登记在 setting.data 里的图片没有本地文件可指向,保留为 minote 引用,避免转换时丢图 + text = text.replace( + /]*fileid="([^"]+)"[^>]*\/>/g, + (_m, fileId: string) => `![](minote://image/${fileId})`, + ); // 移除格式标记 text = text.replace(//g, ""); text = text.replace(/<0\/>/g, ""); @@ -418,6 +423,7 @@ function replaceAttachments( // ============ Markdown → XML ============ const INDENT_TAB = "\t"; +const IMAGE_RE = /!\[[^\]]*\]\(([^)]+)\)/; /** * 将 Markdown 转换为小米笔记 XML 内容(用于创建/更新)。 @@ -499,7 +505,7 @@ export function markdownToXml( const line = rawLine.replace(/\s+$/g, ""); // 图片:minote://image/{id} 或本地映射 - const imageMatch = line.match(/!\[[^\]]*\]\(([^)]+)\)/); + const imageMatch = line.match(IMAGE_RE); if (imageMatch) { const fileId = resolveImageFileId(imageMatch[1], imageMap); if (fileId) { @@ -612,6 +618,82 @@ function resolveImageFileId( return imageMap.get(target); } +/** + * 附件落盘引用 → fileId 映射,与 xmlToMarkdown 产出的 `` 引用一一对应。 + * 上行(markdownToXml)时传入,才能把本地 Markdown 里的图片还原成云端 。 + * 可传多个前缀以兼容不同位置/旧版本写出的引用。 + */ +export function buildImageMap( + files: NoteFile[], + ...assetPrefixes: string[] +): Map { + const prefixes = assetPrefixes.length > 0 ? assetPrefixes : ["assets/"]; + const map = new Map(); + for (const prefix of prefixes) { + for (const f of files) map.set(`${prefix}${f.name}`, f.fileId); + } + return map; +} + +/** 每行第一个图片引用的 target(与 markdownToXml 的识别规则一致) */ +export function listImageTargets(markdown: string): string[] { + const targets: string[] = []; + for (const line of markdown.replace(/\r\n/g, "\n").split("\n")) { + const m = line.match(IMAGE_RE); + if (m) targets.push(m[1]); + } + return targets; +} + +/** 是否为需要上传的本地图片引用(外链与 minote 引用之外的都算) */ +export function isLocalImageTarget(target: string): boolean { + return !/^https?:\/\//i.test(target) && !target.startsWith("minote://image/"); +} + +/** + * 找出 markdownToXml 无法还原、上传后会退化成纯文本的附件引用: + * - 不在 imageMap 里的本地图片(通常是文件找不到;外链 http(s) 图片按原样保留为文本,不算) + * - 与文字同行的图片(整行会被替换为 ,同行文字丢失) + * - 指向 assets/ 的音频/视频等非图片链接(暂不支持回写) + */ +export function findUnresolvedAttachments( + markdown: string, + imageMap: Map = new Map(), +): string[] { + const problems: string[] = []; + for (const raw of markdown.replace(/\r\n/g, "\n").split("\n")) { + const line = raw.trim(); + const image = line.match(IMAGE_RE); + if (image && !/^https?:\/\//i.test(image[1])) { + if (!resolveImageFileId(image[1], imageMap)) { + problems.push(`找不到图片文件或格式不支持:${image[1]}`); + } else if (line !== image[0]) { + problems.push(`图片需独占一行:${line}`); + } + } + for (const m of line.matchAll(/(? = new Map(), +): void { + const problems = findUnresolvedAttachments(markdown, imageMap); + if (problems.length > 0) { + throw new Error( + `内容里有无法回写到云端的附件引用,已中止以免丢失附件:\n ${problems.join("\n ")}\n` + + " 本地图片路径相对 Markdown 文件所在目录(--content / 标准输入时相对当前目录)。", + ); + } +} + /** 从 XML 内容提取首行非空文本作为 snippet */ export function extractSnippet(xml: string): string { return ( diff --git a/src/images.ts b/src/images.ts new file mode 100644 index 0000000..003d2c3 --- /dev/null +++ b/src/images.ts @@ -0,0 +1,98 @@ +import { readFile, stat } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import type { MiNoteClient } from "./client.js"; +import { + listImageTargets, + isLocalImageTarget, + assertAttachmentsResolvable, +} from "./converter.js"; +import { inferImageMimeType } from "./utils.js"; +import type { NoteSetting, RawNoteFile } from "./types.js"; + +/** + * 上传 Markdown 里引用的本地图片,返回可交给 markdownToXml 的 imageMap, + * 以及需要登记到笔记 setting.data 的附件(小米客户端靠它识别图片类型)。 + * + * 先按「本地图片都能上传」做一次附件守护再真正上传,避免因其他问题中止时留下无用的上传。 + * + * @param imageMap 已知引用 → fileId(如当前笔记已有附件) + * @param baseDirs 解析相对路径的基准目录,按顺序查找 + */ +export async function uploadLocalImages( + client: Pick, + markdown: string, + imageMap: Map, + baseDirs: string[], +): Promise<{ imageMap: Map; uploaded: RawNoteFile[] }> { + const pending = new Map(); + for (const target of listImageTargets(markdown)) { + if (!isLocalImageTarget(target) || imageMap.has(target) || pending.has(target)) continue; + const path = await findLocalImage(target, baseDirs); + if (path) pending.set(target, path); + } + + const provisional = new Map(imageMap); + for (const target of pending.keys()) provisional.set(target, "pending"); + assertAttachmentsResolvable(markdown, provisional); + + const result = new Map(imageMap); + const uploaded: RawNoteFile[] = []; + const fileIdByPath = new Map(); + for (const [target, path] of pending) { + let fileId = fileIdByPath.get(path); + if (!fileId) { + const mimeType = inferImageMimeType(path); + const buffer = new Uint8Array(await readFile(path)); + const res = await client.uploadImage(buffer, { filename: basename(path), mimeType }); + fileId = res.fileId; + fileIdByPath.set(path, fileId); + uploaded.push({ fileId, digest: res.digest, mimeType }); + } + result.set(target, fileId); + } + return { imageMap: result, uploaded }; +} + +/** 把新上传的附件追加进 setting.data(已存在的 fileId 不重复登记) */ +export function withAttachments( + setting: NoteSetting | undefined, + uploaded: RawNoteFile[], +): NoteSetting { + const base = setting ?? { themeId: 0, stickyTime: 0, version: 0 }; + if (uploaded.length === 0) return base; + const existing = base.data ?? []; + const known = new Set(existing.map((f) => f.fileId)); + return { + ...base, + data: [...existing, ...uploaded.filter((f) => !known.has(f.fileId))], + }; +} + +/** 解析 Markdown 图片 target 为本地图片文件路径;不存在或不是图片时返回 undefined */ +async function findLocalImage( + target: string, + baseDirs: string[], +): Promise { + // 去掉可选标题 `path "title"` 与尖括号 `` + let raw = target.replace(/\s+"[^"]*"$/, "").trim(); + if (raw.startsWith("<") && raw.endsWith(">")) raw = raw.slice(1, -1); + const candidates = new Set([raw]); + try { + candidates.add(decodeURIComponent(raw)); + } catch { + // 非法百分号编码,按原样查找 + } + + for (const dir of baseDirs) { + for (const c of candidates) { + const path = resolve(dir, c); + if (!inferImageMimeType(path).startsWith("image/")) continue; + try { + if ((await stat(path)).isFile()) return path; + } catch { + // 不存在,继续找 + } + } + } + return undefined; +} diff --git a/tests/converter.test.ts b/tests/converter.test.ts index 992f131..0831478 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -11,6 +11,9 @@ import { extractSnippet, truncateDisplay, renderFileNameTemplate, + buildImageMap, + findUnresolvedAttachments, + assertAttachmentsResolvable, } from "../src/converter.ts"; import type { RawNoteEntry, ParsedNote } from "../src/types.ts"; @@ -705,3 +708,57 @@ test("round-trip: 综合夹具 md → xml → md 严格相等(trim 后)", () // xmlToMarkdown 末尾 trim() 会去掉文件末尾换行,原文也 trim 后比对 assert.equal(back.trim(), md.trim()); }); + +// ============ 上行附件守护 ============ + +test("buildImageMap: assets 引用可还原为 ", () => { + const note = parseNoteEntry({ + id: "1", + createDate: 1700000000000, + content: '', + setting: { data: [{ fileId: "1234.abcdEFG", mimeType: "image/jpeg" }] }, + }); + const md = xmlToMarkdown(note.content, note.files); + assert.equal( + markdownToXml(md, buildImageMap(note.files)), + '', + ); +}); + +test("findUnresolvedAttachments: 识别会丢失的附件引用", () => { + const map = new Map([["assets/a.jpg", "id1"]]); + assert.deepEqual(findUnresolvedAttachments("![](assets/a.jpg)", map), []); + assert.deepEqual(findUnresolvedAttachments("![](minote://image/x)"), []); + assert.deepEqual(findUnresolvedAttachments("![](https://example.com/x.png)"), []); + assert.deepEqual(findUnresolvedAttachments("![](./new.png)", map), ["找不到图片文件或格式不支持:./new.png"]); + assert.deepEqual(findUnresolvedAttachments("看图 ![](assets/a.jpg)", map), [ + "图片需独占一行:看图 ![](assets/a.jpg)", + ]); + assert.deepEqual(findUnresolvedAttachments("[🔊 a.mp3](assets/a.mp3)"), [ + "暂不支持回写的附件:assets/a.mp3", + ]); + assert.deepEqual(findUnresolvedAttachments("[文档](https://example.com)"), []); +}); + +test("assertAttachmentsResolvable: 有问题时抛错并提示 upload-image", () => { + assert.throws(() => assertAttachmentsResolvable("![](./new.png)"), /new\.png/); + assert.doesNotThrow(() => assertAttachmentsResolvable("纯文本")); +}); + +test("xmlToMarkdown: 未登记附件的图片保留为 minote 引用并可往返", () => { + const xml = ''; + const md = xmlToMarkdown(xml); + assert.equal(md, "![](minote://image/1234.abcdEFG)"); + assert.equal(markdownToXml(md), xml); +}); + +test("buildImageMap: 支持多个前缀", () => { + const note = parseNoteEntry({ + id: "1", + createDate: 1700000000000, + setting: { data: [{ fileId: "1234.abcdEFG", mimeType: "image/jpeg" }] }, + }); + const map = buildImageMap(note.files, "../assets/", "assets/"); + assert.equal(map.size, 2); + for (const id of map.values()) assert.equal(id, "1234.abcdEFG"); +});