@lancc/cmark 0.0.0-stage → 1.1.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.
Files changed (197) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +185 -2
  4. package/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  5. package/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  6. package/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  7. package/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  8. package/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  9. package/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  10. package/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  11. package/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  12. package/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  13. package/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  14. package/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  15. package/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  16. package/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  17. package/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  18. package/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  19. package/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  20. package/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  21. package/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  22. package/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  23. package/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  24. package/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  25. package/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  26. package/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  27. package/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  28. package/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  29. package/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  30. package/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  31. package/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  32. package/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  33. package/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  34. package/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  35. package/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  36. package/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  37. package/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  38. package/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  39. package/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  40. package/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  41. package/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  42. package/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  43. package/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  44. package/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  45. package/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  46. package/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  47. package/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  48. package/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  49. package/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  50. package/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  51. package/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  52. package/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  53. package/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  54. package/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  55. package/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  56. package/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  57. package/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  58. package/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  59. package/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  60. package/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  61. package/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  62. package/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  63. package/assets/abnfDiagram-WEYOIA3C-BgXsTF7a.js +1 -0
  64. package/assets/architectureDiagram-ZHVYKT53-BsCWa_qC.js +36 -0
  65. package/assets/blockDiagram-RJAE7QA3-wbIxEiYN.js +140 -0
  66. package/assets/c4Diagram-XDPPPE54-5zdyxFvA.js +38 -0
  67. package/assets/chunk-2E7WHVMD-F85Lca-i.js +88 -0
  68. package/assets/chunk-2Q5K7J3B-CjpAZ0OI.js +1 -0
  69. package/assets/chunk-4EMNKR2T-C8Kbh83c.js +1 -0
  70. package/assets/chunk-55XCU76K-BINcFiLN.js +1 -0
  71. package/assets/chunk-5VM5RSS4-Diy5h2LS.js +15 -0
  72. package/assets/chunk-JWPE2WC7-BXnjDdh1.js +1 -0
  73. package/assets/chunk-VQ5W62QI-BM7ZZo1I.js +1 -0
  74. package/assets/chunk-XXDRQBXY-CW8CjB1w.js +1 -0
  75. package/assets/chunk-ZCHMQSOR-C7ATz1L1.js +2 -0
  76. package/assets/classDiagram-v2-4MYWR3UA-CMoqNk-b.js +217 -0
  77. package/assets/cose-bilkent-JH36ORCC-6rqR5iJk.js +1 -0
  78. package/assets/cynefin-EF2NZ3EQ-DypLIJxl.js +141 -0
  79. package/assets/cynefinDiagram-WF24UDRP-8b2CEBlR.js +62 -0
  80. package/assets/cytoscape.esm-CjX6Hyah.js +331 -0
  81. package/assets/dagre-NK4GIZVX-Dv6vuUbR.js +4 -0
  82. package/assets/diagram-CBK3MWTC-DJgMDeYd.js +24 -0
  83. package/assets/diagram-I5XIIV2D-BB9cMoRW.js +24 -0
  84. package/assets/diagram-ODIL2UCW-BVcRtsdy.js +30 -0
  85. package/assets/diagram-PFBEJUXE-BbGSOtJx.js +3 -0
  86. package/assets/diagram-RJA3I4VV-CzkY_8Fo.js +41 -0
  87. package/assets/diagram-TYUNFSNR-YTl5QdfL.js +200 -0
  88. package/assets/ebnfDiagram-V4P3TTPT-COVjPAjE.js +1 -0
  89. package/assets/echarts-BAPDkhe3.js +61 -0
  90. package/assets/elk-QONTBCTQ-DHsjY8Z_.js +27 -0
  91. package/assets/erDiagram-KYQIGQAA-CUAbTUl1.js +99 -0
  92. package/assets/flowDiagram-IUGLLWBU-CWsiqTQC.js +213 -0
  93. package/assets/ganttDiagram-R7GPNTJY-DetBW0Wm.js +292 -0
  94. package/assets/gitGraphDiagram-PDYL7XHY-ByrdgJmw.js +106 -0
  95. package/assets/graph-D8UmR1vK.js +1 -0
  96. package/assets/index-BLONSItV.css +1 -0
  97. package/assets/index-Cz6qaZbV.js +744 -0
  98. package/assets/infoDiagram-MVDRKAX5-BM48fjhU.js +2 -0
  99. package/assets/ishikawaDiagram-HPEHV2GE-DtHYywHZ.js +70 -0
  100. package/assets/journeyDiagram-BOSHW7O7-DJ1LiRh3.js +139 -0
  101. package/assets/kanban-definition-FNSA4HC5-DPPt8pwJ.js +89 -0
  102. package/assets/katex-CQL9J12G.js +257 -0
  103. package/assets/markdown-DVehI1Sg.js +314 -0
  104. package/assets/mermaid-C_q9pug0.js +312 -0
  105. package/assets/mindmap-definition-6OY57UG3-zbqYOEY6.js +98 -0
  106. package/assets/pegDiagram-ECIUJNBX-e7QG7TMq.js +1 -0
  107. package/assets/pieDiagram-GNMPY74T-Duf6YP0N.js +39 -0
  108. package/assets/quadrantDiagram-D57K4MC3-B2eYoUUy.js +7 -0
  109. package/assets/railroadDiagram-Y72NRBL7-BWCzPcI_.js +1 -0
  110. package/assets/requirementDiagram-EQYSW5RA-HhPMEqB1.js +84 -0
  111. package/assets/sankeyDiagram-SY24JHO5-CMUTwFBB.js +40 -0
  112. package/assets/sequenceDiagram-BLEHNHQU-C9wR2ll7.js +169 -0
  113. package/assets/sizeCapture-INFHLROL-DXfz37S6.js +1 -0
  114. package/assets/stateDiagram-v2-A6IESKWZ-D_gtonhE.js +291 -0
  115. package/assets/swimlanes-B3ENRKFQ-DdJOuuMV.js +1 -0
  116. package/assets/swimlanesDiagram-AYQRV4AK-BWt3HxHV.js +8 -0
  117. package/assets/timeline-definition-JXYKGLGB-DQsdwJe-.js +120 -0
  118. package/assets/usecaseDiagram-WPOLBNNX-DTRyFq7d.js +370 -0
  119. package/assets/vennDiagram-Q3Y3IDCW-dihYszm0.js +34 -0
  120. package/assets/wardleyDiagram-IVAIDKWA-BoPpgYfK.js +78 -0
  121. package/assets/xychartDiagram-4URNHDU4-DoLjYxeB.js +7 -0
  122. package/export-file/github-markdown.css +550 -0
  123. package/export-file/index.html +33 -0
  124. package/fonts/KaTeX_AMS-Regular.ttf +0 -0
  125. package/fonts/KaTeX_AMS-Regular.woff +0 -0
  126. package/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  127. package/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  128. package/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  129. package/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  130. package/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  131. package/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  132. package/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  133. package/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  134. package/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  135. package/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  136. package/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  137. package/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  138. package/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  139. package/fonts/KaTeX_Main-Bold.ttf +0 -0
  140. package/fonts/KaTeX_Main-Bold.woff +0 -0
  141. package/fonts/KaTeX_Main-Bold.woff2 +0 -0
  142. package/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  143. package/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  144. package/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  145. package/fonts/KaTeX_Main-Italic.ttf +0 -0
  146. package/fonts/KaTeX_Main-Italic.woff +0 -0
  147. package/fonts/KaTeX_Main-Italic.woff2 +0 -0
  148. package/fonts/KaTeX_Main-Regular.ttf +0 -0
  149. package/fonts/KaTeX_Main-Regular.woff +0 -0
  150. package/fonts/KaTeX_Main-Regular.woff2 +0 -0
  151. package/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  152. package/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  153. package/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  154. package/fonts/KaTeX_Math-Italic.ttf +0 -0
  155. package/fonts/KaTeX_Math-Italic.woff +0 -0
  156. package/fonts/KaTeX_Math-Italic.woff2 +0 -0
  157. package/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  158. package/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  159. package/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  160. package/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  161. package/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  162. package/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  163. package/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  164. package/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  165. package/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  166. package/fonts/KaTeX_Script-Regular.ttf +0 -0
  167. package/fonts/KaTeX_Script-Regular.woff +0 -0
  168. package/fonts/KaTeX_Script-Regular.woff2 +0 -0
  169. package/fonts/KaTeX_Size1-Regular.ttf +0 -0
  170. package/fonts/KaTeX_Size1-Regular.woff +0 -0
  171. package/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  172. package/fonts/KaTeX_Size2-Regular.ttf +0 -0
  173. package/fonts/KaTeX_Size2-Regular.woff +0 -0
  174. package/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  175. package/fonts/KaTeX_Size3-Regular.ttf +0 -0
  176. package/fonts/KaTeX_Size3-Regular.woff +0 -0
  177. package/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  178. package/fonts/KaTeX_Size4-Regular.ttf +0 -0
  179. package/fonts/KaTeX_Size4-Regular.woff +0 -0
  180. package/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  181. package/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  182. package/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  183. package/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  184. package/index.html +15 -0
  185. package/katex.min.css +1 -0
  186. package/logo.png +0 -0
  187. package/package.json +31 -4
  188. package/plugin.json +241 -0
  189. package/preload/branch-bar.js +159 -0
  190. package/preload/git-panel.js +1311 -0
  191. package/preload/git-service.js +1403 -0
  192. package/preload/ndb.js +1407 -0
  193. package/preload/package.json +3 -0
  194. package/preload/rename-note.js +151 -0
  195. package/preload/services.js +432 -0
  196. package/preload/settings-panel.js +847 -0
  197. package/preload.js +1615 -0
package/preload/ndb.js ADDED
@@ -0,0 +1,1407 @@
1
+ /**
2
+ * ndb.js —— 让 Markdown 笔记 UI 直接跑在「指定目录 + 真实 .md 文件」上
3
+ *
4
+ * 原理:原插件的 UI 只通过 window.db 读写数据。我们把 bundle 里的
5
+ * `db.*` 换成 `window.__NDB.*`,再在这里用 fs 实现同名接口:
6
+ * note/<relpath.md> -> <工作区>/<relpath.md> 正文就是磁盘上的 .md
7
+ * folder/<relpath> -> 目录(扫描得出,children 内联)
8
+ * setting / rootsort / last_work_note/* -> <用户数据目录>/znotes-data/<hash>/meta/ui.json
9
+ * attachment/<digest> -> <用户数据目录>/znotes-data/<hash>/assets/<digest>.<ext>
10
+ * 删除直接从磁盘移除(不做回收站,误删靠 git 历史恢复)。
11
+ */
12
+ const fs = require('fs')
13
+ const os = require('os')
14
+ const path = require('path')
15
+ const crypto = require('crypto')
16
+
17
+ module.exports = function installNDB(win, externalCfg) {
18
+ // ---------------- 工作区(优先级:环境变量 > 外部配置 > asar 内默认)----------------
19
+ // 默认工作区不写死盘符(新机器未必有 E 盘);真实路径由 preload 的配置传入
20
+ let WS = process.env.ZNOTES_WORKSPACE || path.join(fs.existsSync(path.join(os.homedir(), 'Documents')) ? path.join(os.homedir(), 'Documents') : os.homedir(), 'CMark')
21
+ const candidates = []
22
+ if (externalCfg) candidates.push(externalCfg)
23
+ candidates.push(path.join(__dirname, 'workspace.json'))
24
+ for (const c of candidates) {
25
+ try {
26
+ const j = JSON.parse(fs.readFileSync(c, 'utf8'))
27
+ if (j && j.workspace) { WS = j.workspace; break }
28
+ } catch (e) {}
29
+ }
30
+ WS = path.resolve(WS)
31
+
32
+ // 元数据与附件一律放在笔记库之外:笔记库是 git 仓库,任何多余文件都会被自动提交推送
33
+ const __META_ROOT = (function () {
34
+ try {
35
+ const os = require('os')
36
+ const crypto = require('crypto')
37
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
38
+ const h = crypto.createHash('md5').update(String(WS).toLowerCase()).digest('hex').slice(0, 12)
39
+ return path.join(base, 'znotes-data', h)
40
+ } catch (e) {
41
+ const os = require('os')
42
+ return path.join(os.tmpdir(), 'znotes-data')
43
+ }
44
+ })()
45
+ const META = path.join(__META_ROOT, 'meta')
46
+ const UIJSON = path.join(META, 'ui.json')
47
+ const STOREJSON = path.join(META, 'storage.json')
48
+ // 仓库内的状态目录:排序、只读锁定、图片附件都放这里,随 git 一起提交。
49
+ // 点开头 → 目录树扫描会跳过,不会显示成一本书;.gitignore 里也不要忽略它。
50
+ // (本机运行态仍然留在仓库外的 __META_ROOT:git 配置、界面偏好、备份、日志)
51
+ // .cmark 是「仓库级」元数据(排序、只读标记、附件),放在仓库根而不是选中的子目录里:
52
+ // 一是不污染笔记目录(比如 Obsidian 库的视图),二是同一个仓库下换子目录做工作区时
53
+ // 元数据只有一份。没有 git 时仓库根就等于工作区本身。
54
+ function detectRepoRoot (start) {
55
+ let cur = start
56
+ let depth = 0
57
+ while (cur && cur !== path.dirname(cur) && depth < 5) {
58
+ depth++
59
+ try {
60
+ if (fs.existsSync(path.join(cur, '.git'))) return cur
61
+ } catch (e) {}
62
+ cur = path.dirname(cur)
63
+ }
64
+ return ''
65
+ }
66
+ const REPO_ROOT = detectRepoRoot(WS) || WS
67
+ const REPO_META = path.join(REPO_ROOT, '.cmark')
68
+ const ASSETS = path.join(REPO_META, 'attachments')
69
+
70
+ const mk = (p) => { try { fs.mkdirSync(p, { recursive: true }) } catch (e) {} }
71
+
72
+ // ===================== 图片相对路径重写 =====================
73
+ //
74
+ // 正文里的图片是相对 md 文件位置的路径(这样 Typora / VSCode / Obsidian 都能显示)。
75
+ // 一旦笔记被改名、拖到别的目录,或者整个目录被移动,原来算好的 ../ 层数就全错了,
76
+ // 图片会集体失效。所以每次文件落位之后都要按新旧位置把引用重算一遍。
77
+
78
+ const IMG_MD = /(!\[[^\]]*\]\()([^)\s]+)((?:\s+"[^"]*")?\))/g
79
+
80
+ /** 纯字符串版:把 txt 里所有图片相对路径按「旧目录 → 新目录」重算 */
81
+ function rewriteRelInText(txt, oldDir, newDir) {
82
+ if (!txt || oldDir === newDir) return txt
83
+ // 代码块/行内代码里的只是示例,跳过
84
+ const segs = String(txt).split(
85
+ /(```[\s\S]*?```|~~~[\s\S]*?~~~|\$\$[\s\S]*?\$\$|`+[^`]*`+)/g,
86
+ )
87
+ return segs
88
+ .map((seg, idx) => (idx % 2 === 1 ? seg : rewriteSeg(seg, oldDir, newDir)))
89
+ .join('')
90
+ }
91
+
92
+ function rewriteSeg(seg, oldDir, newDir) {
93
+ return String(seg).replace(IMG_MD, (whole, pre, url, post) => {
94
+ if (/^(https?:|data:|file:|attachment:|#|mailto:)/i.test(url)) return whole
95
+ let abs
96
+ try { abs = path.resolve(oldDir, url) } catch (e) { return whole }
97
+ // 只动仓库范围内的、确实存在的文件,避免误改外链和失效引用
98
+ if (abs !== REPO_ROOT && !abs.startsWith(REPO_ROOT + path.sep)) return whole
99
+ try { if (!fs.existsSync(abs)) return whole } catch (e) { return whole }
100
+ let rel = path.relative(newDir, abs).split(path.sep).join('/')
101
+ if (rel && !rel.startsWith('.')) rel = './' + rel
102
+ return pre + rel + post
103
+ })
104
+ }
105
+
106
+ /** 文件已经落位后,就地改写它内部的图片相对路径 */
107
+ function rewriteImagePaths(absOld, absNew) {
108
+ try {
109
+ if (absOld === absNew) return
110
+ const oldDir = path.dirname(absOld)
111
+ const newDir = path.dirname(absNew)
112
+ if (oldDir === newDir) return // 同目录改名,相对关系没变
113
+ let txt
114
+ try { txt = fs.readFileSync(absNew, 'utf8') } catch (e) { return }
115
+ const next = rewriteRelInText(txt, oldDir, newDir)
116
+ if (next !== txt) {
117
+ const tmp = absNew + '.tmp-' + Date.now() + '-' + Math.random().toString(16).slice(2, 8)
118
+ fs.writeFileSync(tmp, next, 'utf8')
119
+ fs.renameSync(tmp, absNew)
120
+ }
121
+ } catch (e) {}
122
+ }
123
+
124
+ /** 目录整体移位:逐篇按新旧路径重写 */
125
+ function rewriteDirImagePaths(oldDirAbs, newDirAbs) {
126
+ const walk = (dirNew, dirOld) => {
127
+ let list = []
128
+ try { list = fs.readdirSync(dirNew, { withFileTypes: true }) } catch (e) { return }
129
+ for (const ent of list) {
130
+ const nAbs = path.join(dirNew, ent.name)
131
+ const oAbs = path.join(dirOld, ent.name)
132
+ if (ent.isDirectory()) { walk(nAbs, oAbs); continue }
133
+ if (!/\.(md|markdown)$/i.test(ent.name)) continue
134
+ rewriteImagePaths(oAbs, nAbs)
135
+ }
136
+ }
137
+ walk(newDirAbs, oldDirAbs)
138
+ }
139
+
140
+ ;[META, REPO_META, ASSETS].forEach(mk)
141
+
142
+ const rj = (p, d) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch (e) { return d } }
143
+ const wj = (p, v) => { mk(path.dirname(p)); fs.writeFileSync(p, JSON.stringify(v, null, 1), 'utf8') }
144
+
145
+ // ---------------- Slate <-> Markdown ----------------
146
+ // <br> 与软换行互转:读 md 时 br 标签变换行符,写回时换行符变 br 标签
147
+ const brToNl = (v) => String(v == null ? '' : v).replace(/<br\s*\/?>/gi, String.fromCharCode(10))
148
+ const nlToBr = (v) => String(v == null ? '' : v).split(String.fromCharCode(10)).join('<br/>')
149
+
150
+ const inlineText = (n) => {
151
+ if (!n) return ''
152
+ if (typeof n.text === 'string') return n.text
153
+ if (Array.isArray(n.children)) return n.children.map(inlineText).join(String.fromCharCode(10))
154
+ return ''
155
+ }
156
+
157
+ function nodeToMd(n) {
158
+ if (!n) return ''
159
+ const t = n.type || 'paragraph'
160
+ const kids = Array.isArray(n.children) ? n.children : []
161
+
162
+ if (t === 'paragraph' || !t) {
163
+ // 空段落代表一个 <br/>;非空段落原样输出,段落内的软换行保持为换行符
164
+ // (不能转成 <br/>,否则会把普通的连续多行文本全部硬化为 <br/>)
165
+ const txt = inlineText(n)
166
+ return txt.trim() ? txt : ''
167
+ }
168
+
169
+ if (t === 'codeblock' || t === 'code-block' || t === 'code') {
170
+ const lang = n.lang || n.language || ''
171
+ const fromKids = kids.map((k) => inlineText(k)).join('')
172
+ const body = fromKids !== '' ? fromKids : (typeof n.content === 'string' ? n.content : '')
173
+ return '```' + lang + '\n' + body + '\n```'
174
+ }
175
+
176
+ if (t === 'table') {
177
+ const rows = kids.filter((k) => /row/i.test(k.type || ''))
178
+ const cells = (r) => (r.children || []).map((c) => nlToBr(inlineText(c)).replace(/\|/g, '\|').trim())
179
+ if (!rows.length) return ''
180
+ const head = cells(rows[0])
181
+ const aligns = Array.isArray(n.align) ? n.align : []
182
+ const sepAt = (idx) => {
183
+ const a = aligns[idx] || ''
184
+ if (a === 'center') return ':---:'
185
+ if (a === 'left') return ':---'
186
+ if (a === 'right') return '---:'
187
+ return '---'
188
+ }
189
+ const out = [
190
+ '| ' + head.join(' | ') + ' |',
191
+ '| ' + head.map((_, idx) => sepAt(idx)).join(' | ') + ' |',
192
+ ]
193
+ rows.slice(1).forEach((r) => out.push('| ' + cells(r).join(' | ') + ' |'))
194
+ return out.join('\n')
195
+ }
196
+
197
+ if (t === 'bulleted-list' || t === 'numbered-list' || t === 'unordered-list' || t === 'list') {
198
+ const ordered = t === 'numbered-list'
199
+ const LN = String.fromCharCode(10)
200
+ const out = []
201
+ let num = 0
202
+ kids.forEach((k) => {
203
+ // 子列表(嵌套):整体缩进两格,接在上一项后面
204
+ if (isListLike(k)) {
205
+ const sub = nodeToMd(k)
206
+ if (sub) sub.split(LN).forEach((l) => out.push(' ' + l))
207
+ return
208
+ }
209
+ num++
210
+ const mark = k.checked === true ? '- [x] ' : k.checked === false ? '- [ ] ' : ordered ? num + '. ' : '- '
211
+ // 一项的 children 里可能既有文本、也有挂在它下面的子列表,
212
+ // 不能用 inlineText(它会把子列表的文字一起揉进这一行)
213
+ const parts = []
214
+ ;(k.children || []).forEach((c) => {
215
+ if (isListLike(c)) {
216
+ const sub = nodeToMd(c)
217
+ if (sub) sub.split(LN).forEach((l) => parts.push(' ' + l))
218
+ } else if (c && typeof c.text === 'string') {
219
+ parts.push(c.text.split(LN).join(' '))
220
+ }
221
+ })
222
+ if (!parts.length) {
223
+ out.push(mark.trimEnd())
224
+ return
225
+ }
226
+ const [first, ...rest] = parts
227
+ out.push(mark + first + (rest.length ? LN + rest.join(LN) : ''))
228
+ })
229
+ return out.join(LN)
230
+ }
231
+
232
+ if (t === 'list-item' || t === 'list_item') {
233
+ const mark = n.checked === true ? '- [x] ' : n.checked === false ? '- [ ] ' : '- '
234
+ const LN = String.fromCharCode(10)
235
+ const parts = []
236
+ ;(n.children || []).forEach((c) => {
237
+ if (isListLike(c)) {
238
+ // 子列表:整体缩进两格,接在这一项下面
239
+ const sub = nodeToMd(c)
240
+ if (sub) sub.split(LN).forEach((l) => parts.push(' ' + l))
241
+ } else if (c && typeof c.text === 'string') {
242
+ parts.push(c.text.split(LN).join(' '))
243
+ }
244
+ })
245
+ // 第一段接在列表标记后面,后面的嵌套行单独成行
246
+ if (!parts.length) return mark.trimEnd()
247
+ const [first, ...rest] = parts
248
+ return mark + first + (rest.length ? LN + rest.join(LN) : '')
249
+ }
250
+
251
+ if (t === 'blockquote') {
252
+ const body = kids.map((k) => inlineText(k)).join('')
253
+ return body.split(String.fromCharCode(10)).map((l) => '> ' + l).join(String.fromCharCode(10))
254
+ }
255
+
256
+ if (t === 'heading' || /^h[1-6]$/.test(t)) {
257
+ const lvl = parseInt(n.level || t.replace(/\D/g, '') || 1, 10)
258
+ return '#'.repeat(Math.min(6, Math.max(1, lvl))) + ' ' + inlineText(n).split(String.fromCharCode(10)).join(' ')
259
+ }
260
+
261
+ if (t === 'image' || t === 'img') {
262
+ const url = n.url || n.src || (n._attachments && n._attachments[0] ? 'attachment:' + (n._attachments[0].digest || '') : '')
263
+ if (!url) return ''
264
+ const alt = typeof n.alt === 'string' ? n.alt : inlineText(n) || ''
265
+ return '![' + alt + '](' + url + ')'
266
+ }
267
+
268
+ if (t === 'hr' || t === 'divider') return '---'
269
+
270
+ if (t === 'math' || t === 'formula') {
271
+ const v = typeof n.value === 'string' ? n.value : (typeof n.content === 'string' ? n.content : inlineText(n))
272
+ return '$$' + String.fromCharCode(10) + v + String.fromCharCode(10) + '$$'
273
+ }
274
+
275
+ if (kids.length) return kids.map(nodeToMd).filter((s) => s !== '').join('\n')
276
+ return ''
277
+ }
278
+
279
+ const nodesToMd = (nodes) => {
280
+ // 标准 Markdown(Obsidian 口径):段落之间空一行,段内不换行。
281
+ // 这样在 Obsidian / Typora / GitHub 里打开都是正确分段;
282
+ // 空段落不输出——Markdown 里多个空行没有语义,压缩成一个分隔即可,
283
+ // 因此反复保存不会累积空白(git 里只有真正改动的内容行)。
284
+ const LF = String.fromCharCode(10)
285
+ const parts = (nodes || [])
286
+ .map(nodeToMd)
287
+ .filter((v) => v !== undefined && v !== null && String(v).trim() !== '')
288
+ return parts.join(LF + LF)
289
+ }
290
+
291
+ function isListLike (n) {
292
+ const t = n && n.type
293
+ return t === 'bulleted-list' || t === 'numbered-list' || t === 'unordered-list' || t === 'list'
294
+ }
295
+
296
+ function mdToNodes(md) {
297
+
298
+ const lines = String(md || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
299
+ const nodes = []
300
+ let i = 0
301
+ let pendingBlank = 0
302
+ while (i < lines.length) {
303
+ const line = lines[i]
304
+
305
+ // 代码块
306
+ const fence = line.match(/^\s*```(\S*)\s*$/)
307
+ if (fence) {
308
+ const lang = fence[1] || ''
309
+ const body = []
310
+ i++
311
+ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { body.push(lines[i]); i++ }
312
+ i++
313
+ nodes.push({ type: 'codeblock', key: 'cb' + Math.random().toString(36).slice(2, 9) + Date.now().toString(36), lang: lang || '', content: body.join('\n'), children: [{ text: '' }] })
314
+ continue
315
+ }
316
+
317
+ // 分割线(--- / *** / ___ 独立成行)
318
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
319
+ nodes.push({ type: 'hr', key: 'hr' + Math.random().toString(36).slice(2, 9) + Date.now().toString(36), children: [{ text: '' }] })
320
+ i++
321
+ continue
322
+ }
323
+
324
+ // 表格(首行 |...|,次行是 |---|---| 分隔)
325
+ if (/^\s*\|/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|?\s*$/.test(lines[i + 1])) {
326
+ const raw = []
327
+ while (i < lines.length && /^\s*\|/.test(lines[i])) { raw.push(lines[i]); i++ }
328
+ const cells = (r) =>
329
+ r.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim())
330
+ const head = cells(raw[0])
331
+ const body = raw.slice(2).map(cells)
332
+ // 分隔行决定每列对齐,丢了这个信息往返就会把 :-- / :-: / --: 全变成 ---
333
+ const align = cells(raw[1]).map((c) => {
334
+ const t = c.trim()
335
+ const left = t.startsWith(':')
336
+ const right = t.endsWith(':')
337
+ if (left && right) return 'center'
338
+ if (left) return 'left'
339
+ if (right) return 'right'
340
+ return ''
341
+ })
342
+ nodes.push({
343
+ type: 'table',
344
+ align,
345
+ children: [head, ...body].map((cs) => ({
346
+ type: 'table-row',
347
+ children: cs.map((c) => ({ type: 'table-cell', children: [{ text: brToNl(c) }] })),
348
+ })),
349
+ })
350
+ continue
351
+ }
352
+
353
+ // 空行
354
+ if (!line.trim()) { pendingBlank++; i++; continue }
355
+ // <br/> 独立成行:按空行处理,笔记里不保留 br 标签
356
+ if (/^[ \t]*<br[ \t]*\/?>[ \t]*$/i.test(line)) { pendingBlank++; i++; continue }
357
+ // 连续空行里,第一个用于分隔段落,剩下的还原成空段落(保证往返一致)
358
+ // 标准 Markdown:空行是段落分隔符,不产生空段落(连续多个空行效果等同)。
359
+ pendingBlank = 0
360
+
361
+ // 图片(独占一行):![alt](url)
362
+ const imgLine = line.match(/^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/)
363
+ if (imgLine) {
364
+ nodes.push({ type: 'image', url: imgLine[2], alt: imgLine[1], children: [{ text: '' }] })
365
+ i++
366
+ continue
367
+ }
368
+
369
+ // 公式块(独占两行的 $$):$$ 开头到 $$ 结尾
370
+ if (/^\s*\$\$\s*$/.test(line)) {
371
+ const body = []
372
+ i++
373
+ while (i < lines.length && !/^\s*\$\$\s*$/.test(lines[i])) { body.push(lines[i]); i++ }
374
+ i++
375
+ nodes.push({ type: 'math', value: body.join(String.fromCharCode(10)), children: [{ text: '' }] })
376
+ continue
377
+ }
378
+
379
+ // 列表
380
+ if (/^\s*([-*+]|\d+\.)\s+/.test(line)) {
381
+ const ordered = /^\s*\d+\./.test(line)
382
+ // 按缩进还原嵌套:同一层归一组,缩进更深的挂到上一项的子列表里
383
+ const flat = []
384
+ let baseIndent = -1
385
+ while (i < lines.length && /^\s*([-*+]|\d+\.)\s+/.test(lines[i])) {
386
+ const m = lines[i].match(/^(\s*)([-*+]|\d+)\.?\s+(.*)$/)
387
+ if (!m) { i++; continue }
388
+ const indent = m[1].replace(/\t/g, ' ').length
389
+ if (baseIndent < 0) baseIndent = indent
390
+ const depth = Math.max(0, Math.round((indent - baseIndent) / 2))
391
+ const body = m[3]
392
+ const checked = /^\[( |x|X)\]\s/.test(body) ? /^\[[xX]\]/.test(body) : undefined
393
+ const text = body.replace(/^\[( |x|X)\]\s+/, '')
394
+ flat.push({ depth, text, checked })
395
+ i++
396
+ }
397
+ // 用栈把扁平项组装成嵌套
398
+ const root = { type: ordered ? 'numbered-list' : 'bulleted-list', children: [] }
399
+ const stack = [{ depth: 0, list: root }]
400
+ flat.forEach((f) => {
401
+ while (stack.length > 1 && f.depth < stack[stack.length - 1].depth) stack.pop()
402
+ const top = stack[stack.length - 1]
403
+ if (f.depth > top.depth) {
404
+ // 更深 → 挂到上一项下面
405
+ const prev = top.list.children[top.list.children.length - 1]
406
+ if (prev) {
407
+ const sub = { type: ordered ? 'numbered-list' : 'bulleted-list', children: [] }
408
+ prev.children.push(sub)
409
+ stack.push({ depth: f.depth, list: sub })
410
+ }
411
+ }
412
+ const item = { type: 'list-item', children: [{ text: brToNl(f.text) }] }
413
+ if (f.checked !== undefined) item.checked = f.checked
414
+ stack[stack.length - 1].list.children.push(item)
415
+ })
416
+ nodes.push(root)
417
+ continue
418
+ }
419
+
420
+ // 标题
421
+ const h = line.match(/^(#{1,6})\s+(.*)$/)
422
+ if (h) {
423
+ nodes.push({ type: 'heading', level: h[1].length, children: [{ text: brToNl(h[2]) }] })
424
+ i++
425
+ continue
426
+ }
427
+
428
+ // 引用
429
+ if (/^\s*>\s?/.test(line)) {
430
+ const body = []
431
+ while (i < lines.length && /^\s*>\s?/.test(lines[i])) { body.push(lines[i].replace(/^\s*>\s?/, '')); i++ }
432
+ nodes.push({ type: 'blockquote', children: [{ text: brToNl(body.join('\n')) }] })
433
+ continue
434
+ }
435
+
436
+ // 普通段落:一行一个段落,不再把连续非空行合并。
437
+ // 合并会让多行变成一个 Slate 段落(行与行之间是软换行),
438
+ // 编辑器里看起来有好几行其实是一个 <p>:光标进去删不掉整行,
439
+ // 空行也没地方安放,最后变成"源码有、编辑区没有"。
440
+ nodes.push({ type: 'paragraph', children: [{ text: brToNl(line) }] })
441
+ i++
442
+ }
443
+
444
+ // 末尾空行不生成空段落(标准 Markdown);但编辑器需要一个空段落承接光标,
445
+ // 序列化时会把它连同其它空段落一起过滤,不会写进文件。
446
+ nodes.push({ type: 'paragraph', children: [{ text: '' }] })
447
+ return nodes
448
+ }
449
+
450
+ // ---------------- 扫描工作区 ----------------
451
+ // 认 .md 与 .markdown 两种后缀;标题一律去掉后缀,否则 .markdown 文件的
452
+ // 标题会带着 ".markdown" 尾巴,改名判断也会误判。
453
+ const MD_EXT = /\.(md|markdown)$/i
454
+ const stripExt = (rel) => path.basename(String(rel)).replace(MD_EXT, '')
455
+ // 遍历上限:不忽略任何目录(磁盘上有什么就显示什么),但目录/笔记数量
456
+ // 超过阈值时停止深挖并标记,由界面提示「目录过多」,避免选到工程目录时
457
+ // 每次扫描都卡住整个插件。
458
+ const MAX_DIRS = 5000
459
+ const MAX_FILES = 20000
460
+ let LAST_TRUNCATED = false
461
+
462
+ function walk(dir, rel, acc) {
463
+ if (acc.dirs.length > MAX_DIRS || acc.files.length > MAX_FILES) { acc.truncated = true; return }
464
+ let ents = []
465
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }) } catch (e) { return }
466
+ for (const e of ents) {
467
+ if (e.name.startsWith('.')) continue // 隐藏目录/文件(.git .obsidian .attachments 等)
468
+ if (e.name === 'assets') continue // 图片附件目录,图片通过 assets/xxx.png 直接引用,不进笔记树
469
+ const r = rel ? rel + '/' + e.name : e.name
470
+ const full = path.join(dir, e.name)
471
+ if (e.isDirectory()) {
472
+ acc.dirs.push(r)
473
+ walk(full, r, acc)
474
+ } else if (MD_EXT.test(e.name)) {
475
+ acc.files.push({ rel: r, full })
476
+ }
477
+ }
478
+ }
479
+
480
+ let CACHE = null
481
+ let CACHE_AT = 0
482
+ const TTL = 800 // ms,避免每次交互全盘重扫
483
+
484
+ function dirOf(rel) {
485
+ const d = path.dirname(rel)
486
+ return d === '.' ? '' : d.replace(/\\/g, '/')
487
+ }
488
+
489
+ function buildTree(dirList) {
490
+ // 生成 {dir: children[]},children 里子目录内联(自包含,UI 好渲染)
491
+ const byParent = new Map()
492
+ for (const d of dirList) {
493
+ const p = dirOf(d) === '' ? '__root__' : dirOf(d)
494
+ if (!byParent.has(p)) byParent.set(p, [])
495
+ byParent.get(p).push(d)
496
+ }
497
+ const inline = (d) => {
498
+ const kids = []
499
+ const notesOf = NOTES_BY_DIR.get(d) || []
500
+ for (const rel of notesOf) {
501
+ const nd = NOTES_DOCS.get('note/' + rel)
502
+ kids.push(nd ? { id: 'note/' + rel, data: { id: 'note/' + rel, title: nd.title, plain: nd.plain } } : { id: 'note/' + rel })
503
+ }
504
+ for (const sub of (byParent.get(d) || [])) {
505
+ // id 用裸名(T3),必须和 folder doc 的 id 一致:bundle 的 _R() 会拿 folder id
506
+ // 去 children 里认重复项,两边格式不同就会既保留又重挂,反复展开折叠后节点重叠。
507
+ kids.push({ id: sub.split('/').pop(), name: sub.split('/').pop(), children: inline(sub) })
508
+ }
509
+ return sortKids(kids, TREE_ORDER[d])
510
+ }
511
+ return inline
512
+ }
513
+
514
+ let NOTES_BY_DIR = new Map()
515
+ let NOTES_DOCS = new Map()
516
+
517
+ function scan() {
518
+ TREE_ORDER = readTreeOrder()
519
+ const now = Date.now()
520
+ if (CACHE && now - CACHE_AT < TTL) return CACHE
521
+ const acc = { dirs: [], files: [], truncated: false }
522
+ walk(WS, '', acc)
523
+ LAST_TRUNCATED = !!acc.truncated
524
+
525
+ NOTES_BY_DIR = new Map()
526
+ NOTES_DOCS = new Map()
527
+ const docs = new Map()
528
+
529
+ for (const f of acc.files) {
530
+ let st
531
+ try { st = fs.statSync(f.full) } catch (e) { continue }
532
+ const d = dirOf(f.rel)
533
+ if (!NOTES_BY_DIR.has(d)) NOTES_BY_DIR.set(d, [])
534
+ NOTES_BY_DIR.get(d).push(f.rel)
535
+
536
+ const catId = 'category/' + d
537
+ // 尽量复用缓存里已解析的 data(mtime 没变就不重读)
538
+ const old = CACHE && CACHE.get ? CACHE.get('note/' + f.rel) : null
539
+ let data
540
+ let raw = ''
541
+ if (old && old._rev === String(st.mtimeMs)) {
542
+ data = old.data
543
+ raw = old.plain || ''
544
+ } else {
545
+ try {
546
+ raw = fs.readFileSync(f.full, 'utf8')
547
+ data = mdToNodes(raw)
548
+ } catch (e) {
549
+ raw = ''
550
+ data = [{ type: 'paragraph', children: [{ text: '' }] }]
551
+ }
552
+ }
553
+ const roSet = (function () { try { return readNoteState().readonlyHashes } catch (e) { return new Set() } })()
554
+ const _doc = {
555
+ _id: 'note/' + f.rel,
556
+ id: 'note/' + f.rel,
557
+ _rev: String(st.mtimeMs),
558
+ title: stripExt(f.rel),
559
+ data,
560
+ createAt: Math.round(st.birthtimeMs || st.mtimeMs),
561
+ updateAt: Math.round(st.mtimeMs),
562
+ categoryId: catId,
563
+ plain: String(raw || '').slice(0, 8000),
564
+ }
565
+ if (roSet.has(idHash('note/' + f.rel))) _doc.readonly = true
566
+ docs.set('note/' + f.rel, _doc)
567
+ NOTES_DOCS.set('note/' + f.rel, docs.get('note/' + f.rel))
568
+ }
569
+
570
+ const inline = buildTree(acc.dirs)
571
+ // 每个目录一个 folder doc
572
+ for (const d of acc.dirs) {
573
+ docs.set('folder/' + d, {
574
+ _id: 'folder/' + d,
575
+ // id 用裸名,和 children 里子目录项的 id 保持一致(bundle 靠它去重、重挂)
576
+ id: d.split('/').pop(),
577
+ name: d.split('/').pop(),
578
+ children: inline(d),
579
+ })
580
+ }
581
+ // 根目录(散落笔记 + 顶层目录)作为一个虚拟 folder,供 rootsort 用
582
+ const rootChildren = []
583
+ for (const rel of (NOTES_BY_DIR.get('') || [])) rootChildren.push({ id: 'note/' + rel })
584
+ for (const d of acc.dirs) {
585
+ if (dirOf(d) === '') rootChildren.push({ id: d, name: d.split('/').pop(), children: inline(d) })
586
+ }
587
+ docs.set('__root__', { _id: '__root__', id: '__root__', name: '', children: rootChildren })
588
+
589
+ CACHE = docs
590
+ CACHE_AT = now
591
+ return docs
592
+ }
593
+
594
+ function invalidate() { CACHE = null; CACHE_AT = 0 }
595
+
596
+ // ---- 监听工作区:外部新建/改名/删除文件时通知上层刷新 ----
597
+ let fsWatcher = null
598
+ const fsListeners = []
599
+ let pendingNotify = null
600
+ function notifyFsChange(name) {
601
+ if (pendingNotify) return
602
+ pendingNotify = setTimeout(function () {
603
+ pendingNotify = null
604
+ for (let i = 0; i < fsListeners.length; i++) {
605
+ try { fsListeners[i](name) } catch (e) {}
606
+ }
607
+ }, 350)
608
+ if (pendingNotify.unref) pendingNotify.unref()
609
+ }
610
+ function startWatch() {
611
+ if (fsWatcher) return
612
+ try {
613
+ fsWatcher = fs.watch(WS, { recursive: true }, function (evt, filename) {
614
+ if (!filename) return
615
+ const rel = String(filename).split(path.sep).join('/')
616
+ // 忽略点开头(.git / .attachments)与元数据
617
+ if (rel.startsWith('.') || rel.indexOf('/.') >= 0) return
618
+ invalidate()
619
+ notifyFsChange(rel)
620
+ })
621
+ if (fsWatcher.on) fsWatcher.on('error', function () {})
622
+ if (fsWatcher.unref) fsWatcher.unref()
623
+ } catch (e) {
624
+ try { console.log('[znotes] watch 启动失败', e && e.message) } catch (e2) {}
625
+ }
626
+ }
627
+
628
+ // ---------------- 笔记附加状态(只读等,不进正文,避免污染 md) ----------------
629
+ // 存成 .cmark/locked.txt:一行一个已锁定笔记 id 的哈希,文件为空/没有该行就是未锁定。
630
+ // 这里只回答"这个 id 是否锁定",读取时按 id 现算哈希比对即可,
631
+ // 不需要保留可读路径,也不怕文件名里出现特殊字符。
632
+ const LOCKEDTXT = path.join(REPO_META, 'locked.txt')
633
+ // 设为全局搜索(ZTools feature)的笔记也记在这里,同样只存哈希
634
+ const NEARTXT = path.join(REPO_META, 'near.txt')
635
+ const idHash = function (id) {
636
+ return crypto.createHash('md5').update('cmark-lock:' + String(id)).digest('hex').slice(0, 12)
637
+ }
638
+ function readHashFile (file) {
639
+ const set = new Set()
640
+ try {
641
+ fs.readFileSync(file, 'utf8').split(/\r?\n/).forEach(function (line) {
642
+ const t = line.trim()
643
+ if (t && t.charAt(0) !== '#') set.add(t)
644
+ })
645
+ } catch (e) {}
646
+ return set
647
+ }
648
+ function writeHashFile (file, set) {
649
+ try {
650
+ mk(REPO_META)
651
+ const arr = Array.from(set || []).filter(Boolean).sort()
652
+ const next = arr.length ? arr.join('\n') + '\n' : ''
653
+ let prev = ''
654
+ try { prev = fs.readFileSync(file, 'utf8') } catch (e) {}
655
+ // 内容没变不写盘,避免插件启动就刷新 mtime、把工作区标成"已修改"
656
+ if (prev !== next) fs.writeFileSync(file, next, 'utf8')
657
+ } catch (e) {}
658
+ }
659
+ function readNoteState () {
660
+ return {
661
+ readonlyHashes: readHashFile(LOCKEDTXT),
662
+ nearHashes: readHashFile(NEARTXT)
663
+ }
664
+ }
665
+ function setNoteFlag (id, flag, on) {
666
+ if (flag !== 'readonly' && flag !== 'near') return
667
+ const st = readNoteState()
668
+ const h = idHash(id)
669
+ const set = flag === 'readonly' ? st.readonlyHashes : st.nearHashes
670
+ const file = flag === 'readonly' ? LOCKEDTXT : NEARTXT
671
+ const has = set.has(h)
672
+ if (!!on === has) return // 状态没变就不写盘
673
+ if (on) set.add(h)
674
+ else set.delete(h)
675
+ writeHashFile(file, set)
676
+ }
677
+
678
+ // ---------------- 上手引导状态 ----------------
679
+ // 单独一个文件,不塞进 ui.json(那个结构被 bundle 依赖,字段缺失会让初始化中断)
680
+ const ONBOARDJSON = path.join(META, 'onboard.json')
681
+ function onboardGet () {
682
+ try { return JSON.parse(fs.readFileSync(ONBOARDJSON, 'utf8')) || {} } catch (e) { return {} }
683
+ }
684
+ function onboardSet (o) {
685
+ try { mk(META); fs.writeFileSync(ONBOARDJSON, JSON.stringify(o || {}), 'utf8') } catch (e) {}
686
+ return o || {}
687
+ }
688
+
689
+ // ---------------- 目录树排序(拖拽结果持久化) ----------------
690
+ // 磁盘只决定"有哪些节点",人工拖拽决定的"顺序"记在 .cmark/order.txt,
691
+ // 每次扫描按它重排,于是重开插件顺序不变。新出现的节点不在表里,排到末尾。
692
+ // 顺序存成纯文本 .cmark/order.txt,一行一项,段头写目录:
693
+ //
694
+ // # 根目录
695
+ // IWAN
696
+ // RN
697
+ //
698
+ // # IWAN
699
+ // Cloudflare.md
700
+ // IKEX
701
+ //
702
+ // 行 = 条目的 id(笔记 note/…、目录裸名),和内存里排序用的键完全一致,
703
+ // 读写两端都不做转换。段头仍保留:每个目录各有自己的内部顺序,必须分组。
704
+ // 排序的改动没人会去读 diff,所以这里优先"少转换、少出错",不追求可读性。
705
+ const ORDERTXT = path.join(REPO_META, 'order.txt')
706
+ function readTreeOrder () {
707
+ const out = {}
708
+ try {
709
+ const raw = fs.readFileSync(ORDERTXT, 'utf8')
710
+ let cur = ''
711
+ raw.split(/\r?\n/).forEach(function (line) {
712
+ const t = line.trim()
713
+ if (!t) return
714
+ if (t.charAt(0) === '#') {
715
+ const name = t.slice(1).trim()
716
+ cur = (name === '根目录' || name === '根' || name === '') ? '' : name.replace(/\/+$/, '')
717
+ if (!out[cur]) out[cur] = []
718
+ return
719
+ }
720
+ if (!out[cur]) out[cur] = []
721
+ out[cur].push(t)
722
+ })
723
+ } catch (e) { return {} }
724
+ return out
725
+ }
726
+
727
+ function writeTreeOrder (o) {
728
+ try {
729
+ mk(REPO_META)
730
+ const keys = Object.keys(o || {}).filter(function (k) {
731
+ return Array.isArray(o[k]) && o[k].length
732
+ })
733
+ // 根目录置顶、其余按路径字典序:同样的顺序永远生成同样的文件,diff 不会因抖动变大
734
+ keys.sort(function (a, b) {
735
+ if (a === b) return 0
736
+ if (a === '') return -1
737
+ if (b === '') return 1
738
+ return a < b ? -1 : 1
739
+ })
740
+ const lines = []
741
+ keys.forEach(function (k, i) {
742
+ if (i) lines.push('')
743
+ lines.push('# ' + (k === '' ? '根目录' : k))
744
+ o[k].forEach(function (id) {
745
+ // 直接写 id:匹配就是按 id 做的,没必要再裁成名字、读的时候又拼回去
746
+ lines.push(String(id))
747
+ })
748
+ })
749
+ const next = lines.join('\n') + '\n'
750
+ let prev = ''
751
+ try { prev = fs.readFileSync(ORDERTXT, 'utf8') } catch (e) {}
752
+ // 内容没变就不写:否则每次扫描都刷新 mtime,git 工作区会一直挂着"已修改"
753
+ if (prev !== next) fs.writeFileSync(ORDERTXT, next, 'utf8')
754
+ } catch (e) {}
755
+ }
756
+
757
+ // 树与磁盘对账:以「用户拖拽后的树」为准,修正磁盘上的实际位置。
758
+ // 解决两类残留:
759
+ // 1) 拖到根级/别的目录后,原目录里留下空壳(bundle 只改树不动磁盘时会这样)
760
+ // 2) 目标位置被先建了一个空目录,挡住真正的移动
761
+ function reconcileTree (tree) {
762
+ try {
763
+ const want = new Set()
764
+ const visit = (list, prefix) => {
765
+ if (!Array.isArray(list)) return
766
+ list.forEach((n) => {
767
+ if (!n || !n.id) return
768
+ const sid = String(n.id)
769
+ if (sid.indexOf('note/') === 0) return
770
+ const leaf = sid.split('/').pop()
771
+ if (!leaf) return
772
+ const full = prefix ? prefix + '/' + leaf : leaf
773
+ want.add(full)
774
+ if (n.children && n.children.length) visit(n.children, full)
775
+ })
776
+ }
777
+ visit(tree, '')
778
+ if (!want.size) return false
779
+
780
+ const acc = { dirs: [], files: [], truncated: false }
781
+ walk(WS, '', acc)
782
+ let changed = false
783
+
784
+ // A) 该在的位置还没有 → 把别处的同名目录挪过来(优先层级浅的)
785
+ // 目标若被一个空壳占着(拖拽时先建的空目录),先清掉它再挪。
786
+ want.forEach(function (toRel) {
787
+ const toAbs = path.join(WS, toRel)
788
+ const leaf = toRel.split('/').pop()
789
+ const cands = acc.dirs.filter(function (d) {
790
+ return d.split('/').pop() === leaf && d !== toRel
791
+ })
792
+ if (!cands.length) return
793
+ cands.sort(function (a, b) { return a.split('/').length - b.split('/').length })
794
+ if (fs.existsSync(toAbs)) {
795
+ try {
796
+ if (fs.readdirSync(toAbs).length === 0) fs.rmdirSync(toAbs)
797
+ else return // 已有真实内容,不动
798
+ } catch (e) { return }
799
+ }
800
+ for (let i = 0; i < cands.length; i++) {
801
+ const fromAbs = path.join(WS, cands[i])
802
+ try {
803
+ if (fs.existsSync(fromAbs) && !fs.existsSync(toAbs)) {
804
+ mk(path.dirname(toAbs))
805
+ fs.renameSync(fromAbs, toAbs)
806
+ changed = true
807
+ break
808
+ }
809
+ } catch (e) {}
810
+ }
811
+ })
812
+
813
+ // B) 磁盘上多出来的空目录(树里已经不在了)→ 清掉残留空壳。
814
+ // 只删空目录:非空的一律不动,避免误伤。
815
+ acc.dirs.forEach(function (d) {
816
+ if (want.has(d)) return
817
+ const abs = path.join(WS, d)
818
+ try {
819
+ if (fs.existsSync(abs) && fs.readdirSync(abs).length === 0) {
820
+ fs.rmdirSync(abs)
821
+ changed = true
822
+ }
823
+ } catch (e) {}
824
+ })
825
+
826
+ if (changed) { invalidate(); notifyFsChange('') }
827
+ return changed
828
+ } catch (e) { return false }
829
+ }
830
+
831
+ function sortKids (kids, seq) {
832
+ if (!seq || !seq.length || !kids.length) return kids
833
+ const idx = new Map()
834
+ seq.forEach((id, i) => idx.set(id, i))
835
+ return kids.slice().sort((a, b) => {
836
+ const ia = idx.has(a.id) ? idx.get(a.id) : 1e9
837
+ const ib = idx.has(b.id) ? idx.get(b.id) : 1e9
838
+ return ia === ib ? 0 : ia - ib
839
+ })
840
+ }
841
+ function saveTreeOrder (tree) {
842
+ // key 与元素 id 统一用「相对仓库根的完整路径」(目录 T1/T3、笔记 note/a/b.md),
843
+ // 因为 buildTree 重建时用的就是完整路径,两边格式不一致会永远匹配不上,
844
+ // 表现就是拖进去的东西固定排在最下面。
845
+ // 另外:bundle 移动目录时不会更新子笔记的 id,这里按文件名把它们对齐到磁盘实际路径,
846
+ // 否则目录一移,里面笔记的顺序记录就全部失效。
847
+ const out = {}
848
+ const acc = { dirs: [], files: [], truncated: false }
849
+ walk(WS, '', acc)
850
+ const byBase = new Map()
851
+ for (let fi = 0; fi < acc.files.length; fi++) {
852
+ const b = path.basename(acc.files[fi].rel)
853
+ if (!byBase.has(b)) byBase.set(b, [])
854
+ byBase.get(b).push(acc.files[fi].rel)
855
+ }
856
+ const normNote = (sid) => {
857
+ const rel = sid.slice('note/'.length)
858
+ try { if (fs.existsSync(path.join(WS, rel))) return sid } catch (e) {}
859
+ const cands = byBase.get(path.basename(rel)) || []
860
+ return cands.length === 1 ? 'note/' + cands[0] : sid
861
+ }
862
+ const walk2 = (list, key, prefix) => {
863
+ if (!Array.isArray(list) || !list.length) return
864
+ out[key] = list.map((n) => {
865
+ if (!n || !n.id) return null
866
+ const sid = String(n.id)
867
+ if (sid.indexOf('note/') === 0) return normNote(sid)
868
+ const leaf = sid.split('/').pop()
869
+ // 目录存裸名,和 folder id / buildTree 生成的 children id 保持一致
870
+ return leaf || null
871
+ }).filter(Boolean)
872
+ list.forEach((n) => {
873
+ if (!n || !n.children || !n.children.length) return
874
+ const sid = String(n.id || '')
875
+ if (!sid) return
876
+ const leaf = sid.split('/').pop()
877
+ if (!leaf) return
878
+ const full = prefix ? prefix + '/' + leaf : leaf
879
+ walk2(n.children, full, full)
880
+ })
881
+ }
882
+ walk2(tree, '', '')
883
+ writeTreeOrder(out)
884
+ return out
885
+ }
886
+ let TREE_ORDER = {}
887
+
888
+ // ---------------- ui.json / storage.json ----------------
889
+ // ui.json 里存 setting / rootsort / lastNote。
890
+ // 新目录首次使用时该文件不存在,必须就地补齐默认值并落盘:
891
+ // bundle 的 initData 依赖这些 doc 存在,缺文件会导致初始化中断(界面卡在「读取中」)。
892
+ const ui = () => {
893
+ let u = rj(UIJSON, null)
894
+ let dirty = false
895
+ if (!u || typeof u !== 'object') { u = {}; dirty = true }
896
+ if (!u.setting) { u.setting = { hideToolbar: false, bodyFontSize: 14 }; dirty = true }
897
+ if (!Array.isArray(u.rootsort)) { u.rootsort = []; dirty = true }
898
+ if (!u.lastNote || typeof u.lastNote !== 'object') { u.lastNote = {}; dirty = true }
899
+ if (dirty) { try { wj(UIJSON, u) } catch (e) {} }
900
+ return u
901
+ }
902
+ const setUi = (patch) => { const u = ui(); Object.assign(u, patch); wj(UIJSON, u) }
903
+
904
+ // ---------------- __NDB ----------------
905
+ const __NDB = {
906
+ get(id) {
907
+ if (!id) return null
908
+ if (id === 'setting') {
909
+ const u = ui()
910
+ return u.setting ? { _id: 'setting', ...u.setting } : null
911
+ }
912
+ if (id === 'rootsort') return ui().rootsort || null
913
+ if (id.startsWith('last_work_note/')) {
914
+ const k = id.slice('last_work_note/'.length)
915
+ const o = (ui().lastNote || {})[k]
916
+ return o ? { _id: id, note: o } : null
917
+ }
918
+ if (id.startsWith('attachment/')) {
919
+ // 附件按 digest 找实际文件
920
+ const digest = id.slice('attachment/'.length)
921
+ try {
922
+ const f = fs.readdirSync(ASSETS).find((x) => x.startsWith(digest))
923
+ if (f) return { _id: id, digest }
924
+ } catch (e) {}
925
+ return null
926
+ }
927
+ const docs = scan()
928
+ return docs.get(id) || null
929
+ },
930
+
931
+ put(doc) {
932
+ if (!doc || !doc._id) return { error: true, message: 'bad doc' }
933
+ let id = doc._id
934
+
935
+ if (id === 'setting') {
936
+ const { _id, _rev, ...rest } = doc
937
+ setUi({ setting: rest })
938
+ return { ok: true, id, rev: String(Date.now()) }
939
+ }
940
+ if (id === 'rootsort') {
941
+ setUi({ rootsort: doc.data !== undefined ? doc.data : doc })
942
+ return { ok: true, id, rev: String(Date.now()) }
943
+ }
944
+ if (id.startsWith('last_work_note/')) {
945
+ const k = id.slice('last_work_note/'.length)
946
+ const u = ui()
947
+ const m = Object.assign({}, u.lastNote || {})
948
+ m[k] = doc.note !== undefined ? doc.note : doc
949
+ setUi({ lastNote: m })
950
+ return { ok: true, id, rev: String(Date.now()) }
951
+ }
952
+
953
+ if (id.startsWith('note/')) {
954
+ const __oldId = id // 改名后 id 会变,用于迁移附加状态
955
+ // 移动前后 md 所在目录:写文件时要用它重算图片相对路径,
956
+ // 否则 doc.data 序列化出的内容会带着旧位置的 ../ 层数,图全断
957
+ let pathMovedFrom = null
958
+ let rel = id.slice('note/'.length)
959
+ const safeTitle = String(doc.title || '').replace(/[\/:*?"<>|]/g, '').trim()
960
+ if (!MD_EXT.test(rel)) {
961
+ // 新建笔记:上层用 note/<时间戳> 作临时 id,落盘要用标题作文件名。
962
+ // 目录优先取 _id 里的前缀,其次取 doc.categoryId(在文件夹里新建时
963
+ // bundle 只设置 categoryId,_id 仍是纯时间戳)
964
+ let dirRel = dirOf(rel)
965
+ if (!dirRel && doc.categoryId) {
966
+ const cid = String(doc.categoryId)
967
+ if (cid.indexOf('category/') === 0) dirRel = cid.slice('category/'.length).split('/').join('/')
968
+ }
969
+ const leaf = safeTitle || rel.split('/').pop()
970
+ rel = (dirRel ? dirRel + '/' : '') + leaf + '.md'
971
+ id = 'note/' + rel
972
+ doc._id = id
973
+ } else if (safeTitle && safeTitle !== stripExt(rel)) {
974
+ // 改名:标题与磁盘文件名不一致时,把文件重命名到新标题(同目录内)。
975
+ // 后缀沿用原文件的(.md / .markdown),不要因为改名就换掉用户的后缀。
976
+ const dirRel = dirOf(rel)
977
+ const ext = path.extname(rel) || '.md'
978
+ const newRel = (dirRel ? dirRel + '/' : '') + safeTitle + ext
979
+ const oldAbs = path.join(WS, rel)
980
+ const newAbs = path.join(WS, newRel)
981
+ try {
982
+ if (rel !== newRel && fs.existsSync(oldAbs) && !fs.existsSync(newAbs)) {
983
+ fs.renameSync(oldAbs, newAbs)
984
+ rewriteImagePaths(oldAbs, newAbs)
985
+ pathMovedFrom = path.dirname(oldAbs)
986
+ rel = newRel
987
+ id = 'note/' + rel
988
+ doc._id = id
989
+ }
990
+ } catch (e) {}
991
+ }
992
+ // 拖拽移动:categoryId 指向的目录与文件当前所在目录不一致时,移动文件
993
+ {
994
+ let wantDir = ''
995
+ if (doc.categoryId && String(doc.categoryId).indexOf('category/') === 0) {
996
+ wantDir = String(doc.categoryId).slice('category/'.length)
997
+ }
998
+ const curDir = dirOf(rel)
999
+ if (wantDir !== curDir) {
1000
+ const base = path.basename(rel)
1001
+ const newRel = (wantDir ? wantDir + '/' : '') + base
1002
+ const oldAbs = path.join(WS, rel)
1003
+ const newAbs = path.join(WS, newRel)
1004
+ try {
1005
+ if (fs.existsSync(oldAbs) && !fs.existsSync(newAbs)) {
1006
+ mk(path.dirname(newAbs))
1007
+ fs.renameSync(oldAbs, newAbs)
1008
+ rewriteImagePaths(oldAbs, newAbs)
1009
+ pathMovedFrom = path.dirname(oldAbs)
1010
+ rel = newRel
1011
+ id = 'note/' + rel
1012
+ doc._id = id
1013
+ }
1014
+ } catch (e) {}
1015
+ }
1016
+ }
1017
+
1018
+ // 只读等附加状态:与正文无关,必须在“内容相同就早退”之前处理,
1019
+ // 否则切换只读(内容没变)不会被持久化
1020
+ try {
1021
+ setNoteFlag(__oldId, 'readonly', false)
1022
+ if (doc.readonly) setNoteFlag(id, 'readonly', true)
1023
+ } catch (e) {}
1024
+
1025
+ const target = path.join(WS, rel)
1026
+ mk(path.dirname(target))
1027
+ let md = nodesToMd(doc.data || [])
1028
+ // 位置变过:按新目录重算一遍相对路径(rename 时改过的内容会被这里覆盖,所以必须在这里也做)
1029
+ if (pathMovedFrom && pathMovedFrom !== path.dirname(target)) {
1030
+ md = rewriteRelInText(md, pathMovedFrom, path.dirname(target))
1031
+ }
1032
+ // 内容与磁盘一致时不写盘:避免打开笔记就改写文件
1033
+ let _prev = null
1034
+ try { _prev = fs.readFileSync(target, 'utf8') } catch (e) {}
1035
+ // 保持原文件的末尾换行习惯:原来结尾有换行就补一个,没有就不加。
1036
+ // 否则改一个字会连带改变文件尾部,git diff 里多出一条空行改动。
1037
+ if (typeof _prev === 'string' && _prev.endsWith('\n') && !md.endsWith('\n')) md += '\n'
1038
+ if (typeof _prev === 'string' && _prev.replace(/\r\n/g, '\n').replace(/\s+$/, '') === md.replace(/\s+$/, '')) {
1039
+ return { ok: true, id, rev: String(Date.now()) }
1040
+ }
1041
+ fs.writeFileSync(target, md, 'utf8')
1042
+ // 通知 git 服务:用户产生了编辑,用于"停止编辑后自动同步"
1043
+ try { if (window.__znotesGit && window.__znotesGit.noteEditActivity) window.__znotesGit.noteEditActivity() } catch (e) {}
1044
+ invalidate()
1045
+ try {
1046
+ const st = fs.statSync(target)
1047
+ return { ok: true, id, rev: String(st.mtimeMs) }
1048
+ } catch (e) {
1049
+ return { ok: true, id, rev: String(Date.now()) }
1050
+ }
1051
+ }
1052
+
1053
+ if (id.startsWith('folder/')) {
1054
+ // 目录:新建要真的建文件夹,改名要真的 rename
1055
+ const rel = id.slice('folder/'.length)
1056
+ const name = doc.name
1057
+ const parent = dirOf(rel)
1058
+ const abs = path.join(WS, rel)
1059
+ // 1) 新建目录(磁盘上还没有就创建)
1060
+ if (!fs.existsSync(abs)) {
1061
+ try { fs.mkdirSync(abs, { recursive: true }) } catch (e) {}
1062
+ }
1063
+ // 2) 改名(name 与当前目录名不同)
1064
+ if (name && name !== rel.split('/').pop()) {
1065
+ const to = path.join(WS, parent, name)
1066
+ try { if (fs.existsSync(abs) && !fs.existsSync(to)) fs.renameSync(abs, to) } catch (e) {}
1067
+ }
1068
+ // 2.5) 移出:本次 put 里被移除的旧 children,若文件还在本目录,移回根目录
1069
+ {
1070
+ invalidate() // 丢弃 800ms 扫描缓存,拿到最新 children
1071
+ const prev = api.get ? api.get(id) : null
1072
+ const prevKids = (prev && Array.isArray(prev.children)) ? prev.children : []
1073
+ const nowIds = (Array.isArray(doc.children) ? doc.children : []).map(function (k) { return k && k.id })
1074
+ for (let pi = 0; pi < prevKids.length; pi++) {
1075
+ const pkId = prevKids[pi] && prevKids[pi].id
1076
+ if (!pkId || nowIds.indexOf(pkId) >= 0) continue
1077
+ if (pkId.indexOf('note/') === 0) {
1078
+ // children 里的 id 是相对本目录的(如 note/4456.md),不是仓库相对路径
1079
+ const pkRel = pkId.slice('note/'.length)
1080
+ const srcAbs = path.join(WS, rel, pkRel)
1081
+ const dstAbs = path.join(WS, path.basename(pkRel))
1082
+ try {
1083
+ if (fs.existsSync(srcAbs) && !fs.existsSync(dstAbs)) fs.renameSync(srcAbs, dstAbs)
1084
+ } catch (e) {}
1085
+ } else if (pkId.indexOf('folder/') !== 0) {
1086
+ // 子目录被拖出本目录:挪回根级。
1087
+ // 只在拖拽期间做 —— 启动时 bundle 也会 put 各 folder doc,
1088
+ // 那时候不该搬动任何目录。
1089
+ if (window.__znotesDragging) {
1090
+ const leafOut = String(pkId).split('/').pop()
1091
+ const srcAbs = path.join(WS, rel, leafOut)
1092
+ const dstAbs = path.join(WS, leafOut)
1093
+ try {
1094
+ // 根级已存在同名目录:空的(残留壳)就清掉,非空则不动
1095
+ if (fs.existsSync(dstAbs)) {
1096
+ let emptyOut = false
1097
+ try { emptyOut = fs.readdirSync(dstAbs).length === 0 } catch (e0) { emptyOut = false }
1098
+ if (!emptyOut) throw new Error('target not empty')
1099
+ fs.rmdirSync(dstAbs)
1100
+ }
1101
+ if (fs.existsSync(srcAbs) && !fs.existsSync(dstAbs)) fs.renameSync(srcAbs, dstAbs)
1102
+ } catch (e) {}
1103
+ }
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ // 3) 收拢子笔记:bundle 新建笔记会先 put 笔记(落在根目录),
1109
+ // 再把 {id} 塞进父文件夹的 children,最后 put 父文件夹。
1110
+ // 目录归属只存在于 children 里,这里把落到根目录的子笔记移进来。
1111
+ if (Array.isArray(doc.children)) {
1112
+ for (let ci = 0; ci < doc.children.length; ci++) {
1113
+ const kid = doc.children[ci]
1114
+ const kidId = kid && kid.id
1115
+ if (!kidId || kidId.indexOf('note/') !== 0) continue
1116
+ const kidRel = kidId.slice('note/'.length)
1117
+ const srcAbs = path.join(WS, kidRel) // 落在根目录时
1118
+ const dstAbs = path.join(WS, rel, path.basename(kidRel))
1119
+ try {
1120
+ if (fs.existsSync(srcAbs) && !fs.existsSync(dstAbs)) {
1121
+ mk(path.dirname(dstAbs))
1122
+ fs.renameSync(srcAbs, dstAbs)
1123
+ }
1124
+ } catch (e) {}
1125
+ }
1126
+ }
1127
+
1128
+ // 3.5) 收拢子目录:children 里出现的目录项(既不是 note/ 也不是 folder/)
1129
+ // 应当位于本目录下。bundle 拖拽只改 children 不动磁盘,这里补齐真正的
1130
+ // 目录移动 —— 否则界面把子目录挂进来了,磁盘上它还留在原处,重扫就又回去了。
1131
+ // 只在拖拽期间做:启动时 bundle 也会 put 各 folder doc,不能让它搬目录。
1132
+ if (Array.isArray(doc.children) && window.__znotesDragging) {
1133
+ let scanned = null
1134
+ for (let ci = 0; ci < doc.children.length; ci++) {
1135
+ const kid = doc.children[ci]
1136
+ const kidId = kid && kid.id
1137
+ if (!kidId) continue
1138
+ const sid = String(kidId)
1139
+ if (sid.indexOf('note/') === 0 || sid.indexOf('folder/') === 0) continue
1140
+ const leafName = sid.split('/').pop()
1141
+ if (!leafName || leafName === '.' || leafName === '..') continue
1142
+ const dstAbs = path.join(WS, rel, leafName)
1143
+ // 目标位置已存在时:如果它只是个空壳(上次移动留下的空目录),
1144
+ // 清掉它再移;有真实内容才跳过,避免覆盖。
1145
+ if (fs.existsSync(dstAbs)) {
1146
+ let emptyHere = false
1147
+ try { emptyHere = fs.readdirSync(dstAbs).length === 0 } catch (e0) { emptyHere = false }
1148
+ if (!emptyHere) continue
1149
+ try { fs.rmdirSync(dstAbs) } catch (e1) { continue }
1150
+ }
1151
+ // 在磁盘上找同名目录的当前位置(优先层级浅的,根级最常见)
1152
+ if (!scanned) { scanned = { dirs: [], files: [], truncated: false }; walk(WS, '', scanned) }
1153
+ const cands = scanned.dirs.filter(function (d) {
1154
+ return d.split('/').pop() === leafName && d !== rel && d.indexOf(rel + '/') !== 0
1155
+ })
1156
+ if (!cands.length) continue
1157
+ cands.sort(function (a, b) { return a.split('/').length - b.split('/').length })
1158
+ const fromAbs = path.join(WS, cands[0])
1159
+ try {
1160
+ if (fs.existsSync(fromAbs) && !fs.existsSync(dstAbs)) {
1161
+ mk(path.dirname(dstAbs))
1162
+ fs.renameSync(fromAbs, dstAbs)
1163
+ rewriteDirImagePaths(fromAbs, dstAbs)
1164
+ }
1165
+ } catch (e) {}
1166
+ }
1167
+ }
1168
+
1169
+ invalidate()
1170
+ try { if (typeof api.onFolderChanged === 'function') api.onFolderChanged() } catch (e) {}
1171
+ return { ok: true, id, rev: String(Date.now()) }
1172
+ }
1173
+
1174
+ return { ok: true, id, rev: String(Date.now()) }
1175
+ },
1176
+
1177
+ remove(doc) {
1178
+ const id = typeof doc === 'string' ? doc : doc && doc._id
1179
+ if (!id) return { error: true, message: 'bad id' }
1180
+ try { setNoteFlag(id, 'readonly', false) } catch (e) {}
1181
+
1182
+ if (id.startsWith('note/')) {
1183
+ const rel = id.slice('note/'.length)
1184
+ const src = path.join(WS, rel)
1185
+ try {
1186
+ // 直接删除,不保留副本:误删由 git 恢复(提交过的版本都在历史里)。
1187
+ // 注意:新建成、还没被自动同步提交过的笔记删掉就找不回来了。
1188
+ if (fs.existsSync(src)) fs.unlinkSync(src)
1189
+ } catch (e) {
1190
+ return { error: true, message: String(e && e.message) }
1191
+ }
1192
+ invalidate()
1193
+ return { ok: true, id, rev: String(Date.now()) }
1194
+ }
1195
+
1196
+ if (id.startsWith('folder/')) {
1197
+ const rel = id.slice('folder/'.length)
1198
+ const src = path.join(WS, rel)
1199
+ try {
1200
+ // 同上:真删,不搬回收站
1201
+ if (fs.existsSync(src)) fs.rmSync(src, { recursive: true, force: true })
1202
+ } catch (e) {
1203
+ return { error: true, message: String(e && e.message) }
1204
+ }
1205
+ invalidate()
1206
+ return { ok: true, id, rev: String(Date.now()) }
1207
+ }
1208
+
1209
+ return { ok: true, id, rev: String(Date.now()) }
1210
+ },
1211
+
1212
+ allDocs(selector) {
1213
+ const docs = scan()
1214
+ const out = []
1215
+ // 支持:不传(全部)、'folder/' 前缀、'note/' 前缀、id 数组
1216
+ if (Array.isArray(selector)) {
1217
+ for (const id of selector) {
1218
+ const d = __NDB.get(id)
1219
+ if (d) out.push(d)
1220
+ }
1221
+ return out
1222
+ }
1223
+ const prefix = typeof selector === 'string' ? selector : ''
1224
+ for (const [key, doc] of docs) {
1225
+ if (key === '__root__') continue
1226
+ // 关键:folder 一律只返回根级(无论调用方传 'folder/' 还是不带参数)。
1227
+ // UI 的 _R() 会把 children 中 id 命中「所有 folder id」的项当重复 splice 删除,
1228
+ // 一旦子目录也被单独返回,children 就会被就地破坏,反复展开/折叠后出现重叠。
1229
+ if (key.startsWith('folder/')) {
1230
+ if (dirOf(key.slice('folder/'.length)) !== '') continue
1231
+ }
1232
+ if (!prefix || key.startsWith(prefix)) out.push(doc)
1233
+ }
1234
+ // 根级顺序按用户拖拽记录(order.txt 的根段)重排
1235
+ if (out.length && (!prefix || prefix === 'folder/')) {
1236
+ const seq = new Map()
1237
+ ;(TREE_ORDER[''] || []).forEach((x, idx) => seq.set('folder/' + String(x).replace(/^folder\//, ''), idx))
1238
+ if (seq.size) {
1239
+ out.sort((a, b) => {
1240
+ const ia = seq.has(a._id) ? seq.get(a._id) : 1e9
1241
+ const ib = seq.has(b._id) ? seq.get(b._id) : 1e9
1242
+ return ia === ib ? 0 : ia - ib
1243
+ })
1244
+ }
1245
+ }
1246
+ return out
1247
+ },
1248
+
1249
+ postAttachment(id, data, contentType) {
1250
+ try {
1251
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data)
1252
+ const ext = (() => {
1253
+ const map = {
1254
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif',
1255
+ 'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/bmp': 'bmp',
1256
+ }
1257
+ return map[contentType] || 'bin'
1258
+ })()
1259
+ const digest = id.startsWith('attachment/') ? id.slice('attachment/'.length) : id
1260
+ const file = path.join(ASSETS, digest + '.' + ext)
1261
+ mk(ASSETS)
1262
+ fs.writeFileSync(file, buf)
1263
+ // 记一张映射表,getAttachment 用
1264
+ const idx = rj(path.join(META, 'assets.json'), {})
1265
+ idx[digest] = digest + '.' + ext
1266
+ wj(path.join(META, 'assets.json'), idx)
1267
+ return { ok: true, id: 'attachment/' + digest }
1268
+ } catch (e) {
1269
+ return { error: true, message: String(e && e.message) }
1270
+ }
1271
+ },
1272
+
1273
+ getAttachment(id) {
1274
+ try {
1275
+ const digest = String(id || '').startsWith('attachment/') ? id.slice('attachment/'.length) : id
1276
+ const idx = rj(path.join(META, 'assets.json'), {})
1277
+ let file = idx[digest]
1278
+ if (!file) {
1279
+ const found = fs.readdirSync(ASSETS).find((x) => x.startsWith(digest))
1280
+ if (!found) return null
1281
+ file = found
1282
+ }
1283
+ return fs.readFileSync(path.join(ASSETS, file))
1284
+ } catch (e) {
1285
+ return null
1286
+ }
1287
+ },
1288
+
1289
+ replicateStateFromCloud() {
1290
+ return Promise.resolve({ ok: true })
1291
+ },
1292
+
1293
+ promises: {},
1294
+ }
1295
+
1296
+ __NDB.promises = {
1297
+ get: async (id) => __NDB.get(id),
1298
+ put: async (doc) => __NDB.put(doc),
1299
+ remove: async (doc) => __NDB.remove(doc),
1300
+ allDocs: async (sel) => __NDB.allDocs(sel),
1301
+ }
1302
+
1303
+ // ---------------- __NSTORE(对应 dbStorage)----------------
1304
+ const store = () => rj(STOREJSON, {})
1305
+ const __NSTORE = {
1306
+ getItem(k) {
1307
+ const s = store()
1308
+ return Object.prototype.hasOwnProperty.call(s, k) ? s[k] : null
1309
+ },
1310
+ setItem(k, v) {
1311
+ const s = store()
1312
+ s[k] = v
1313
+ wj(STOREJSON, s)
1314
+ return true
1315
+ },
1316
+ removeItem(k) {
1317
+ const s = store()
1318
+ delete s[k]
1319
+ wj(STOREJSON, s)
1320
+ return true
1321
+ },
1322
+ }
1323
+
1324
+ // 挂到渲染进程 window(ZTools 的插件 preload 与页面共享 window)
1325
+ win.__NDB = __NDB
1326
+ win.__NSTORE = __NSTORE
1327
+ win.__ZNOTES_WS = WS
1328
+
1329
+ // 额外的诊断接口,方便排查
1330
+ win.__ZNOTES = {
1331
+ ws: WS,
1332
+ rescan: () => { invalidate(); return scan().size },
1333
+ // 把指定笔记收拢到根目录(bundle 把笔记拖到根时只改内存 state.data
1334
+ // 和 rootsort,不调用 put,所以只能在这里按文件名兜底移动)
1335
+ pullToRoot: function (ids) {
1336
+ if (!Array.isArray(ids)) return false
1337
+ const byName = new Map()
1338
+ const docs = scan()
1339
+ docs.forEach(function (d) {
1340
+ if (d && d._id && d._id.indexOf('note/') === 0) {
1341
+ const b = path.basename(d._id)
1342
+ if (!byName.has(b)) byName.set(b, [])
1343
+ byName.get(b).push(d._id.slice('note/'.length))
1344
+ }
1345
+ })
1346
+ let changed = false
1347
+ for (let i = 0; i < ids.length; i++) {
1348
+ const id = ids[i]
1349
+ if (!id || String(id).indexOf('note/') !== 0) continue
1350
+ const base = path.basename(String(id))
1351
+ const cands = byName.get(base) || []
1352
+ for (let c = 0; c < cands.length; c++) {
1353
+ const rel = cands[c]
1354
+ if (rel.indexOf('/') < 0) continue // 已经在根
1355
+ const srcAbs = path.join(WS, rel)
1356
+ const dstAbs = path.join(WS, base)
1357
+ try {
1358
+ if (fs.existsSync(srcAbs) && !fs.existsSync(dstAbs)) {
1359
+ fs.renameSync(srcAbs, dstAbs)
1360
+ changed = true
1361
+ }
1362
+ } catch (e) {}
1363
+ }
1364
+ }
1365
+ if (changed) { invalidate(); notifyFsChange('') }
1366
+ return changed
1367
+ },
1368
+ onFsChange: function (fn) { try { fsListeners.push(fn); startWatch() } catch (e) {} },
1369
+ toMarkdown: nodesToMd,
1370
+ saveTreeOrder: saveTreeOrder,
1371
+ readTreeOrder: readTreeOrder,
1372
+ reconcileTree: reconcileTree,
1373
+ toNodes: mdToNodes,
1374
+ onboardGet: onboardGet,
1375
+ onboardSet: onboardSet,
1376
+ metaDir: META,
1377
+ // 扫描是否因为目录/笔记过多而截断(界面据此提示用户换更具体的目录)
1378
+ truncated: () => LAST_TRUNCATED,
1379
+ // 只读状态:界面按 rel 询问,内部换算成 note/<rel> 的哈希
1380
+ readNoteState: function (rel) {
1381
+ try {
1382
+ const st = readNoteState()
1383
+ const h = idHash('note/' + String(rel))
1384
+ return { locked: st.readonlyHashes.has(h), near: st.nearHashes.has(h) }
1385
+ } catch (e) {
1386
+ return { locked: false, near: false }
1387
+ }
1388
+ },
1389
+ setNoteLocked: function (rel, on) {
1390
+ try {
1391
+ setNoteFlag('note/' + String(rel), 'readonly', !!on)
1392
+ return { ok: true }
1393
+ } catch (e) {
1394
+ return { ok: false, message: String(e && e.message ? e.message : e) }
1395
+ }
1396
+ },
1397
+ setNoteNear: function (rel, on) {
1398
+ try {
1399
+ setNoteFlag('note/' + String(rel), 'near', !!on)
1400
+ return { ok: true }
1401
+ } catch (e) {
1402
+ return { ok: false, message: String(e && e.message ? e.message : e) }
1403
+ }
1404
+ },
1405
+ }
1406
+ console.log('[znotes] workspace =', WS)
1407
+ }