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
+
+- 引用本地图片文件(``)在 `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:
"
```
-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 #  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/{fileId}` 转换为小米笔记的图片标记。
+工具会自动把 `minote://image/{fileId}` 转换为小米笔记的图片标记。也可以直接引用本地图片文件,写入时会自动上传:
+
+```bash
+mi-note-cli create --file ./notes/旅行.md # 其中的  会自动上传
+```
+
+`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 拿到 ,放进 --content 即可插图。
+ 插图:upload-image 后把  放进内容;或直接写本地路径  自动上传。
+ --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) => ``,
+ );
// 移除格式标记
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("", map), []);
+ assert.deepEqual(findUnresolvedAttachments(""), []);
+ assert.deepEqual(findUnresolvedAttachments(""), []);
+ assert.deepEqual(findUnresolvedAttachments("", map), ["找不到图片文件或格式不支持:./new.png"]);
+ assert.deepEqual(findUnresolvedAttachments("看图 ", map), [
+ "图片需独占一行:看图 ",
+ ]);
+ 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/);
+ assert.doesNotThrow(() => assertAttachmentsResolvable("纯文本"));
+});
+
+test("xmlToMarkdown: 未登记附件的图片保留为 minote 引用并可往返", () => {
+ const xml = '
';
+ const md = xmlToMarkdown(xml);
+ assert.equal(md, "");
+ 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");
+});