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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `<u>...</u>`, 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 `<u>...</u>`, 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 `<u>`. These will be silently dropped or kept as literal text during conversion.

Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` / 标准输入时基于当前目录)。

## 数据目录

Expand Down Expand Up @@ -181,7 +187,7 @@ ${YYYY}-${MM}-${DD}[_${title}]

> 项目刚起步,实际使用样本还不多,转换器可能存在已知或未知的漏洞。重要笔记建议先在小范围验证,重要内容做手动备份;遇到不对的转换欢迎反馈。

**已稳定支持**(单元/集成 round-trip 双向往返测试覆盖):标题(H1–H3)、有序/无序列表(含多级嵌套、跨段续号)、复选框、引用、分割线、加粗 `**bold**` / 斜体 `*italic*` / 删除线 `~~strike~~` / 下划线 `<u>...</u>`、链接、行内代码、段落、图片附件。
**已稳定支持**(单元/集成 round-trip 双向往返测试覆盖):标题(H1–H3)、有序/无序列表(含多级嵌套、跨段续号)、复选框、引用、分割线、加粗 `**bold**` / 斜体 `*italic*` / 删除线 `~~strike~~` / 下划线 `<u>...</u>`、链接、行内代码、段落、图片附件。音频/视频附件只支持导出,含这类附件的笔记在本地修改后无法回写。

**当前不支持**:表格、脚注、定义列表、围栏代码块语言标识、Setext 风格标题(`===` / `---`)、`<u>` 之外的内嵌 HTML。这些语法在转换时会被悄悄丢弃或保留为字面文本。

Expand Down
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@
- `mi-note-cli sync diagnose` 子命令:抽样若干笔记做 `local md → markdownToXml → upload → fetch → xmlToMarkdown → md`,对比首尾是否相等,列出哪些笔记/语法在漂移
- 或者 sync 时检测到非预期的 baseHash 漂移自动告警(而不是默默走覆盖路径)

为什么暂缓:当前已支持语法实测无损(夹具单元 + 集成 round-trip 通过),漂移仅可能发生在不支持的语法上,README 已显式列出白名单与风险。等真出现漂移再做工具化排查。
为什么暂缓:图片附件曾经是已支持语法里的漂移点(上行时未还原成 `<img>`),已修复并加了上行前的附件守护,无法回写的附件会直接报错而不是静默丢失。其余已支持语法有夹具单元 + 集成 round-trip 覆盖。等再出现漂移再做工具化排查。
2 changes: 2 additions & 0 deletions skills/mi-note-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<fileId>),放进 --content 即可插图。
插图:upload-image 后把 ![](minote://image/<fileId>) 放进内容;或直接写本地路径 ![](path.jpg) 自动上传。
--file 里的相对图片路径基于该文件所在目录;--content / 管道时基于当前目录。
`,
);

Expand Down
13 changes: 10 additions & 3 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -31,8 +32,14 @@ export async function createCommand(opts: CreateOptions): Promise<void> {
}

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,
Expand All @@ -41,7 +48,7 @@ export async function createCommand(opts: CreateOptions): Promise<void> {
modifyDate: now,
content: xmlContent,
alertDate: 0,
setting: { themeId: 0, stickyTime: 0, version: 0 },
setting: withAttachments(undefined, uploaded),
extraInfo: buildExtraInfoString(opts.title),
snippet: extractSnippet(xmlContent),
};
Expand Down
8 changes: 8 additions & 0 deletions src/commands/shared.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
28 changes: 23 additions & 5 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 = {
Expand All @@ -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)),
Expand Down
84 changes: 83 additions & 1 deletion src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ export function xmlToMarkdown(

// 预处理:替换附件标记
text = replaceAttachments(text, files, assetPrefix);
// 未登记在 setting.data 里的图片没有本地文件可指向,保留为 minote 引用,避免转换时丢图
text = text.replace(
/<img\s+[^>]*fileid="([^"]+)"[^>]*\/>/g,
(_m, fileId: string) => `![](minote://image/${fileId})`,
);
// 移除格式标记
text = text.replace(/<new-format\s*\/>/g, "");
text = text.replace(/<0\/>/g, "");
Expand Down Expand Up @@ -418,6 +423,7 @@ function replaceAttachments(
// ============ Markdown → XML ============

const INDENT_TAB = "\t";
const IMAGE_RE = /!\[[^\]]*\]\(([^)]+)\)/;

/**
* 将 Markdown 转换为小米笔记 XML 内容(用于创建/更新)。
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -612,6 +618,82 @@ function resolveImageFileId(
return imageMap.get(target);
}

/**
* 附件落盘引用 → fileId 映射,与 xmlToMarkdown 产出的 `<prefix><name>` 引用一一对应。
* 上行(markdownToXml)时传入,才能把本地 Markdown 里的图片还原成云端 <img>。
* 可传多个前缀以兼容不同位置/旧版本写出的引用。
*/
export function buildImageMap(
files: NoteFile[],
...assetPrefixes: string[]
): Map<string, string> {
const prefixes = assetPrefixes.length > 0 ? assetPrefixes : ["assets/"];
const map = new Map<string, string>();
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) 图片按原样保留为文本,不算)
* - 与文字同行的图片(整行会被替换为 <img>,同行文字丢失)
* - 指向 assets/ 的音频/视频等非图片链接(暂不支持回写)
*/
export function findUnresolvedAttachments(
markdown: string,
imageMap: Map<string, string> = 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(/(?<!!)\[[^\]]*\]\(([^)]+)\)/g)) {
if (/(^|\/)assets\//.test(m[1])) {
problems.push(`暂不支持回写的附件:${m[1]}`);
}
}
}
return problems;
}

/** 上行前的附件守护:存在会退化成纯文本的引用时直接报错,避免覆盖云端附件 */
export function assertAttachmentsResolvable(
markdown: string,
imageMap: Map<string, string> = 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 (
Expand Down
Loading
Loading