dsh-maoying 0.1.17 → 0.1.19
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/README.en.md +6 -6
- package/README.md +6 -6
- package/docs/creator-panel.md +23 -11
- package/docs/design.md +3 -3
- package/docs/spec.md +3 -0
- package/index.mjs +66 -10
- package/lib/base.css +122 -3
- package/lib/client.js +436 -35
- package/lib/novel-parse.mjs +12 -1
- package/lib/panel-core.mjs +162 -15
- package/lib/self-update.mjs +408 -123
- package/package.json +1 -1
- package/presets/maoying/agent.cordis.v2.yml +7 -7
- package/presets/maoying/agent.cordis.yml +7 -7
- package/skills/maoying-review-checklist/SKILL.md +8 -3
- package/templates/novel/README.md +4 -0
- package/templates/novel/style.md +35 -0
package/lib/novel-parse.mjs
CHANGED
|
@@ -7,15 +7,26 @@
|
|
|
7
7
|
* 永远是同一口径。五期(§9)在此追加:章纲 outlines/ 读取(readOutline /
|
|
8
8
|
* listOutlines)、写作卡组装(buildBrief)与静态体检(checkProject)。
|
|
9
9
|
*
|
|
10
|
-
* 零依赖:只用 node:fs / node:path。YAML 解析走自带的极简子集
|
|
10
|
+
* 零依赖:只用 node:crypto / node:fs / node:path。YAML 解析走自带的极简子集
|
|
11
11
|
* parseFlatYamlArray(本项目数据规范:yml 顶层只允许数组或平键,禁止深嵌套),
|
|
12
12
|
* 与 dsh home 里的 yaml 包无关——yaml 包的真解析在 lib/yaml-loader.mjs 一侧,
|
|
13
13
|
* 由 panel-core 自行惰性加载。
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
16
17
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
17
18
|
import path from 'node:path'
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* sha256 hex(小写):文本 → 指纹。全仓库唯一 hash 实现——面板「人工改动
|
|
22
|
+
* 保护」的各处基线(project_hash / 章节 chapter 的 hash / 章纲 outline.hash /
|
|
23
|
+
* style 的 hash)与各 save 路由的 base_hash 比对都从这一个口径出,避免多处
|
|
24
|
+
* 手搓摘要算法日后漂移(比如一方忘了 utf8 编码)。
|
|
25
|
+
*/
|
|
26
|
+
export function sha256Hex(text) {
|
|
27
|
+
return createHash('sha256').update(text, 'utf8').digest('hex')
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
/** 章节状态机(frontmatter status 枚举 + 中文标签;CLI 展示与面板共用)。 */
|
|
20
31
|
export const CHAPTER_STATUSES = [
|
|
21
32
|
['pending', '待写'],
|
package/lib/panel-core.mjs
CHANGED
|
@@ -10,21 +10,33 @@
|
|
|
10
10
|
* parseStateSnapshot(novelDir) docs/creator-panel.md §3 的全量快照
|
|
11
11
|
* (五期起含 outlines 章纲清单)
|
|
12
12
|
* readChapter(novelDir, file) 章节正文全文(抽屉用;五期起带对应章纲
|
|
13
|
-
* outline
|
|
13
|
+
* outline 字段,二者各带原文 sha256 hash——
|
|
14
|
+
* 面板正文/章纲编辑保存的 base_hash 基线)
|
|
14
15
|
* readOutlineFile(novelDir, file) 章纲全文(章纲弹层用;0.1.15 起按单
|
|
15
16
|
* 写作的未建章章纲可看全文)
|
|
16
17
|
* projectFileHash(novelDir) project.yml 原文 sha256 hex(快照
|
|
17
18
|
* project_hash / 保存旧版检测共用基线)
|
|
19
|
+
* readStyleState(novelDir) 写作风格基准 novel/style.md 全文 + 指纹 +
|
|
20
|
+
* 插件模板全文(面板「写作风格」tab 取数,
|
|
21
|
+
* found:false 表示文件尚未创建、不是错误)
|
|
18
22
|
*
|
|
19
23
|
* 写入面(行级原地替换三件套:读原文 → 手术 → 写后校验替换计数 === 1;
|
|
20
24
|
* 计数不符或目标行缺失一律 409 拒写不落盘,把对 git diff 的扰动压到最小。
|
|
21
|
-
* createChapterFromOutline
|
|
22
|
-
*
|
|
25
|
+
* createChapterFromOutline 是例外之一——它从章纲落一个全新章节文件,不动
|
|
26
|
+
* 任何既有文件的字节;saveStyleText 是例外之二——style.md 是纯 markdown
|
|
27
|
+
* 基准文档、无 frontmatter/行级键位可言,整文件覆写就是它的最小手术;
|
|
28
|
+
* mutateChapterBody 是例外之三——正文是自由形态文本,写原语是「frontmatter
|
|
29
|
+
* 围栏定位 + 围栏后整段替换」,围栏(含)之前逐字节保留;mutateOutlineBody
|
|
30
|
+
* 是例外之四——章纲纯 markdown,整文件替换。后四者写前都读原文比对
|
|
31
|
+
* baseHash(人工改动保护与 mutateProject 同口径))
|
|
23
32
|
* mutateChapterStatus 章节 frontmatter 的 status 行
|
|
24
33
|
* mutateForeshadowStatus foreshadows.yml 对应 id 条目块内的 status 行
|
|
25
34
|
* mutatePhase project.yml 的 phase 行
|
|
26
35
|
* rebuildIndex 复用 lib/novel-parse 的 buildIndexYaml 重建
|
|
27
36
|
* createChapterFromOutline 从章纲建章(chapters/ 落 pending 空章,0.1.15)
|
|
37
|
+
* saveStyleText 写作风格基准整文件覆写(novel/style.md,0.1.19)
|
|
38
|
+
* mutateChapterBody 章节正文整段替换(面板正文编辑态保存)
|
|
39
|
+
* mutateOutlineBody 章纲整文件替换(面板章纲对照栏编辑态保存)
|
|
28
40
|
*
|
|
29
41
|
* 错误约定:本模块抛 PanelError(带 statusCode),路由层按 statusCode 回
|
|
30
42
|
* JSON(403 拒绝 / 404 找不到 / 409 拒写 / 422 入参不合法);未带状态码的
|
|
@@ -35,11 +47,10 @@
|
|
|
35
47
|
* 绝不让它炸掉整个快照。
|
|
36
48
|
*/
|
|
37
49
|
|
|
38
|
-
import { createHash } from 'node:crypto'
|
|
39
50
|
import { mkdirSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
40
51
|
import path from 'node:path'
|
|
41
52
|
|
|
42
|
-
import { buildIndexYaml, canonicalVolume, chapterSortKey, collectChapters, countCjk, FIVE_DIM_KEYS, parseFlatYamlArray, parseScalarText, splitFrontmatter, stripInlineComment } from './novel-parse.mjs'
|
|
53
|
+
import { buildIndexYaml, canonicalVolume, chapterSortKey, collectChapters, countCjk, FIVE_DIM_KEYS, parseFlatYamlArray, parseScalarText, sha256Hex, splitFrontmatter, stripInlineComment } from './novel-parse.mjs'
|
|
43
54
|
import { loadYamlLib, resolveDshHome } from './yaml-loader.mjs'
|
|
44
55
|
|
|
45
56
|
/** 章节状态机(与 lib/novel-parse 的表同源:只取键)。 */
|
|
@@ -405,10 +416,7 @@ function readProject(novelDir, yamlLib, warnings) {
|
|
|
405
416
|
}
|
|
406
417
|
}
|
|
407
418
|
|
|
408
|
-
/** sha256 hex
|
|
409
|
-
function sha256Hex(text) {
|
|
410
|
-
return createHash('sha256').update(text, 'utf8').digest('hex')
|
|
411
|
-
}
|
|
419
|
+
/** sha256 hex(小写):从 novel-parse 引全仓库唯一实现,本文件不再私有手搓。 */
|
|
412
420
|
|
|
413
421
|
/**
|
|
414
422
|
* project.yml 原文指纹(sha256 hex):面板「人工改动保护」的基线。快照带出
|
|
@@ -426,6 +434,69 @@ export function projectFileHash(novelDir) {
|
|
|
426
434
|
return sha256Hex(text)
|
|
427
435
|
}
|
|
428
436
|
|
|
437
|
+
/**
|
|
438
|
+
* 插件内置写作风格模板(templates/novel/style.md)全文:readStyleState 的
|
|
439
|
+
* template 字段原料——style.md 尚未创建时,面板用它在编辑器里预填默认基线。
|
|
440
|
+
* 每次现读(35 行小文件,不值得缓存);读不到返回 null,面板据此禁用
|
|
441
|
+
* 「用默认基线创建」按钮,不炸面板。
|
|
442
|
+
*/
|
|
443
|
+
function styleTemplateText() {
|
|
444
|
+
try {
|
|
445
|
+
return readFileSync(new URL('../templates/novel/style.md', import.meta.url), 'utf8')
|
|
446
|
+
} catch {
|
|
447
|
+
return null
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* 写作风格基准读取(0.1.19,面板「写作风格」tab 取数):novel/style.md 存在
|
|
453
|
+
* 则读全文 + sha256 指纹(hash 即保存路由 base_hash 的基线,口径与
|
|
454
|
+
* projectFileHash 一致);不存在返回 found:false、text/hash 为 null——文件
|
|
455
|
+
* 尚未创建是常态不是错误,路由照 200 回,客户端空态引导创建。读失败不炸
|
|
456
|
+
* (照 projectFileHash 的宽容口径,读不动一律按不存在处理)。template 恒为
|
|
457
|
+
* 插件模板全文(读不到为 null)。
|
|
458
|
+
*/
|
|
459
|
+
export function readStyleState(novelDir) {
|
|
460
|
+
const template = styleTemplateText()
|
|
461
|
+
let text = null
|
|
462
|
+
try {
|
|
463
|
+
text = readFileSync(path.join(path.resolve(novelDir), 'style.md'), 'utf8')
|
|
464
|
+
} catch {
|
|
465
|
+
return { found: false, text: null, hash: null, template }
|
|
466
|
+
}
|
|
467
|
+
return { found: true, text, hash: sha256Hex(text), template }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* 写作风格基准保存(0.1.19,面板「写作风格」tab 的编辑态):novel/style.md
|
|
472
|
+
* 整文件覆写——纯 markdown 基准文档,无行级键位可言,整文件替换即最小手术
|
|
473
|
+
* (「行级原地替换」纪律的例外,与 agent 的写法兼容:写前读、原子写)。
|
|
474
|
+
*
|
|
475
|
+
* 校验:text 非 string 422。人工改动保护与 mutateProject 同口径:options.
|
|
476
|
+
* baseHash 是面板载入编辑器时的原文指纹,与写入前现读不一致 → 409 拒写;
|
|
477
|
+
* 文件不存在且 baseHash 非 null 同样 409(调用方声称基于既有版本改,磁盘却
|
|
478
|
+
* 无文件,宁可不写);未传 baseHash 跳过检查(创建路径:文件不存在时直接
|
|
479
|
+
* 落盘)。空文本允许保存(空文件 = 无风格基准,agent 侧优雅降级)。
|
|
480
|
+
* 返回写后新指纹 hash,客户端下一轮 base_hash 直接取它。
|
|
481
|
+
*/
|
|
482
|
+
export function saveStyleText(novelDir, text, options = {}) {
|
|
483
|
+
if (typeof text !== 'string') fail(422, 'text 必须是字符串(style.md 全文)')
|
|
484
|
+
const target = path.join(path.resolve(novelDir), 'style.md')
|
|
485
|
+
let current = null
|
|
486
|
+
try {
|
|
487
|
+
current = readFileSync(target, 'utf8')
|
|
488
|
+
} catch {
|
|
489
|
+
current = null // 不存在(读不动也按不存在处理,宽容口径同 readStyleState)
|
|
490
|
+
}
|
|
491
|
+
const baseHash = typeof options.baseHash === 'string' ? options.baseHash.trim().toLowerCase() : ''
|
|
492
|
+
if (baseHash !== '' && (current === null || baseHash !== sha256Hex(current))) {
|
|
493
|
+
fail(409, 'style.md 已被外部修改,请刷新面板后基于最新内容重改再存')
|
|
494
|
+
}
|
|
495
|
+
writeAtomic(target, text)
|
|
496
|
+
// 返回写后指纹:客户端下一轮保存的 base_hash 直接取它,不必再拉一次快照。
|
|
497
|
+
return { ok: true, hash: sha256Hex(text) }
|
|
498
|
+
}
|
|
499
|
+
|
|
429
500
|
/**
|
|
430
501
|
* 全量快照(docs/creator-panel.md §3,字段名冻结)。novelDir 为工作区的
|
|
431
502
|
* novel 目录;project.yml 不在 = 未初始化(found: false,其余字段缺省)。
|
|
@@ -606,8 +677,10 @@ export function parseStateSnapshot(novelDir, options = {}) {
|
|
|
606
677
|
/**
|
|
607
678
|
* 章纲定位(readChapter 的 outline 字段,五期):outlines/ 下与章节同 stem
|
|
608
679
|
* 的文件。章纲纯 markdown 无 frontmatter,words 为全文 CJK 计数、body 为
|
|
609
|
-
*
|
|
610
|
-
*
|
|
680
|
+
* 原文、hash 为原文 sha256(面板章纲编辑保存的 base_hash 基线,与 body 同源
|
|
681
|
+
* 同刻读出——拆开读会有被外部改动的窗口);无对应文件(含 outlines/ 目录不
|
|
682
|
+
* 存在)返回 null——无章纲是常态,不告警。stem 已过 safeFileName,拼接结果
|
|
683
|
+
* 不可能越出 novel 根。
|
|
611
684
|
*/
|
|
612
685
|
function readOutline(novelDir, stem) {
|
|
613
686
|
let text = null
|
|
@@ -616,20 +689,22 @@ function readOutline(novelDir, stem) {
|
|
|
616
689
|
} catch {
|
|
617
690
|
return null
|
|
618
691
|
}
|
|
619
|
-
return { file: `${stem}.md`, words: countCjk(text), body: text }
|
|
692
|
+
return { file: `${stem}.md`, words: countCjk(text), body: text, hash: sha256Hex(text) }
|
|
620
693
|
}
|
|
621
694
|
|
|
622
695
|
/**
|
|
623
696
|
* 章节全文(抽屉用):路径校验同写路由;frontmatter 以对象返回(yaml 可用
|
|
624
|
-
* 走真解析,否则平键回退),body
|
|
625
|
-
*
|
|
697
|
+
* 走真解析,否则平键回退),body 为原文正文,hash 为章节文件原文 sha256
|
|
698
|
+
* (人工改动保护:正文编辑态保存时作 base_hash 回传,外部改动 409 拒写);
|
|
699
|
+
* outline 为同 stem 章纲({ file, words, body, hash },无对应章纲时 null),
|
|
700
|
+
* 章节抽屉的章纲对照栏取数。
|
|
626
701
|
*/
|
|
627
702
|
export function readChapter(novelDir, file, options = {}) {
|
|
628
703
|
const { name, target } = locateChapter(novelDir, file)
|
|
629
704
|
const text = readTarget(target, `章节 ${name}`)
|
|
630
705
|
const { meta, body } = parseFrontmatterMeta(text, yamlLibOf(options))
|
|
631
706
|
const outline = readOutline(novelDir, name.replace(/\.md$/i, ''))
|
|
632
|
-
return { ok: true, file: name, frontmatter: meta, body, outline }
|
|
707
|
+
return { ok: true, file: name, frontmatter: meta, body, hash: sha256Hex(text), outline }
|
|
633
708
|
}
|
|
634
709
|
|
|
635
710
|
/**
|
|
@@ -700,6 +775,78 @@ export function createChapterFromOutline(novelDir, file) {
|
|
|
700
775
|
return { ok: true, file: `${stem}.md`, title }
|
|
701
776
|
}
|
|
702
777
|
|
|
778
|
+
// ── 正文/章纲写原语(面板编辑态保存):自由形态 body,无行级键位可手术 ─────
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* 章节正文保存(面板章节阅读页编辑态):frontmatter 围栏(含闭合 --- 行)
|
|
782
|
+
* 之前逐字节保留,围栏后整段替换——正文是自由形态文本,任何行级手术都会
|
|
783
|
+
* 凭空改写作者的文字,整段替换才是最小干预;对 git diff 的扰动天然只落在
|
|
784
|
+
* 正文段内。校验与拒写:
|
|
785
|
+
* - file 只认 chapters/ 下纯文件名(locateChapter 同款双保险,穿越 403);
|
|
786
|
+
* - body 非 string 422;章节缺 frontmatter 围栏 422(先补齐再编辑);
|
|
787
|
+
* - 章节不存在 404;
|
|
788
|
+
* - options.baseHash 是编辑态载入时的原文指纹(chapter 路由的 hash),
|
|
789
|
+
* 归一(trim + 小写)后与写入前现读不一致 → 409 拒写不落盘(人工改动
|
|
790
|
+
* 保护,与 mutateProject 同口径);未传跳过检查(旧调用方兼容)。
|
|
791
|
+
* 正文末尾无换行补一个 \n( POSIX 文本文件惯例,textarea 草稿常缺尾换行)。
|
|
792
|
+
* 返回 { ok, hash }——写后新指纹,客户端下一轮 base_hash 直接取它。
|
|
793
|
+
*/
|
|
794
|
+
export function mutateChapterBody(novelDir, file, body, options = {}) {
|
|
795
|
+
if (typeof body !== 'string') fail(422, 'body 必须是字符串(章节正文全文)')
|
|
796
|
+
const { name, target } = locateChapter(novelDir, file)
|
|
797
|
+
const text = readTarget(target, `章节 ${name}`)
|
|
798
|
+
const fence = splitFrontmatter(text)
|
|
799
|
+
if (fence.raw === null) fail(422, `章节 ${name} 缺少 frontmatter 围栏,请先补齐再在面板编辑正文`)
|
|
800
|
+
// 旧版检测在读原文之后、落笔之前:指纹不一致就地 409,一个字节都不动。
|
|
801
|
+
const baseHash = typeof options.baseHash === 'string' ? options.baseHash.trim().toLowerCase() : ''
|
|
802
|
+
if (baseHash !== '' && baseHash !== sha256Hex(text)) {
|
|
803
|
+
fail(409, `章节文件已被外部修改,请刷新后基于最新内容重改再存:${name}`)
|
|
804
|
+
}
|
|
805
|
+
// 围栏闭合行从原文原样切出(与 splitFrontmatter 的闭合正则同形)——
|
|
806
|
+
// CRLF/行尾空白等字节一字不动,改写它的刀不属于正文编辑。
|
|
807
|
+
const cut = fence.index + fence.raw.length
|
|
808
|
+
const closeM = /\r?\n---[ \t]*(?:\r?\n|$)/.exec(text.slice(cut))
|
|
809
|
+
let preserved = text.slice(0, cut + closeM[0].length)
|
|
810
|
+
// 退化形状:闭合 --- 是文件末行且无换行(罕见),补一个换行防正文黏上围栏行。
|
|
811
|
+
if (!preserved.endsWith('\n')) preserved += '\n'
|
|
812
|
+
const next = preserved + (body.endsWith('\n') ? body : `${body}\n`)
|
|
813
|
+
writeAtomic(target, next)
|
|
814
|
+
// 返回写后指纹:客户端下一轮保存的 base_hash 直接取它,不必重拉章节。
|
|
815
|
+
return { ok: true, hash: sha256Hex(next) }
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* 章纲保存(面板章节阅读页章纲对照栏编辑态):章纲纯 markdown 无
|
|
820
|
+
* frontmatter(spec §outlines),整文件替换即最小手术(与 saveStyleText
|
|
821
|
+
* 同一判断)。file 接受章节文件名或 stem(v01-c001.md / v01-c001 皆可——
|
|
822
|
+
* 调用方手里是章节上下文,不强迫换算),校验与 readOutlineFile 同口径:
|
|
823
|
+
* safeFileName(穿越 403)+ chapterSortKey(stem 不合 vXX-cXXX 规范 422);
|
|
824
|
+
* 目标恒为 outlines/<stem>.md,章纲不存在 404(v1 不提供面板新建章纲)。
|
|
825
|
+
* baseHash 人工改动保护与 mutateChapterBody 同口径,409 文案点名章纲。
|
|
826
|
+
* 返回 { ok, hash }——写后新指纹。
|
|
827
|
+
*/
|
|
828
|
+
export function mutateOutlineBody(novelDir, chapterFile, body, options = {}) {
|
|
829
|
+
if (typeof body !== 'string') fail(422, 'body 必须是字符串(章纲全文)')
|
|
830
|
+
const name = safeFileName(chapterFile, 'file')
|
|
831
|
+
const stem = name.replace(/\.md$/i, '')
|
|
832
|
+
if (chapterSortKey(stem) === null) fail(422, `非章纲文件名(stem 不合 vXX-cXXX 规范):${name}`)
|
|
833
|
+
const target = path.join(path.resolve(novelDir), 'outlines', `${stem}.md`)
|
|
834
|
+
let current = null
|
|
835
|
+
try {
|
|
836
|
+
current = readFileSync(target, 'utf8')
|
|
837
|
+
} catch (err) {
|
|
838
|
+
if (err?.code === 'ENOENT') fail(404, `章纲不存在:outlines/${stem}.md`)
|
|
839
|
+
fail(500, `章纲读取失败(${err?.code ?? err?.message ?? err})`)
|
|
840
|
+
}
|
|
841
|
+
const baseHash = typeof options.baseHash === 'string' ? options.baseHash.trim().toLowerCase() : ''
|
|
842
|
+
if (baseHash !== '' && baseHash !== sha256Hex(current)) {
|
|
843
|
+
fail(409, `章纲文件已被外部修改,请刷新后基于最新内容重改再存:outlines/${stem}.md`)
|
|
844
|
+
}
|
|
845
|
+
const next = body.endsWith('\n') ? body : `${body}\n`
|
|
846
|
+
writeAtomic(target, next)
|
|
847
|
+
return { ok: true, hash: sha256Hex(next) }
|
|
848
|
+
}
|
|
849
|
+
|
|
703
850
|
// ── 写操作:行级原地替换 ────────────────────────────────────────────────────
|
|
704
851
|
|
|
705
852
|
/**
|