md-to-mowen 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -6,10 +6,14 @@ import { fileURLToPath } from 'url';
6
6
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
7
7
  import { homedir } from 'os';
8
8
  import { createInterface } from 'readline';
9
+ import { lstat } from 'fs/promises';
9
10
  import { processFile } from '../publish/process-file.js';
11
+ import { processDirectory } from '../publish/process-directory.js';
10
12
  import { MowenClient } from '../mowen/client.js';
11
13
  import { noteAtomToMast } from '../noteatom/to-mast.js';
12
14
  import { mastToMarkdown } from '../mast/to-markdown.js';
15
+ import { findMetadataPath, readMetadata, writeMetadata, lookupNote, upsertNote } from '../shared/metadata.js';
16
+ import { loadConfig } from '../shared/config.js';
13
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
18
  /** 按优先级搜索 .env 文件 */
15
19
  function searchEnvPaths() {
@@ -104,12 +108,14 @@ program
104
108
  program
105
109
  .command('publish')
106
110
  .description('发布 Markdown 文件为墨问笔记')
107
- .requiredOption('-i, --input <file>', 'Markdown 文件路径')
111
+ .requiredOption('-i, --input <path>', 'Markdown 文件路径或目录')
108
112
  .option('--note-id <id>', '已有笔记 ID(编辑模式,全量替换)')
109
- .option('--tags <tags>', '标签,逗号分隔(如 "tech,ai"')
110
- .option('--auto-publish', '自动发布(非草稿)', false)
113
+ .option('--tags <tags>', '标签,逗号分隔(如 "tech,ai"),覆盖配置文件 defaultTags')
114
+ .option('--auto-publish', '自动发布(非草稿),覆盖配置文件 autoPublish', false)
111
115
  .option('--dry-run', '走完流水线但不调用墨问 API,仅打印统计', false)
112
- .option('--cache-dir <dir>', '保存各阶段产物的目录(调试用)', 'out/pipeline-cache')
116
+ .option('--cache-dir <dir>', '保存各阶段产物的目录(调试用)')
117
+ .option('--code-block-style <style>', '代码块样式:paragraph 或 codeblock')
118
+ .option('--no-recursive', '批量发布时不递归扫描子目录', false)
113
119
  .action(async (opts) => {
114
120
  const apiKey = getApiKey();
115
121
  if (!apiKey && !opts.dryRun) {
@@ -121,24 +127,96 @@ program
121
127
  console.error('获取方式:微信小程序"墨问" → 个人主页 → 开发者 → 我的 API Key');
122
128
  process.exit(1);
123
129
  }
130
+ // ── 加载配置 ────────────────────────────────────────────────────────────────
131
+ const absInput = resolve(opts.input);
132
+ const inputDir = dirname(absInput);
133
+ const cliOverrides = {};
134
+ // CLI 参数优先级最高,仅当明确提供时才覆盖配置
135
+ if (opts.tags !== undefined) {
136
+ cliOverrides.defaultTags = opts.tags;
137
+ }
138
+ if (opts.autoPublish) {
139
+ cliOverrides.autoPublish = true;
140
+ }
141
+ if (opts.cacheDir) {
142
+ cliOverrides.cacheDir = opts.cacheDir;
143
+ }
144
+ if (opts.codeBlockStyle) {
145
+ const style = opts.codeBlockStyle;
146
+ if (style === 'paragraph' || style === 'codeblock') {
147
+ cliOverrides.codeBlockStyle = style;
148
+ }
149
+ else {
150
+ console.error(`错误:--code-block-style="${style}" 无效,支持:paragraph, codeblock`);
151
+ process.exit(1);
152
+ }
153
+ }
154
+ const resolvedConfig = loadConfig(cliOverrides, inputDir);
124
155
  const client = new MowenClient(apiKey ?? 'dry-run-placeholder');
125
- const tags = opts.tags ? opts.tags.split(',').map((t) => t.trim()) : undefined;
156
+ // 使用 resolvedConfig.defaultTags(可能来自配置文件),但 CLI --tags 覆盖时优先使用
157
+ const tagsStr = opts.tags !== undefined ? opts.tags : resolvedConfig.defaultTags;
158
+ const tags = tagsStr ? tagsStr.split(',').map((t) => t.trim()) : undefined;
159
+ // ── 检测 input 类型 ───────────────────────────────────────────────────────
126
160
  try {
127
- const result = await processFile(opts.input, client, {
128
- ...(opts.noteId ? { noteId: opts.noteId } : {}),
129
- ...(tags ? { tags } : {}),
130
- autoPublish: opts.autoPublish,
131
- dryRun: opts.dryRun,
132
- ...(opts.cacheDir ? { cacheDir: opts.cacheDir } : {}),
133
- });
134
- if (!result.dryRun) {
135
- console.log(`\n✅ 发布成功`);
136
- console.log(` 笔记 ID:${result.noteId}`);
137
- console.log(` 访问地址:${result.noteUrl}\n`);
161
+ const inputStat = await lstat(absInput);
162
+ if (inputStat.isDirectory()) {
163
+ // 目录:批量处理
164
+ const result = await processDirectory(opts.input, client, {
165
+ ...(tags ? { tags } : {}),
166
+ autoPublish: resolvedConfig.autoPublish,
167
+ dryRun: opts.dryRun,
168
+ cacheDir: resolvedConfig.cacheDir,
169
+ recursive: !opts.noRecursive,
170
+ });
171
+ // 批量模式下,有失败则 exit 1
172
+ if (result.failed > 0) {
173
+ process.exit(1);
174
+ }
175
+ }
176
+ else if (inputStat.isFile()) {
177
+ // 单文件:现有逻辑
178
+ const metaPath = findMetadataPath();
179
+ const metaStore = readMetadata(metaPath);
180
+ let noteId = opts.noteId;
181
+ if (!noteId && !opts.dryRun) {
182
+ const existing = lookupNote(metaStore, absInput);
183
+ if (existing) {
184
+ noteId = existing.noteId;
185
+ console.log(` 找到已有笔记映射,进入编辑模式:${noteId}`);
186
+ }
187
+ }
188
+ try {
189
+ const result = await processFile(opts.input, client, {
190
+ ...(noteId ? { noteId } : {}),
191
+ ...(tags ? { tags } : {}),
192
+ autoPublish: resolvedConfig.autoPublish,
193
+ dryRun: opts.dryRun,
194
+ cacheDir: resolvedConfig.cacheDir,
195
+ codeBlockStyle: resolvedConfig.codeBlockStyle,
196
+ });
197
+ if (!result.dryRun && result.noteId) {
198
+ upsertNote(metaStore, absInput, result.noteId);
199
+ writeMetadata(metaPath, metaStore);
200
+ }
201
+ if (!result.dryRun) {
202
+ console.log(`\n✅ 发布成功`);
203
+ console.log(` 笔记 ID:${result.noteId}`);
204
+ console.log(` 访问地址:${result.noteUrl}\n`);
205
+ }
206
+ }
207
+ catch (err) {
208
+ console.error('发布失败:', err instanceof Error ? err.message : err);
209
+ process.exit(1);
210
+ }
211
+ }
212
+ else {
213
+ console.error(`错误:路径不存在 ${absInput}`);
214
+ process.exit(1);
138
215
  }
139
216
  }
140
217
  catch (err) {
141
- console.error('发布失败:', err instanceof Error ? err.message : err);
218
+ // lstat 抛错(如路径不存在)
219
+ console.error('错误:', err instanceof Error ? err.message : err);
142
220
  process.exit(1);
143
221
  }
144
222
  });
@@ -172,4 +250,69 @@ program
172
250
  process.exit(1);
173
251
  }
174
252
  });
253
+ // ── privacy ────────────────────────────────────────────────────────────────────
254
+ program
255
+ .command('privacy')
256
+ .description('设置笔记的隐私状态')
257
+ .option('--note-id <id>', '笔记 ID')
258
+ .option('-i, --input <file>', 'Markdown 文件路径(需要元数据支持)')
259
+ .requiredOption('--visibility <visibility>', '隐私状态:public 或 private')
260
+ .option('--dry-run', '不调用 API,仅打印操作信息', false)
261
+ .action(async (opts) => {
262
+ if (!opts.noteId && !opts.input) {
263
+ console.error('错误:必须提供 --note-id 或 --input');
264
+ process.exit(1);
265
+ }
266
+ if (opts.noteId && opts.input) {
267
+ console.error('错误:--note-id 和 --input 不能同时使用');
268
+ process.exit(1);
269
+ }
270
+ const visibility = opts.visibility;
271
+ if (visibility !== 'public' && visibility !== 'private') {
272
+ console.error('错误:--visibility 必须是 public 或 private');
273
+ process.exit(1);
274
+ }
275
+ const apiKey = getApiKey();
276
+ if (!apiKey && !opts.dryRun) {
277
+ console.error('错误:未设置 MOWEN_API_KEY。');
278
+ console.error('');
279
+ console.error('请先运行以下命令配置 API Key:');
280
+ console.error(' md-to-mowen config');
281
+ console.error('');
282
+ console.error('获取方式:微信小程序"墨问" → 个人主页 → 开发者 → 我的 API Key');
283
+ process.exit(1);
284
+ }
285
+ let noteId;
286
+ if (opts.input) {
287
+ const absInput = resolve(opts.input);
288
+ const metaPath = findMetadataPath();
289
+ const metaStore = readMetadata(metaPath);
290
+ const existing = lookupNote(metaStore, absInput);
291
+ if (!existing) {
292
+ console.error('错误:未找到该文件的元数据记录,请使用 --note-id 指定笔记 ID');
293
+ process.exit(1);
294
+ }
295
+ noteId = existing.noteId;
296
+ console.log(` 从元数据找到笔记 ID:${noteId}`);
297
+ }
298
+ else {
299
+ noteId = opts.noteId;
300
+ }
301
+ if (opts.dryRun) {
302
+ console.log(`\n[dry-run] 将设置笔记 ${noteId} 隐私状态为 ${visibility}`);
303
+ return;
304
+ }
305
+ const client = new MowenClient(apiKey);
306
+ try {
307
+ await client.setPrivacy(noteId, visibility);
308
+ console.log(`\n✅ 隐私设置成功`);
309
+ console.log(` 笔记 ID:${noteId}`);
310
+ console.log(` 隐私状态:${visibility}`);
311
+ console.log(` 访问地址:https://mowen.cn/note/${noteId}\n`);
312
+ }
313
+ catch (err) {
314
+ console.error('设置失败:', err instanceof Error ? err.message : err);
315
+ process.exit(1);
316
+ }
317
+ });
175
318
  program.parse();
@@ -35,6 +35,8 @@ function serializeBlock(block, doc) {
35
35
  return serializeImage(block);
36
36
  case 'audio':
37
37
  return serializeAudio(block);
38
+ case 'codeblock':
39
+ return serializeCodeBlock(block);
38
40
  }
39
41
  }
40
42
  function serializeParagraph(block) {
@@ -64,6 +66,10 @@ function serializeAudio(block) {
64
66
  const src = block.uuid ? block.src : block.src;
65
67
  return `![audio: ${block.showNote}](${src})`;
66
68
  }
69
+ function serializeCodeBlock(block) {
70
+ const lang = block.language || '';
71
+ return '```' + lang + '\n' + block.content + '\n```';
72
+ }
67
73
  function serializeTextRun(run) {
68
74
  if (!run.marks)
69
75
  return run.text;
@@ -61,6 +61,18 @@ export class MowenClient {
61
61
  async editNote(noteId, body) {
62
62
  await this.request('/api/open/api/v1/note/edit', { noteId, body });
63
63
  }
64
+ /** 设置笔记隐私状态 */
65
+ async setPrivacy(noteId, visibility) {
66
+ await this.request('/api/open/api/v1/note/settings', {
67
+ noteId,
68
+ section: 1,
69
+ settings: {
70
+ privacy: {
71
+ type: visibility,
72
+ },
73
+ },
74
+ });
75
+ }
64
76
  }
65
77
  export class MowenApiError extends Error {
66
78
  code;
@@ -24,6 +24,8 @@ function convertBlock(block, doc) {
24
24
  return [convertImage(block)];
25
25
  case 'audio':
26
26
  return [convertAudio(block)];
27
+ case 'codeblock':
28
+ return [convertCodeBlock(block)];
27
29
  }
28
30
  }
29
31
  function convertParagraph(block) {
@@ -79,6 +81,15 @@ function convertAudio(block) {
79
81
  },
80
82
  };
81
83
  }
84
+ function convertCodeBlock(block) {
85
+ return {
86
+ type: 'codeblock',
87
+ attrs: {
88
+ language: block.language,
89
+ },
90
+ content: block.content,
91
+ };
92
+ }
82
93
  // ── 行内节点转换 ───────────────────────────────────────────────────────────────
83
94
  function convertTextRun(run) {
84
95
  const node = { type: 'text', text: run.text };
@@ -27,6 +27,8 @@ function convertNode(node, blocks) {
27
27
  return convertImage(node, blocks);
28
28
  case 'audio':
29
29
  return convertAudio(node, blocks);
30
+ case 'codeblock':
31
+ return convertCodeBlock(node, blocks);
30
32
  }
31
33
  }
32
34
  function convertParagraph(block, blocks) {
@@ -72,6 +74,17 @@ function convertAudio(block, blocks) {
72
74
  blocks[id] = mast;
73
75
  return id;
74
76
  }
77
+ function convertCodeBlock(block, blocks) {
78
+ const id = newId();
79
+ const mast = {
80
+ id,
81
+ type: 'codeblock',
82
+ language: block.attrs.language ?? '',
83
+ content: block.content ?? '',
84
+ };
85
+ blocks[id] = mast;
86
+ return id;
87
+ }
75
88
  // ── 行内节点转换 ───────────────────────────────────────────────────────────────
76
89
  function convertTextRun(run) {
77
90
  const mast = { type: 'text', text: run.text };
@@ -117,7 +117,7 @@ function makeEmptyParagraph() {
117
117
  * 将 HAST 元素转换为一组 MAST 块节点。
118
118
  * 返回数组是因为某些元素(如列表)会展开为多个块。
119
119
  */
120
- function convertBlock(node, doc) {
120
+ function convertBlock(node, doc, opts = {}) {
121
121
  const tag = node.tagName;
122
122
  // ── 标题 ──────────────────────────────────────────────────────────────────
123
123
  if (/^h[1-6]$/.test(tag)) {
@@ -164,7 +164,7 @@ function convertBlock(node, doc) {
164
164
  for (const child of node.children) {
165
165
  if (!isElement(child))
166
166
  continue;
167
- const childBlocks = convertBlock(child, doc);
167
+ const childBlocks = convertBlock(child, doc, opts);
168
168
  for (const b of childBlocks) {
169
169
  doc.blocks[b.id] = b;
170
170
  childIds.push(b.id);
@@ -179,16 +179,43 @@ function convertBlock(node, doc) {
179
179
  }
180
180
  // ── 无序列表 ──────────────────────────────────────────────────────────────
181
181
  if (tag === 'ul') {
182
- return convertList(node, doc, false, 0);
182
+ return convertList(node, doc, opts, false, 0);
183
183
  }
184
184
  // ── 有序列表 ──────────────────────────────────────────────────────────────
185
185
  if (tag === 'ol') {
186
- return convertList(node, doc, true, 0);
186
+ return convertList(node, doc, opts, true, 0);
187
187
  }
188
188
  // ── 代码块 ────────────────────────────────────────────────────────────────
189
189
  if (tag === 'pre') {
190
190
  const codeEl = node.children.find((c) => isElement(c) && c.tagName === 'code');
191
191
  const rawText = codeEl ? extractTextContent(codeEl) : extractTextContent(node);
192
+ // 提取语言标识(从 code 元素的 className)
193
+ let language = '';
194
+ if (codeEl && codeEl.properties?.className) {
195
+ const classNames = Array.isArray(codeEl.properties.className)
196
+ ? codeEl.properties.className
197
+ : [codeEl.properties.className];
198
+ // 常见格式:language-js, lang-js, js 等
199
+ const langClass = classNames.find((c) => typeof c === 'string' && (c.startsWith('language-') || c.startsWith('lang-')));
200
+ if (langClass) {
201
+ language = langClass.replace(/^language-|^lang-/, '');
202
+ }
203
+ else if (classNames.length > 0 && typeof classNames[0] === 'string') {
204
+ // 直接使用第一个 class 名作为语言(如 typescript, python)
205
+ language = classNames[0];
206
+ }
207
+ }
208
+ // codeBlockStyle: codeblock → 生成 MASTCodeBlock
209
+ if (opts.codeBlockStyle === 'codeblock') {
210
+ const block = {
211
+ id: newId(),
212
+ type: 'codeblock',
213
+ language,
214
+ content: rawText.replace(/\n$/, ''),
215
+ };
216
+ return [block];
217
+ }
218
+ // 默认 paragraph 模式:每行转为带 code 标记的 paragraph
192
219
  const lines = rawText.replace(/\n$/, '').split('\n');
193
220
  return lines.map((line) => makeParagraph([{ type: 'text', text: line, marks: { code: true } }]));
194
221
  }
@@ -215,7 +242,7 @@ function convertBlock(node, doc) {
215
242
  const blocks = [];
216
243
  for (const child of node.children) {
217
244
  if (isElement(child)) {
218
- blocks.push(...convertBlock(child, doc));
245
+ blocks.push(...convertBlock(child, doc, opts));
219
246
  }
220
247
  }
221
248
  return blocks;
@@ -228,7 +255,7 @@ function convertBlock(node, doc) {
228
255
  return [];
229
256
  }
230
257
  // ── 列表转换 ───────────────────────────────────────────────────────────────────
231
- function convertList(listEl, doc, ordered, depth) {
258
+ function convertList(listEl, doc, opts, ordered, depth) {
232
259
  const blocks = [];
233
260
  let itemIndex = 1;
234
261
  const indent = ' '.repeat(depth);
@@ -248,10 +275,10 @@ function convertList(listEl, doc, ordered, depth) {
248
275
  continue;
249
276
  }
250
277
  if (liChild.tagName === 'ul') {
251
- nestedBlocks.push(...convertList(liChild, doc, false, depth + 1));
278
+ nestedBlocks.push(...convertList(liChild, doc, opts, false, depth + 1));
252
279
  }
253
280
  else if (liChild.tagName === 'ol') {
254
- nestedBlocks.push(...convertList(liChild, doc, true, depth + 1));
281
+ nestedBlocks.push(...convertList(liChild, doc, opts, true, depth + 1));
255
282
  }
256
283
  else if (liChild.tagName === 'p') {
257
284
  paragraphContent.push(...extractInlineContent(liChild));
@@ -315,13 +342,16 @@ function tableToMarkdown(tableEl) {
315
342
  // ── 主入口 ────────────────────────────────────────────────────────────────────
316
343
  /**
317
344
  * 将 HAST Root 转换为 MASTDocument。
345
+ *
346
+ * @param hast HAST Root 节点
347
+ * @param opts 转换选项
318
348
  */
319
- export function hastToMast(hast) {
349
+ export function hastToMast(hast, opts = {}) {
320
350
  const doc = { blocks: {}, topLevel: [] };
321
351
  for (const node of hast.children) {
322
352
  if (!isElement(node))
323
353
  continue;
324
- const blocks = convertBlock(node, doc);
354
+ const blocks = convertBlock(node, doc, opts);
325
355
  for (const block of blocks) {
326
356
  doc.blocks[block.id] = block;
327
357
  doc.topLevel.push(block.id);
@@ -0,0 +1,118 @@
1
+ import { readdir, lstat } from 'fs/promises';
2
+ import { join, resolve, basename, extname } from 'path';
3
+ import { processFile } from './process-file.js';
4
+ /**
5
+ * 扫描目录中的 Markdown 文件
6
+ * - 递归扫描(除非 recursive = false)
7
+ * - 按文件名排序
8
+ * - 跳过隐藏文件、非 .md 文件、符号链接
9
+ */
10
+ export async function scanMarkdownFiles(dirPath, recursive = true) {
11
+ const absPath = resolve(dirPath);
12
+ const files = [];
13
+ await scanDir(absPath, files, recursive);
14
+ // 按文件名排序(basename)
15
+ files.sort((a, b) => basename(a).localeCompare(basename(b)));
16
+ return files;
17
+ }
18
+ async function scanDir(dir, files, recursive) {
19
+ const entries = await readdir(dir, { withFileTypes: true });
20
+ for (const entry of entries) {
21
+ const fullPath = join(dir, entry.name);
22
+ // 跳过隐藏文件(.开头)
23
+ if (entry.name.startsWith('.'))
24
+ continue;
25
+ // 跳过符号链接
26
+ if (entry.isSymbolicLink())
27
+ continue;
28
+ if (entry.isDirectory() && recursive) {
29
+ await scanDir(fullPath, files, recursive);
30
+ }
31
+ else if (entry.isFile() && extname(entry.name).toLowerCase() === '.md') {
32
+ files.push(fullPath);
33
+ }
34
+ }
35
+ }
36
+ /**
37
+ * 批量发布目录中的 Markdown 文件
38
+ */
39
+ export async function processDirectory(dirPath, client, opts = {}) {
40
+ const { recursive = true } = opts;
41
+ const absDirPath = resolve(dirPath);
42
+ // 检查目录是否存在
43
+ const dirStat = await lstat(absDirPath);
44
+ if (!dirStat.isDirectory()) {
45
+ throw new Error(`路径不是目录: ${absDirPath}`);
46
+ }
47
+ // 扫描文件
48
+ const files = await scanMarkdownFiles(absDirPath, recursive);
49
+ // 空目录处理
50
+ if (files.length === 0) {
51
+ console.log(`目录 ${absDirPath} 中没有 Markdown 文件`);
52
+ return { total: 0, success: 0, failed: 0, skipped: 0, files: [] };
53
+ }
54
+ console.log(`发现 ${files.length} 个 Markdown 文件,开始发布...\n`);
55
+ const results = [];
56
+ let index = 0;
57
+ for (const filePath of files) {
58
+ index++;
59
+ const fileName = basename(filePath);
60
+ console.log(`[${index}/${files.length}] ${fileName}`);
61
+ try {
62
+ const result = await processFile(filePath, client, opts);
63
+ results.push({
64
+ filePath,
65
+ status: 'success',
66
+ ...(result.noteId ? { noteId: result.noteId } : {}),
67
+ ...(result.noteUrl ? { noteUrl: result.noteUrl } : {}),
68
+ });
69
+ console.log(` ✅ 发布成功: ${result.noteUrl ?? '(dry-run)'}\n`);
70
+ }
71
+ catch (err) {
72
+ const errorMsg = err instanceof Error ? err.message : String(err);
73
+ results.push({
74
+ filePath,
75
+ status: 'failed',
76
+ error: errorMsg,
77
+ });
78
+ console.log(` ❌ 发布失败: ${errorMsg}\n`);
79
+ }
80
+ // 文件间隔 1.1 秒(最后一个文件不等待)
81
+ if (index < files.length) {
82
+ await sleep(1100);
83
+ }
84
+ }
85
+ // 汇总报告
86
+ const summary = computeSummary(results);
87
+ printSummary(summary);
88
+ return summary;
89
+ }
90
+ function computeSummary(files) {
91
+ const success = files.filter((f) => f.status === 'success').length;
92
+ const failed = files.filter((f) => f.status === 'failed').length;
93
+ const skipped = files.filter((f) => f.status === 'skipped').length;
94
+ return {
95
+ total: files.length,
96
+ success,
97
+ failed,
98
+ skipped,
99
+ files,
100
+ };
101
+ }
102
+ function printSummary(result) {
103
+ console.log('── 发布汇总 ──────────────────────────────────────────');
104
+ console.log(`总计:${result.total} 个文件`);
105
+ console.log(` ✅ 成功:${result.success}`);
106
+ console.log(` ❌ 失败:${result.failed}`);
107
+ console.log(` ⏭️ 跳过:${result.skipped}`);
108
+ if (result.failed > 0) {
109
+ console.log('\n失败文件:');
110
+ for (const f of result.files.filter((f) => f.status === 'failed')) {
111
+ console.log(` - ${basename(f.filePath)}: ${f.error ?? '未知错误'}`);
112
+ }
113
+ }
114
+ console.log('──────────────────────────────────────────────────────\n');
115
+ }
116
+ function sleep(ms) {
117
+ return new Promise((resolve) => setTimeout(resolve, ms));
118
+ }
@@ -8,7 +8,7 @@ import { mastToNoteAtom } from '../noteatom/from-mast.js';
8
8
  * 单文件完整流水线:Markdown → HAST → MAST → 资源上传 → NoteAtom → 发布
9
9
  */
10
10
  export async function processFile(filePath, client, opts = {}) {
11
- const { noteId, tags, autoPublish = false, dryRun = false, cacheDir } = opts;
11
+ const { noteId, tags, autoPublish = false, dryRun = false, cacheDir, codeBlockStyle } = opts;
12
12
  // ── 阶段 00:读取文件 ────────────────────────────────────────────────────────
13
13
  const absPath = resolve(filePath);
14
14
  const markdown = await readFile(absPath, 'utf8');
@@ -17,7 +17,7 @@ export async function processFile(filePath, client, opts = {}) {
17
17
  const hast = mdToHast(markdown);
18
18
  await writeCache(cacheDir, '01-hast.json', hast);
19
19
  // ── 阶段 02:HAST → MAST ─────────────────────────────────────────────────────
20
- const mast = hastToMast(hast);
20
+ const mast = hastToMast(hast, codeBlockStyle ? { codeBlockStyle } : {});
21
21
  await writeCache(cacheDir, '02-mast.json', mast);
22
22
  // ── 阶段 03:资源处理 ────────────────────────────────────────────────────────
23
23
  await processAssets(mast, client, { baseDir, dryRun });
@@ -51,6 +51,7 @@ function collectStats(mast, dryRun) {
51
51
  let images = 0;
52
52
  let tables = 0;
53
53
  let audios = 0;
54
+ let codeblocks = 0;
54
55
  for (const block of Object.values(mast.blocks)) {
55
56
  if (block.type === 'paragraph')
56
57
  paragraphs++;
@@ -64,6 +65,8 @@ function collectStats(mast, dryRun) {
64
65
  }
65
66
  else if (block.type === 'audio')
66
67
  audios++;
68
+ else if (block.type === 'codeblock')
69
+ codeblocks++;
67
70
  }
68
71
  return {
69
72
  paragraphs,
@@ -71,7 +74,8 @@ function collectStats(mast, dryRun) {
71
74
  images,
72
75
  tables,
73
76
  audios,
74
- totalBlocks: paragraphs + quotes + images + tables + audios,
77
+ codeblocks,
78
+ totalBlocks: paragraphs + quotes + images + tables + audios + codeblocks,
75
79
  uploadedAssets: dryRun ? 0 : images + tables + audios,
76
80
  };
77
81
  }
@@ -85,6 +89,7 @@ function printDryRunReport(filePath, stats, noteAtom) {
85
89
  console.log(` 图片块: ${stats.images}`);
86
90
  console.log(` 表格块: ${stats.tables}`);
87
91
  console.log(` 音频块: ${stats.audios}`);
92
+ console.log(` 代码块: ${stats.codeblocks}`);
88
93
  console.log(` 总块数: ${stats.totalBlocks}`);
89
94
  console.log(` 待上传资源:${stats.images + stats.tables + stats.audios}(dry-run 跳过)`);
90
95
  console.log(`\nNoteAtom 预览(前 3 个块):`);
@@ -0,0 +1,116 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ const DEFAULT_CONFIG = {
5
+ defaultTags: '',
6
+ autoPublish: false,
7
+ codeBlockStyle: 'paragraph',
8
+ cacheDir: 'out/pipeline-cache',
9
+ };
10
+ // ── 环境变量映射 ───────────────────────────────────────────────────────────────
11
+ function readEnvConfig() {
12
+ const config = {};
13
+ const tags = process.env.MOWEN_DEFAULT_TAGS;
14
+ if (tags !== undefined) {
15
+ config.defaultTags = tags;
16
+ }
17
+ const autoPublish = process.env.MOWEN_AUTO_PUBLISH;
18
+ if (autoPublish !== undefined) {
19
+ config.autoPublish = autoPublish === 'true' || autoPublish === '1';
20
+ }
21
+ const codeBlockStyle = process.env.MOWEN_CODE_BLOCK_STYLE;
22
+ if (codeBlockStyle !== undefined) {
23
+ if (codeBlockStyle === 'paragraph' || codeBlockStyle === 'codeblock') {
24
+ config.codeBlockStyle = codeBlockStyle;
25
+ }
26
+ else {
27
+ console.warn(`[config] MOWEN_CODE_BLOCK_STYLE="${codeBlockStyle}" 无效,忽略(支持:paragraph, codeblock)`);
28
+ }
29
+ }
30
+ const cacheDir = process.env.MOWEN_CACHE_DIR;
31
+ if (cacheDir !== undefined) {
32
+ config.cacheDir = cacheDir;
33
+ }
34
+ return config;
35
+ }
36
+ function loadConfigFile(path, label) {
37
+ if (!existsSync(path)) {
38
+ return null;
39
+ }
40
+ try {
41
+ const raw = readFileSync(path, 'utf8');
42
+ const parsed = JSON.parse(raw);
43
+ // 验证并过滤已知字段
44
+ const validConfig = {};
45
+ if ('defaultTags' in parsed && typeof parsed.defaultTags === 'string') {
46
+ validConfig.defaultTags = parsed.defaultTags;
47
+ }
48
+ if ('autoPublish' in parsed && typeof parsed.autoPublish === 'boolean') {
49
+ validConfig.autoPublish = parsed.autoPublish;
50
+ }
51
+ if ('codeBlockStyle' in parsed) {
52
+ const style = parsed.codeBlockStyle;
53
+ if (style === 'paragraph' || style === 'codeblock') {
54
+ validConfig.codeBlockStyle = style;
55
+ }
56
+ else {
57
+ console.warn(`[config] ${label}: codeBlockStyle="${style}" 无效,忽略(支持:paragraph, codeblock)`);
58
+ }
59
+ }
60
+ if ('cacheDir' in parsed && typeof parsed.cacheDir === 'string') {
61
+ validConfig.cacheDir = parsed.cacheDir;
62
+ }
63
+ // 未知字段忽略(向前兼容)
64
+ const knownKeys = ['defaultTags', 'autoPublish', 'codeBlockStyle', 'cacheDir'];
65
+ const unknownKeys = Object.keys(parsed).filter((k) => !knownKeys.includes(k));
66
+ if (unknownKeys.length > 0) {
67
+ console.warn(`[config] ${label}: 忽略未知字段 ${unknownKeys.join(', ')}`);
68
+ }
69
+ return { config: validConfig, source: label };
70
+ }
71
+ catch (err) {
72
+ console.warn(`[config] ${label}: JSON 解析失败,使用默认值`);
73
+ console.warn(` 错误:${err instanceof Error ? err.message : String(err)}`);
74
+ return null;
75
+ }
76
+ }
77
+ // ── 配置路径查找 ───────────────────────────────────────────────────────────────
78
+ export function getConfigPaths(projectRoot) {
79
+ const cwd = projectRoot ?? process.cwd();
80
+ return {
81
+ project: join(cwd, '.md-to-mowen', 'config.json'),
82
+ user: join(homedir(), '.md-to-mowen', 'config.json'),
83
+ };
84
+ }
85
+ // ── 主入口:合并配置 ───────────────────────────────────────────────────────────
86
+ /**
87
+ * 按优先级合并配置:CLI > env > 项目 > 用户 > 默认
88
+ *
89
+ * @param cliOverrides CLI 参数覆盖(最高优先级)
90
+ * @param projectRoot 项目根目录(用于查找项目级配置)
91
+ */
92
+ export function loadConfig(cliOverrides = {}, projectRoot) {
93
+ const paths = getConfigPaths(projectRoot);
94
+ // 1. 用户级配置(最低优先级)
95
+ const userResult = loadConfigFile(paths.user, '用户级配置');
96
+ // 2. 项目级配置(优先于用户级)
97
+ const projectResult = loadConfigFile(paths.project, '项目级配置');
98
+ // 3. 环境变量配置(优先于配置文件)
99
+ const envConfig = readEnvConfig();
100
+ // 4. 合并(从低到高优先级)
101
+ const merged = {
102
+ ...userResult?.config,
103
+ ...projectResult?.config,
104
+ ...envConfig,
105
+ ...cliOverrides,
106
+ };
107
+ // 5. 应用默认值
108
+ return {
109
+ defaultTags: merged.defaultTags ?? DEFAULT_CONFIG.defaultTags,
110
+ autoPublish: merged.autoPublish ?? DEFAULT_CONFIG.autoPublish,
111
+ codeBlockStyle: merged.codeBlockStyle ?? DEFAULT_CONFIG.codeBlockStyle,
112
+ cacheDir: merged.cacheDir ?? DEFAULT_CONFIG.cacheDir,
113
+ };
114
+ }
115
+ // ── 导出默认值供测试使用 ──────────────────────────────────────────────────────
116
+ export { DEFAULT_CONFIG };
@@ -0,0 +1,249 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync, } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { homedir } from 'os';
4
+ const METADATA_FILENAME = 'metadata.json';
5
+ const TMP_SUFFIX = '.tmp';
6
+ const BAK_SUFFIX = '.bak';
7
+ const LOCK_SUFFIX = '.lock';
8
+ const LOCK_EXPIRE_MS = 30_000; // 锁过期时间:30秒
9
+ const LOCK_WAIT_MS = 5_000; // 等待锁的最大时间:5秒
10
+ const LOCK_RETRY_MS = 100; // 重试间隔:100毫秒
11
+ function emptyStore() {
12
+ return { version: 1, notes: {} };
13
+ }
14
+ /**
15
+ * 查找元数据文件路径(项目级优先于用户级)。
16
+ * 项目级:CWD/.md-to-mowen/metadata.json
17
+ * 用户级:~/.md-to-mowen/metadata.json
18
+ */
19
+ export function findMetadataPath(projectRoot) {
20
+ const projectDir = projectRoot ?? process.cwd();
21
+ const projectPath = join(projectDir, '.md-to-mowen', METADATA_FILENAME);
22
+ const userPath = join(homedir(), '.md-to-mowen', METADATA_FILENAME);
23
+ if (existsSync(projectPath))
24
+ return projectPath;
25
+ if (existsSync(userPath))
26
+ return userPath;
27
+ // 默认写入项目级
28
+ return projectPath;
29
+ }
30
+ /** 尝试解析元数据文件,返回解析结果或 null */
31
+ function tryParseMetadata(filePath) {
32
+ if (!existsSync(filePath))
33
+ return null;
34
+ try {
35
+ const raw = readFileSync(filePath, 'utf8');
36
+ const data = JSON.parse(raw);
37
+ if (data && typeof data === 'object' && data.version === 1 && typeof data.notes === 'object') {
38
+ return data;
39
+ }
40
+ return null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** 读取元数据,文件不存在或损坏时返回空 store */
47
+ export function readMetadata(filePath) {
48
+ // 优先尝试读取主文件
49
+ const mainData = tryParseMetadata(filePath);
50
+ if (mainData)
51
+ return mainData;
52
+ // 主文件不存在或损坏,尝试读取备份
53
+ const bakPath = filePath + BAK_SUFFIX;
54
+ const bakData = tryParseMetadata(bakPath);
55
+ if (bakData) {
56
+ console.warn(`警告:元数据文件损坏,已从备份恢复:${filePath}`);
57
+ return bakData;
58
+ }
59
+ // 主文件和备份都不存在或都损坏
60
+ if (existsSync(filePath)) {
61
+ console.warn(`警告:元数据文件及备份均损坏,已重新创建:${filePath}`);
62
+ }
63
+ return emptyStore();
64
+ }
65
+ /** 原子写入元数据:先写临时文件,fsync 后再 rename */
66
+ export function writeMetadata(filePath, store) {
67
+ const dir = dirname(filePath);
68
+ if (!existsSync(dir)) {
69
+ mkdirSync(dir, { recursive: true });
70
+ }
71
+ const tmpPath = filePath + TMP_SUFFIX;
72
+ const bakPath = filePath + BAK_SUFFIX;
73
+ const content = JSON.stringify(store, null, 2) + '\n';
74
+ // 1. 写入临时文件
75
+ let fd;
76
+ try {
77
+ fd = openSync(tmpPath, 'w');
78
+ writeSync(fd, content, 0, 'utf8');
79
+ // 2. fsync 确保落盘
80
+ fsyncSync(fd);
81
+ closeSync(fd);
82
+ fd = undefined;
83
+ // 3. 将现有文件重命名为备份(如果存在)
84
+ if (existsSync(filePath)) {
85
+ // 删除旧备份(不累积历史版本)
86
+ if (existsSync(bakPath)) {
87
+ unlinkSync(bakPath);
88
+ }
89
+ renameSync(filePath, bakPath);
90
+ }
91
+ // 4. 将临时文件重命名为正式文件
92
+ renameSync(tmpPath, filePath);
93
+ }
94
+ finally {
95
+ // 确保 fd 被关闭(如果中途出错)
96
+ if (fd !== undefined) {
97
+ try {
98
+ closeSync(fd);
99
+ }
100
+ catch {
101
+ /* ignore */
102
+ }
103
+ }
104
+ // 清理临时文件(如果写入失败且临时文件残留)
105
+ if (existsSync(tmpPath)) {
106
+ try {
107
+ unlinkSync(tmpPath);
108
+ }
109
+ catch {
110
+ /* ignore */
111
+ }
112
+ }
113
+ }
114
+ }
115
+ /** 根据绝对路径查找已有记录 */
116
+ export function lookupNote(store, absPath) {
117
+ return store.notes[absPath];
118
+ }
119
+ /** 更新或新增一条记录 */
120
+ export function upsertNote(store, absPath, noteId) {
121
+ const now = new Date().toISOString();
122
+ const existing = store.notes[absPath];
123
+ if (existing) {
124
+ store.notes[absPath] = { ...existing, noteId, updatedAt: now };
125
+ }
126
+ else {
127
+ store.notes[absPath] = { noteId, createdAt: now, updatedAt: now };
128
+ }
129
+ }
130
+ /** 创建锁文件信息 */
131
+ function createLockInfo() {
132
+ return {
133
+ pid: process.pid,
134
+ createdAt: new Date().toISOString(),
135
+ };
136
+ }
137
+ /** 解析锁文件,损坏时返回 null */
138
+ function parseLockFile(lockPath) {
139
+ if (!existsSync(lockPath))
140
+ return null;
141
+ try {
142
+ const raw = readFileSync(lockPath, 'utf8');
143
+ const data = JSON.parse(raw);
144
+ if (data && typeof data === 'object' && typeof data.pid === 'number' && typeof data.createdAt === 'string') {
145
+ return data;
146
+ }
147
+ return null;
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ }
153
+ /** 检查锁是否过期 */
154
+ function isLockExpired(lockInfo) {
155
+ const lockTime = new Date(lockInfo.createdAt).getTime();
156
+ const now = Date.now();
157
+ return now - lockTime > LOCK_EXPIRE_MS;
158
+ }
159
+ /** 等待并获取锁 */
160
+ async function acquireLock(lockPath) {
161
+ const startTime = Date.now();
162
+ while (true) {
163
+ const existingLock = parseLockFile(lockPath);
164
+ if (!existingLock) {
165
+ // 无锁或锁文件损坏,尝试创建锁
166
+ try {
167
+ const lockInfo = createLockInfo();
168
+ const dir = dirname(lockPath);
169
+ if (!existsSync(dir)) {
170
+ mkdirSync(dir, { recursive: true });
171
+ }
172
+ writeFileSync(lockPath, JSON.stringify(lockInfo), 'utf8');
173
+ return; // 成功获取锁
174
+ }
175
+ catch {
176
+ // 写入失败,可能被其他进程抢先,继续重试
177
+ }
178
+ }
179
+ else if (isLockExpired(existingLock)) {
180
+ // 锁已过期,强制获取(删除旧锁)
181
+ try {
182
+ unlinkSync(lockPath);
183
+ const lockInfo = createLockInfo();
184
+ writeFileSync(lockPath, JSON.stringify(lockInfo), 'utf8');
185
+ return; // 成功获取锁
186
+ }
187
+ catch {
188
+ // 删除或写入失败,继续重试
189
+ }
190
+ }
191
+ else {
192
+ // 锁有效且未过期,继续等待
193
+ }
194
+ // 检查是否超时
195
+ if (Date.now() - startTime > LOCK_WAIT_MS) {
196
+ const holderPid = existingLock?.pid ?? 'unknown';
197
+ const holderTime = existingLock?.createdAt ?? 'unknown';
198
+ throw new Error(`获取文件锁超时(等待 ${LOCK_WAIT_MS}ms)。锁文件:${lockPath},持有者 PID:${holderPid},创建时间:${holderTime}`);
199
+ }
200
+ // 等待后重试
201
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
202
+ }
203
+ }
204
+ /** 释放锁 */
205
+ async function releaseLock(lockPath) {
206
+ try {
207
+ if (existsSync(lockPath)) {
208
+ unlinkSync(lockPath);
209
+ }
210
+ }
211
+ catch {
212
+ // 释放失败时忽略(可能是其他进程已删除)
213
+ }
214
+ }
215
+ /** 异步读取元数据 */
216
+ export async function readMetadataAsync(filePath) {
217
+ return readMetadata(filePath);
218
+ }
219
+ /** 异步写入元数据 */
220
+ export async function writeMetadataAsync(filePath, store) {
221
+ return writeMetadata(filePath, store);
222
+ }
223
+ /**
224
+ * 在锁保护下执行读-修改-写操作。
225
+ * 整个过程加锁,保证原子性。
226
+ *
227
+ * @param filePath 元数据文件路径
228
+ * @param operation 修改操作函数,接收当前 store,返回更新后的 store
229
+ * @throws 锁等待超时时抛出错误
230
+ */
231
+ export async function withLock(filePath, operation) {
232
+ const lockPath = filePath + LOCK_SUFFIX;
233
+ await acquireLock(lockPath);
234
+ try {
235
+ const store = await readMetadataAsync(filePath);
236
+ const updated = operation(store);
237
+ await writeMetadataAsync(filePath, updated);
238
+ }
239
+ finally {
240
+ await releaseLock(lockPath);
241
+ }
242
+ }
243
+ /** 导出锁相关常量供测试使用 */
244
+ export const LOCK_CONSTANTS = {
245
+ LOCK_EXPIRE_MS,
246
+ LOCK_WAIT_MS,
247
+ LOCK_RETRY_MS,
248
+ LOCK_SUFFIX,
249
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-to-mowen",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "将 Markdown(GFM)转换为墨问笔记的 CLI 工具",
5
5
  "type": "module",
6
6
  "bin": {