@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.js ADDED
@@ -0,0 +1,1615 @@
1
+ /**
2
+ * CMark V1.1.0 —— preload 入口
3
+ *
4
+ * 职责边界:
5
+ * 渲染层(React)只关心界面,不碰文件系统;
6
+ * 一切磁盘读写、git 操作、导出、附件都在这里。
7
+ *
8
+ * 挂到 window 上的三块能力:
9
+ * window.__ZNOTES 笔记数据层(笔记/目录/排序/只读/附件),实现见 preload/ndb.js
10
+ * window.__znotesGit 版本控制(init/adopt/commit/push/pull/sync/日志),见 preload/git-service.js
11
+ * window.services 导出、剪贴板、图片、文件杂项,见 preload/services.js
12
+ *
13
+ * 另加 window.__CMARK 作为本版本新增的控制面(配置读写、选目录、重扫)。
14
+ */
15
+ const path = require('path')
16
+ const fs = require('fs')
17
+ const os = require('os')
18
+
19
+ const CFG_PATH = path.join(os.homedir(), '.cmark-config.json')
20
+ const LEGACY_CFG = path.join(os.homedir(), '.znotes-config.json') // V1.0.0 的配置,首次启动时迁移
21
+
22
+ function readJSON(p) {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(p, 'utf8'))
25
+ } catch (e) {
26
+ return null
27
+ }
28
+ }
29
+
30
+ function writeJSON(p, obj) {
31
+ try {
32
+ fs.mkdirSync(path.dirname(p), { recursive: true })
33
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf8')
34
+ return true
35
+ } catch (e) {
36
+ console.error('[cmark] 写配置失败', e)
37
+ return false
38
+ }
39
+ }
40
+
41
+ /* ---------------- 配置:优先新文件,没有就从 V1.0.0 的配置迁移 ---------------- */
42
+ function loadConfig() {
43
+ const cur = readJSON(CFG_PATH)
44
+ if (cur && cur.workspace) return cur
45
+
46
+ const legacy = readJSON(LEGACY_CFG)
47
+ if (legacy && legacy.workspace) {
48
+ const migrated = {
49
+ workspace: legacy.workspace,
50
+ initialized: legacy.initialized !== false,
51
+ autoSync: legacy.autoSync || null,
52
+ commit: legacy.commit || null,
53
+ }
54
+ writeJSON(CFG_PATH, migrated)
55
+ return migrated
56
+ }
57
+ return { workspace: '', initialized: false }
58
+ }
59
+
60
+ function saveConfig(patch) {
61
+ const next = Object.assign({}, loadConfig(), patch || {})
62
+ writeJSON(CFG_PATH, next)
63
+ return next
64
+ }
65
+
66
+ let config = loadConfig()
67
+
68
+ // 工作区未设置或不存在时,退到 ~/Documents/CMark,不弹任何框
69
+ let workspace = config.workspace
70
+ if (!workspace || !fs.existsSync(workspace)) {
71
+ const docs = path.join(os.homedir(), 'Documents')
72
+ workspace = path.join(fs.existsSync(docs) ? docs : os.homedir(), 'CMark')
73
+ try {
74
+ fs.mkdirSync(workspace, { recursive: true })
75
+ } catch (e) {}
76
+ }
77
+
78
+ /* ---------------- 挂载数据层 ---------------- */
79
+ // ndb.js 的第二个参数是"能读到 workspace 字段的 JSON 路径"
80
+ try {
81
+ require('./preload/ndb.js')(window, CFG_PATH)
82
+ } catch (e) {
83
+ console.error('[cmark] ndb 挂载失败', e)
84
+ }
85
+
86
+ /* ---------------- 挂载版本控制 ---------------- */
87
+ try {
88
+ require('./preload/git-service.js')(window, workspace)
89
+ } catch (e) {
90
+ console.error('[cmark] git 服务挂载失败', e)
91
+ }
92
+
93
+ /* ---------------- 挂载导出/附件/剪贴板 ---------------- */
94
+ try {
95
+ window.services = require('./preload/services.js')(window)
96
+ } catch (e) {
97
+ console.error('[cmark] services 挂载失败', e)
98
+ window.services = {}
99
+ }
100
+
101
+ /* ---------------- 挂载 V1.0.0 自研面板(原封不动搬过来,不再用 React 重写) ---------------- */
102
+ // git-panel.js / settings-panel.js / rename-note.js 都是本项目自己写的独立 DOM 模块:
103
+ // 自带 CSS、自带渲染、只依赖 __znotesGit 与少量宿主钩子。
104
+ // 直接 require 安装,才能做到与 V1.0.0 像素级一致。
105
+ try {
106
+ /** React 侧通过 window.__cmarkHost() 提供当前笔记与若干动作 */
107
+ const host = () => (window.__cmarkHost && window.__cmarkHost()) || {}
108
+
109
+ window.__znotesFindInstance = function () {
110
+ const st = host()
111
+ return { state: { workNote: st.currentRel ? { _id: 'note/' + st.currentRel } : null } }
112
+ }
113
+ window.__znotesFlushSave = function () {
114
+ const st = host()
115
+ if (st.flushSave) st.flushSave()
116
+ }
117
+ window.__znotesReloadNote = function (rel) {
118
+ const st = host()
119
+ if (!st.reloadNote) return
120
+ // git 面板传进来的是「仓库相对路径」,通常会带一层笔记目录前缀(例如 Notes/归档/a.md);
121
+ // 而笔记侧一律使用「笔记目录内相对路径」(归档/a.md)。不剥掉这一层,
122
+ // readNote 会拼出 Notes/Notes/... 而报 ENOENT,看上去就像文件被删了。
123
+ let r = String(rel == null ? '' : rel).replace(/\\/g, '/')
124
+ let sc = ''
125
+ try { sc = (window.__znotesGit && window.__znotesGit.scopeRel) || '' } catch (e) {}
126
+ if (sc) {
127
+ if (r === sc) r = ''
128
+ else if (r.indexOf(sc + '/') === 0) r = r.slice(sc.length + 1)
129
+ }
130
+ st.reloadNote(r)
131
+ }
132
+ window.__ZNOTES_REFRESH = function () {
133
+ const st = host()
134
+ if (st.refreshTree) st.refreshTree()
135
+ }
136
+ window.__znotesPickDir = function () {
137
+ const st = host()
138
+ if (st.pickDir) st.pickDir()
139
+ }
140
+ // 右键「在资源管理器中打开」:必须走 Electron shell,ztools 会隐藏插件窗口
141
+ window.__znotesRevealCtx = function () {
142
+ try {
143
+ const { shell } = require('electron')
144
+ if (shell && shell.openPath) {
145
+ const r = shell.openPath(workspace)
146
+ if (r && typeof r.catch === 'function') r.catch(function () {})
147
+ }
148
+ } catch (e) {}
149
+ }
150
+
151
+ require('./preload/git-panel.js')(window, workspace)
152
+ } catch (e) {
153
+ console.error('[cmark] git-panel 挂载失败', e)
154
+ }
155
+
156
+ try {
157
+ require('./preload/settings-panel.js')(window)
158
+ } catch (e) {
159
+ console.error('[cmark] settings-panel 挂载失败', e)
160
+ }
161
+
162
+ try {
163
+ require('./preload/rename-note.js')(window, window.__znotesFindInstance)
164
+ } catch (e) {
165
+ console.error('[cmark] rename-note 挂载失败', e)
166
+ }
167
+
168
+ /* ---------------- 控制面 ---------------- */
169
+ window.__CMARK = {
170
+
171
+ // ================= 导出(从 V1.0.0 window.services 原样移植) =================
172
+
173
+ /** 写笔记文件:dir 为空时弹保存框,带附件时按文件夹写 */
174
+ _writeNoteFile(dir, name, content, attachments, ext, withKatex) {
175
+ // 笔记标题常带冒号/星号等非法文件名字符("10:30 会议"),必须过滤
176
+ name = String(name || '').replace(/[\\/:*?"<>|]/g, '').trim() || String(Date.now())
177
+ const list = Array.isArray(attachments) ? attachments : []
178
+ let target = null
179
+
180
+ if (list.length > 0) {
181
+ // 带附件 = 便携目录模式:先确定目录(不是文件)
182
+ if (!dir) {
183
+ const r = window.ztools.showOpenDialog({
184
+ title: '选择导出目录',
185
+ properties: ['openDirectory', 'createDirectory'],
186
+ })
187
+ dir = Array.isArray(r) ? r[0] : r
188
+ if (!dir) return { ok: false, canceled: true }
189
+ }
190
+ try {
191
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
192
+ } catch (e) {
193
+ return { ok: false, message: '无法创建目录 ' + dir + ':' + e.message }
194
+ }
195
+ target = path.join(dir, name + '.' + ext)
196
+ for (const f of list) {
197
+ try {
198
+ fs.copyFileSync(f, path.join(dir, path.basename(f)))
199
+ } catch (e) {
200
+ console.warn('[export] 附件拷贝失败', f, e.message)
201
+ }
202
+ }
203
+ } else if (dir) {
204
+ try {
205
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
206
+ } catch (e) {}
207
+ target = path.join(dir, name + '.' + ext)
208
+ } else {
209
+ const dft = path.join(window.ztools.getPath('downloads') || '', name + '.' + ext)
210
+ target = window.ztools.showSaveDialog({ defaultPath: dft, filters: [{ name: ext, extensions: [ext] }] })
211
+ if (!target) return { ok: false, canceled: true }
212
+ }
213
+
214
+ try {
215
+ fs.writeFileSync(target, content, 'utf8')
216
+ } catch (e) {
217
+ return { ok: false, message: '写入失败:' + e.message }
218
+ }
219
+ return { ok: true, path: target }
220
+ },
221
+
222
+ writeMarkdownFile(dir, name, content, attachments) {
223
+ return this._writeNoteFile(dir, name, content, attachments || [], 'md', false)
224
+ },
225
+
226
+ writeHtmlFile(dir, name, html, attachments, withKatex, selfContained) {
227
+ let css = ''
228
+ // 自包含快照(每个元素都内联了 computed style)不能再套 github-markdown.css,
229
+ // 那套样式会覆盖内联结果,导出的东西又和编辑区不一样了
230
+ if (!selfContained) {
231
+ try {
232
+ const cssDir = fs.existsSync(path.join(__dirname, 'export-file'))
233
+ ? path.join(__dirname, 'export-file')
234
+ : path.join(__dirname, 'dist', 'export-file')
235
+ css = fs.readFileSync(path.join(cssDir, 'github-markdown.css'), 'utf-8').replace(/^\s*[\r\n]/gm, '')
236
+ } catch (e) {}
237
+ }
238
+ const katexLink = withKatex ? '<link rel="stylesheet" href="./katex.min.css">' : ''
239
+ const full = '<!doctype html>\n<html>\n<head>\n<meta charset="utf-8"/>\n<title>' + (name || '') + '</title>\n' +
240
+ katexLink + '\n<style>\nhtml, body { margin: 0; padding: 0; background-color: #ffffff; }\n' +
241
+ '.markdown-body { box-sizing: border-box; min-width: 200px; max-width: 980px; margin: 0 auto; padding: 45px; }\n' +
242
+ '@media (max-width: 767px) { .markdown-body { padding: 15px; } }\n' + css + '\n</style>\n</head>\n<body>\n' +
243
+ '<article class="markdown-body">\n' + String(html || '').trim() + '\n</article>\n</body>\n</html>'
244
+ const extra = Array.isArray(attachments) ? attachments.slice() : []
245
+ if (withKatex) {
246
+ try {
247
+ extra.push(path.join(__dirname, 'katex.min.css'))
248
+ } catch (e) {}
249
+ }
250
+ return this._writeNoteFile(dir, name, full, extra, 'html', withKatex)
251
+ },
252
+
253
+ /** 用隐藏窗口渲染后截图/打印成 PDF(V1.0.0 的做法) */
254
+ exportFile(type, html, css, cb, outDir) {
255
+ if (!window.ztools || typeof window.ztools.createBrowserWindow !== 'function') {
256
+ cb(new Error('当前宿主没有 createBrowserWindow,无法导出 ' + type))
257
+ return
258
+ }
259
+ // 必须 let 先声明:回调可能在赋值前同步触发,const 会 TDZ 抛错让 cb 永远不回调
260
+ let win = null
261
+ let settled = false
262
+ const done = (err, out) => {
263
+ if (settled) return
264
+ settled = true
265
+ cb(err, out)
266
+ }
267
+ // 看门狗:createBrowserWindow 回调不触发时不能让按钮永远转圈
268
+ const watchdog = setTimeout(() => {
269
+ try {
270
+ if (win) win.destroy()
271
+ } catch (e) {}
272
+ done(new Error('导出超时(渲染窗口未响应)'))
273
+ }, 60000)
274
+ win = window.ztools.createBrowserWindow(
275
+ 'export-file/index.html',
276
+ { width: 980, height: 600, show: false },
277
+ async () => {
278
+ try {
279
+ let buf
280
+ await new Promise((r) => setTimeout(r, 50))
281
+ if (css) await win.webContents.insertCSS(css)
282
+ await win.webContents.executeJavaScript(
283
+ 'document.querySelector(".markdown-body").innerHTML = ' + JSON.stringify(String(html || ''))
284
+ )
285
+ await win.webContents.executeJavaScript('window.waitAllImageLoaded()')
286
+ await new Promise((r) => setTimeout(r, 50))
287
+ if (type === 'image') {
288
+ const h = await win.webContents.executeJavaScript('document.body.scrollHeight')
289
+ win.setContentSize(980, h + 25)
290
+ await new Promise((r) => setTimeout(r, 50))
291
+ buf = await win.webContents.capturePage()
292
+ if (buf.toPNG) buf = buf.toPNG()
293
+ } else {
294
+ buf = await win.webContents.printToPDF({
295
+ marginsType: 0,
296
+ printBackground: true,
297
+ printSelectionOnly: false,
298
+ landscape: false,
299
+ pageSize: 'A4',
300
+ scaleFactor: 100,
301
+ })
302
+ }
303
+ clearTimeout(watchdog)
304
+ try {
305
+ win.destroy()
306
+ } catch (e2) {}
307
+ if (!outDir) outDir = window.ztools.getPath('temp')
308
+ const out = path.join(outDir, 'cmark_markdown_render_' + Date.now() + (type === 'image' ? '.png' : '.pdf'))
309
+ fs.writeFileSync(out, buf)
310
+ done(null, out)
311
+ } catch (e) {
312
+ clearTimeout(watchdog)
313
+ try {
314
+ win.destroy()
315
+ } catch (e2) {}
316
+ done(e)
317
+ }
318
+ }
319
+ )
320
+ },
321
+
322
+ /** 把临时导出文件挪到用户选的位置 */
323
+ dialogSaveExportFile(tmpPath, name, kind) {
324
+ name = String(name || '').replace(/[/\\]/g, '').trim() || Date.now().toString()
325
+ const ext = path.extname(tmpPath)
326
+ const dft = path.join(window.ztools.getPath('downloads') || '', name + ext)
327
+ const target = window.ztools.showSaveDialog({
328
+ defaultPath: dft,
329
+ filters: [{ name: kind || ext.replace('.', ''), extensions: [kind === 'image' ? 'png' : kind || ext.replace('.', '')] }],
330
+ })
331
+ if (!target) return
332
+ const reveal = () => {
333
+ setTimeout(() => {
334
+ try {
335
+ window.ztools.hideMainWindow(false)
336
+ window.ztools.shellShowItemInFolder(target)
337
+ } catch (e2) {}
338
+ }, 1000)
339
+ }
340
+ try {
341
+ fs.renameSync(tmpPath, target)
342
+ } catch (e) {
343
+ // 跨盘 rename 必失败(temp 在 C 盘、导出到 D 盘是常态),改用拷贝
344
+ try {
345
+ fs.copyFileSync(tmpPath, target)
346
+ try {
347
+ fs.unlinkSync(tmpPath)
348
+ } catch (e2) {}
349
+ } catch (e3) {
350
+ window.ztools.showNotification('导出保存失败:' + e3.message)
351
+ return
352
+ }
353
+ }
354
+ reveal()
355
+ },
356
+ configPath: CFG_PATH,
357
+
358
+ getConfig() {
359
+ config = loadConfig()
360
+ return Object.assign({}, config, {
361
+ workspace,
362
+ workspaceExists: fs.existsSync(workspace),
363
+ metaDir: (window.__ZNOTES && window.__ZNOTES.metaDir) || null,
364
+ })
365
+ },
366
+
367
+ /** 更换工作区:只校验可写,不搬任何文件 */
368
+ pickWorkspace() {
369
+ let dir
370
+ try {
371
+ dir = window.ztools.showOpenDialog({
372
+ title: '选择 Markdown 笔记目录',
373
+ defaultPath: workspace,
374
+ properties: ['openDirectory', 'createDirectory'],
375
+ })
376
+ } catch (e) {
377
+ return { ok: false, message: '打开目录选择器失败:' + e.message }
378
+ }
379
+ if (!dir) return { ok: false, canceled: true }
380
+ if (Array.isArray(dir)) dir = dir[0]
381
+ try {
382
+ const probe = path.join(dir, '.cmark-write-test')
383
+ fs.writeFileSync(probe, 'ok')
384
+ fs.unlinkSync(probe)
385
+ } catch (e) {
386
+ return { ok: false, message: '这个目录不可写,请换一个' }
387
+ }
388
+ workspace = dir
389
+ config = saveConfig({ workspace: dir, initialized: true })
390
+ // 换目录后需要让 ndb / git 重新绑定:最稳的做法是提示重启插件
391
+ return { ok: true, workspace: dir, needReload: true }
392
+ },
393
+
394
+ /**
395
+ * 把工作区里所有 .md 读进内存,供正文搜索使用。
396
+ * 对应 V1.0.0 的 window.NOTES_DIC:搜索要能匹配正文,就必须先有内容。
397
+ * 返回 [{ rel, text }],rel 是相对工作区的路径。
398
+ */
399
+ loadAllNotes() {
400
+ const out = []
401
+ const SKIP = new Set(['.git', '.cmark', 'node_modules', '.obsidian', 'assets'])
402
+ const walk = (dirAbs, prefix, depth) => {
403
+ if (depth > 12 || out.length > 8000) return
404
+ let list
405
+ try {
406
+ list = fs.readdirSync(dirAbs, { withFileTypes: true })
407
+ } catch (e) {
408
+ return
409
+ }
410
+ for (const ent of list) {
411
+ const name = ent.name
412
+ if (name.startsWith('.') || SKIP.has(name)) continue
413
+ const abs = path.join(dirAbs, name)
414
+ const rel = prefix ? prefix + '/' + name : name
415
+ if (ent.isDirectory()) {
416
+ walk(abs, rel, depth + 1)
417
+ } else if (/\.md$/i.test(name)) {
418
+ try {
419
+ out.push({ rel, text: fs.readFileSync(abs, 'utf8') })
420
+ } catch (e) {}
421
+ }
422
+ }
423
+ }
424
+ walk(workspace, '', 0)
425
+ return out
426
+ },
427
+
428
+ /**
429
+ * 在系统默认浏览器里打开笔记。
430
+ *
431
+ * 不能直接把 .md 的 file:// 交给 openExternal —— Electron 在 Windows 上通常
432
+ * 转给文件关联程序(可能是编辑器而不是浏览器)。这里改成写一个临时 HTML,
433
+ * 用 file:// 打开它,系统一定会用浏览器。
434
+ */
435
+ /**
436
+ * 用系统默认程序打开真实的笔记文件(用户浏览器装了 Markdown 渲染插件)。
437
+ * 走 shell.openExternal + file:// 而不是 shellOpenPath:后者会把插件窗口藏到后台。
438
+ */
439
+ /** 取 katex 样式文本:导出时公式排版全靠它,缺了上标会乱 */
440
+ getKatexCss() {
441
+ try {
442
+ const dirs = [__dirname, path.join(__dirname, 'dist')]
443
+ for (const d of dirs) {
444
+ const f = path.join(d, 'katex.min.css')
445
+ if (fs.existsSync(f)) return fs.readFileSync(f, 'utf-8')
446
+ }
447
+ return ''
448
+ } catch (e) {
449
+ return ''
450
+ }
451
+ },
452
+
453
+ openNoteInBrowser(rel) {
454
+ try {
455
+ const abs = path.resolve(workspace, String(rel || ''))
456
+ if (!fs.existsSync(abs)) return { ok: false, message: '文件不存在:' + rel }
457
+ const url = 'file:///' + abs.replace(/\\/g, '/')
458
+ if (window.ztools && window.ztools.shellOpenExternal) window.ztools.shellOpenExternal(url)
459
+ else require('electron').shell.openExternal(url)
460
+ return { ok: true, path: abs, url }
461
+ } catch (e) {
462
+ return { ok: false, message: e.message }
463
+ }
464
+ },
465
+
466
+ openHtmlInBrowser(rel, html) {
467
+ try {
468
+ const os = require('os')
469
+ const crypto = require('crypto')
470
+ const safe = String(rel || 'note').replace(/[\\/:*?"<>|]/g, '_').replace(/\.md$/i, '')
471
+ // 固定文件名 = 按 rel 生成哈希,同一篇笔记反复打开只覆盖同一个文件,
472
+ // 不再每次留一个新时间戳文件把临时目录撑满
473
+ const tag = crypto.createHash('md5').update(String(rel || '')).digest('hex').slice(0, 8)
474
+ const dir = path.join(os.tmpdir(), 'cmark-preview')
475
+ try {
476
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
477
+ } catch (e) {}
478
+ const tmp = path.join(dir, `${safe}-${tag}.html`)
479
+ fs.writeFileSync(tmp, html, 'utf8')
480
+ const url = 'file:///' + tmp.replace(/\\/g, '/')
481
+ require('electron').shell.openExternal(url)
482
+ return { ok: true, path: tmp }
483
+ } catch (e) {
484
+ return { ok: false, message: e.message }
485
+ }
486
+ },
487
+
488
+ /** 目录里已有多少 .md(用于接入前的预检提示) */
489
+ countMarkdown(dir) {
490
+ let n = 0
491
+ let folders = 0
492
+ const walk = (d, depth) => {
493
+ if (depth > 8 || n > 5000) return
494
+ let list
495
+ try {
496
+ list = fs.readdirSync(d, { withFileTypes: true })
497
+ } catch (e) {
498
+ return
499
+ }
500
+ for (const it of list) {
501
+ if (it.name.startsWith('.')) continue
502
+ if (it.isDirectory()) {
503
+ folders++
504
+ walk(path.join(d, it.name), depth + 1)
505
+ } else if (/\.(md|markdown)$/i.test(it.name)) {
506
+ n++
507
+ }
508
+ }
509
+ }
510
+ walk(dir || workspace, 0)
511
+ return { markdown: n, folders }
512
+ },
513
+
514
+ rescan() {
515
+ try {
516
+ if (window.__ZNOTES && window.__ZNOTES.rescan) return window.__ZNOTES.rescan()
517
+ } catch (e) {
518
+ return { ok: false, message: e.message }
519
+ }
520
+ },
521
+
522
+ /** 工作区变化监听:渲染层订阅后自己决定要不要重扫 */
523
+ onWorkspaceChange(fn) {
524
+ try {
525
+ if (window.__ZNOTES && window.__ZNOTES.onFsChange) window.__ZNOTES.onFsChange(fn)
526
+ } catch (e) {}
527
+ },
528
+
529
+ /**
530
+ * 在资源管理器里打开笔记目录。
531
+ * 必须用 Electron 的 shell:ztools.shellOpenPath 会让 ZTools 隐藏主窗口,
532
+ * 而 ztools.showMainWindow() 唤醒的是搜索框、不是插件。
533
+ */
534
+ shellShowWorkspace() {
535
+ try {
536
+ const { shell } = require('electron')
537
+ if (shell && shell.openPath) {
538
+ const r = shell.openPath(workspace)
539
+ if (r && typeof r.catch === 'function') r.catch(function () {})
540
+ return { ok: true }
541
+ }
542
+ } catch (e) {}
543
+ try {
544
+ window.ztools.shellOpenPath(workspace)
545
+ return { ok: true }
546
+ } catch (e) {
547
+ return { ok: false, message: String(e && e.message) }
548
+ }
549
+ },
550
+
551
+ /* ---------------- 笔记文件级读写(纯文本语义,保真) ---------------- */
552
+
553
+ /** 相对路径 → 绝对路径,并挡住越界(..) */
554
+ /** 向上找 .git 定位仓库根:.cmark 在仓库根下,可能比笔记目录高一级 */
555
+ _repoRoot() {
556
+ let cur = workspace
557
+ for (let i = 0; i < 5; i++) {
558
+ try {
559
+ if (fs.existsSync(path.join(cur, '.git'))) return cur
560
+ } catch (e) {}
561
+ const up = path.dirname(cur)
562
+ if (up === cur) break
563
+ cur = up
564
+ }
565
+ return workspace
566
+ },
567
+
568
+ /** 重算文本里所有图片相对路径:旧目录 → 新目录 */
569
+ _rewriteRelInText(txt, oldDir, newDir) {
570
+ if (!txt || oldDir === newDir) return txt
571
+ const root = this._repoRoot()
572
+ const RE = /(!\[[^\]]*\]\()([^)\s]+)((?:\s+"[^"]*")?\))/g
573
+ // 代码块和行内代码里的内容只是示例,不能当成真实引用去改。
574
+ // 按这些片段切分,只处理偶数段(普通正文)。
575
+ const segs = String(txt).split(
576
+ /(```[\s\S]*?```|~~~[\s\S]*?~~~|\$\$[\s\S]*?\$\$|`+[^`]*`+)/g,
577
+ )
578
+ return segs
579
+ .map((seg, idx) => (idx % 2 === 1 ? seg : this._rewriteSeg(seg, oldDir, newDir, root, RE)))
580
+ .join('')
581
+ },
582
+
583
+ /** 单段普通文本内的路径重算(被 _rewriteRelInText 和外部复用) */
584
+ _rewriteSeg(seg, oldDir, newDir, root, RE) {
585
+ return String(seg).replace(RE, (whole, pre, url, post) => {
586
+ if (/^(https?:|data:|file:|attachment:|#|mailto:)/i.test(url)) return whole
587
+ let abs
588
+ try {
589
+ abs = path.resolve(oldDir, url)
590
+ } catch (e) {
591
+ return whole
592
+ }
593
+ if (abs !== root && !abs.startsWith(root + path.sep)) return whole
594
+ try {
595
+ if (!fs.existsSync(abs)) return whole
596
+ } catch (e) {
597
+ return whole
598
+ }
599
+ let rel = path.relative(newDir, abs).split(path.sep).join('/')
600
+ if (rel && !rel.startsWith('.')) rel = './' + rel
601
+ return pre + rel + post
602
+ })
603
+ },
604
+
605
+ /** 单个 md 移动后重写它自己的图片路径 */
606
+ _rewriteImagePaths(absOld, absNew) {
607
+ try {
608
+ if (absOld === absNew) return
609
+ const oldDir = path.dirname(absOld)
610
+ const newDir = path.dirname(absNew)
611
+ if (oldDir === newDir) return
612
+ let txt
613
+ try {
614
+ txt = fs.readFileSync(absNew, 'utf8')
615
+ } catch (e) {
616
+ return
617
+ }
618
+ const next = this._rewriteRelInText(txt, oldDir, newDir)
619
+ if (next !== txt) {
620
+ // 原子写:先写临时文件再 rename。直接覆盖的话,移动一批笔记时
621
+ // 中途崩溃会留下截断的正文,而且没有任何回滚手段。
622
+ const tmp = absNew + '.tmp-' + Date.now() + '-' + Math.random().toString(16).slice(2, 8)
623
+ fs.writeFileSync(tmp, next, 'utf8')
624
+ fs.renameSync(tmp, absNew)
625
+ }
626
+ } catch (e) {}
627
+ },
628
+
629
+ /** 目录整体移动:逐个 md 重写 */
630
+ _rewriteDirImagePaths(oldDirAbs, newDirAbs) {
631
+ const walk = (dirNew, dirOld) => {
632
+ let list = []
633
+ try {
634
+ list = fs.readdirSync(dirNew, { withFileTypes: true })
635
+ } catch (e) {
636
+ return
637
+ }
638
+ for (const ent of list) {
639
+ const nAbs = path.join(dirNew, ent.name)
640
+ const oAbs = path.join(dirOld, ent.name)
641
+ if (ent.isDirectory()) {
642
+ walk(nAbs, oAbs)
643
+ continue
644
+ }
645
+ if (!/\.(md|markdown)$/i.test(ent.name)) continue
646
+ this._rewriteImagePaths(oAbs, nAbs)
647
+ }
648
+ }
649
+ walk(newDirAbs, oldDirAbs)
650
+ },
651
+
652
+ absOf(rel) {
653
+ const abs = path.resolve(workspace, rel)
654
+ if (abs !== workspace && !abs.startsWith(workspace + path.sep)) {
655
+ throw new Error('路径越界:' + rel)
656
+ }
657
+ return abs
658
+ },
659
+
660
+ readNote(rel) {
661
+ try {
662
+ return fs.readFileSync(this.absOf(rel), 'utf8')
663
+ } catch (e) {
664
+ return ''
665
+ }
666
+ },
667
+
668
+ /**
669
+ * 带状态的读取:读失败必须能被上层区分出来。
670
+ * 旧 readNote 把任何异常都变成空串,界面会把「读失败」当成「空笔记」,
671
+ * 用户一敲字就把整篇原正文覆盖掉——这是最危险的一条路径。
672
+ */
673
+ readNoteEx(rel) {
674
+ try {
675
+ const abs = this.absOf(rel)
676
+ const st = fs.statSync(abs)
677
+ const text = fs.readFileSync(abs, 'utf8')
678
+ return { ok: true, text, mtime: st.mtimeMs, size: st.size }
679
+ } catch (e) {
680
+ return { ok: false, message: e && e.message ? e.message : String(e), code: e && e.code }
681
+ }
682
+ },
683
+
684
+ /** 取文件当前 mtime/size,用来判断磁盘内容是否被外部改过 */
685
+ statNote(rel) {
686
+ try {
687
+ const st = fs.statSync(this.absOf(rel))
688
+ return { ok: true, mtime: st.mtimeMs, size: st.size }
689
+ } catch (e) {
690
+ return { ok: false, code: e && e.code }
691
+ }
692
+ },
693
+
694
+ /** 原子写:临时文件 + rename,避免半截文件被 git 提交 */
695
+ writeNote(rel, text) {
696
+ const abs = this.absOf(rel)
697
+ try {
698
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
699
+ const tmp = abs + '.tmp-' + process.pid + '-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
700
+ // 落盘前最后确认一次目标还在:exists() 与 write 之间可能被外部删除
701
+ // (只对已存在的文件做,新建笔记走 createNote 不经过这里)
702
+ if (fs.existsSync(abs) && !fs.statSync(abs).isFile()) {
703
+ throw new Error('目标不是普通文件')
704
+ }
705
+ fs.writeFileSync(tmp, text, 'utf8')
706
+ // Windows 上目标文件可能被杀毒/索引器短暂占用,rename 会抛 EPERM/EBUSY,重试几次
707
+ let lastErr = null
708
+ for (let attempt = 0; attempt < 4; attempt++) {
709
+ try {
710
+ fs.renameSync(tmp, abs)
711
+ lastErr = null
712
+ break
713
+ } catch (e) {
714
+ lastErr = e
715
+ const busy = e && (e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES')
716
+ if (!busy) break
717
+ const until = Date.now() + 40 * (attempt + 1)
718
+ while (Date.now() < until) { /* 短自旋,避免引入异步把保存变复杂 */ }
719
+ }
720
+ }
721
+ if (lastErr) {
722
+ try { fs.unlinkSync(tmp) } catch (e) {}
723
+ throw lastErr
724
+ }
725
+ if (window.__ZNOTES && window.__ZNOTES.rescan) window.__ZNOTES.rescan()
726
+ return { ok: true, mtime: fs.statSync(abs).mtimeMs }
727
+ } catch (e) {
728
+ return { ok: false, message: e.message }
729
+ }
730
+ },
731
+
732
+ createNote(dirRel, name, text) {
733
+ let base = String(name || '未命名').replace(/[\\/:*?"<>|]/g, '').trim() || '未命名'
734
+ // 点开头会被目录树扫描跳过,文件建出来却永远看不见,用户会重复创建
735
+ if (base.startsWith('.')) base = base.replace(/^\.+/, '') || '未命名'
736
+ // Windows 保留名与结尾点/空格
737
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(base)) base = '_' + base
738
+ base = base.replace(/[. ]+$/, '') || '未命名'
739
+ const dir = dirRel ? this.absOf(dirRel) : workspace
740
+ let rel = path.join(dirRel || '', base + '.md').replace(/\\/g, '/')
741
+ let abs = this.absOf(rel)
742
+ let i = 2
743
+ while (fs.existsSync(abs)) {
744
+ rel = path.join(dirRel || '', base + ' ' + i + '.md').replace(/\\/g, '/')
745
+ abs = this.absOf(rel)
746
+ i++
747
+ }
748
+ try {
749
+ fs.mkdirSync(dir, { recursive: true })
750
+ // 新建笔记必须是完全空白的文件,不预填标题(文件名已经表达了标题)
751
+ fs.writeFileSync(abs, text == null ? '' : text, 'utf8')
752
+ if (window.__ZNOTES && window.__ZNOTES.rescan) window.__ZNOTES.rescan()
753
+ return { ok: true, rel }
754
+ } catch (e) {
755
+ return { ok: false, message: e.message }
756
+ }
757
+ },
758
+
759
+ createFolder(dirRel, name) {
760
+ const base = String(name || '新建文件夹').replace(/[\\/:*?"<>|]/g, '').trim() || '新建文件夹'
761
+ let rel = path.join(dirRel || '', base).replace(/\\/g, '/')
762
+ let abs = this.absOf(rel)
763
+ let i = 2
764
+ while (fs.existsSync(abs)) {
765
+ rel = path.join(dirRel || '', base + ' ' + i).replace(/\\/g, '/')
766
+ abs = this.absOf(rel)
767
+ i++
768
+ }
769
+ try {
770
+ fs.mkdirSync(abs, { recursive: true })
771
+ if (window.__ZNOTES && window.__ZNOTES.rescan) window.__ZNOTES.rescan()
772
+ return { ok: true, rel }
773
+ } catch (e) {
774
+ return { ok: false, message: e.message }
775
+ }
776
+ },
777
+
778
+ /** 改名:只换最后一段,扩展名由调用方决定(笔记保留 .md) */
779
+ renameNode(rel, newName) {
780
+ const clean = String(newName || '').replace(/[\\/:*?"<>|]/g, '').trim()
781
+ if (!clean) return { ok: false, message: '名称不能为空' }
782
+ const isNote = /\.(md|markdown)$/i.test(rel)
783
+ const tail = isNote ? (/\.(md|markdown)$/i.exec(rel) || ['.md'])[0] : ''
784
+ const dirRel = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
785
+ const targetRel = path.join(dirRel, clean + tail).replace(/\\/g, '/')
786
+ if (targetRel === rel) return { ok: true, rel }
787
+ try {
788
+ const from = this.absOf(rel)
789
+ const to = this.absOf(targetRel)
790
+ if (fs.existsSync(to)) return { ok: false, message: '同名已存在' }
791
+ fs.renameSync(from, to)
792
+ if (window.__ZNOTES && window.__ZNOTES.rescan) window.__ZNOTES.rescan()
793
+ return { ok: true, rel: targetRel }
794
+ } catch (e) {
795
+ return { ok: false, message: e.message }
796
+ }
797
+ },
798
+
799
+ /** 删除:直接从磁盘移除(不做回收站,误删靠 git 历史恢复) */
800
+ deleteNode(rel) {
801
+ try {
802
+ const abs = this.absOf(rel)
803
+ if (!fs.existsSync(abs)) return { ok: true }
804
+ fs.rmSync(abs, { recursive: true, force: true })
805
+ if (window.__ZNOTES && window.__ZNOTES.rescan) window.__ZNOTES.rescan()
806
+ return { ok: true }
807
+ } catch (e) {
808
+ return { ok: false, message: e.message }
809
+ }
810
+ },
811
+
812
+ /** 目录里还有几篇笔记(删目录前的拦截) */
813
+ countInDir(rel) {
814
+ try {
815
+ const abs = rel ? this.absOf(rel) : workspace
816
+ let n = 0
817
+ const walk = (d, depth) => {
818
+ if (depth > 20) return
819
+ let list
820
+ try {
821
+ list = fs.readdirSync(d, { withFileTypes: true })
822
+ } catch (e) {
823
+ return
824
+ }
825
+ for (const it of list) {
826
+ if (it.name.startsWith('.')) continue
827
+ if (it.isDirectory()) walk(path.join(d, it.name), depth + 1)
828
+ else if (/\.(md|markdown)$/i.test(it.name)) n++
829
+ }
830
+ }
831
+ walk(abs, 0)
832
+ return n
833
+ } catch (e) {
834
+ return 0
835
+ }
836
+ },
837
+
838
+ /** 拖拽移动:把 rel 移到 targetDirRel 下 */
839
+ moveNode(rel, targetDirRel) {
840
+ const base = rel.split('/').pop()
841
+ let targetRel = path.join(targetDirRel || '', base).replace(/\\/g, '/')
842
+ if (targetRel === rel) return { ok: true, rel }
843
+ // 不能把目录移进自己里面
844
+ if (targetDirRel && (targetDirRel === rel || targetDirRel.startsWith(rel + '/'))) {
845
+ return { ok: false, message: '不能移动到自身内部' }
846
+ }
847
+ try {
848
+ const from = this.absOf(rel)
849
+ let to = this.absOf(targetRel)
850
+ if (fs.existsSync(to)) {
851
+ const ext = /\.(md|markdown)$/i.test(base) ? /\.(md|markdown)$/i.exec(base)[0] : ''
852
+ const stem = ext ? base.slice(0, -ext.length) : base
853
+ let i = 2
854
+ while (fs.existsSync(to)) {
855
+ targetRel = path.join(targetDirRel || '', stem + ' ' + i + ext).replace(/\\/g, '/')
856
+ to = this.absOf(targetRel)
857
+ i++
858
+ }
859
+ }
860
+ fs.mkdirSync(path.dirname(to), { recursive: true })
861
+ fs.renameSync(from, to)
862
+ // 位置变了,笔记正文里的图片相对路径必须同步重算,否则图全断
863
+ try {
864
+ if (fs.statSync(to).isDirectory()) this._rewriteDirImagePaths(from, to)
865
+ else if (/\.(md|markdown)$/i.test(to)) this._rewriteImagePaths(from, to)
866
+ } catch (e) {}
867
+ if (window.__ZNOTES && window.__ZNOTES.rescan) window.__ZNOTES.rescan()
868
+ return { ok: true, rel: targetRel }
869
+ } catch (e) {
870
+ return { ok: false, message: e.message }
871
+ }
872
+ },
873
+
874
+ /**
875
+ * 扫描工作区,返回目录树。
876
+ * 节点:{ id, name, type: 'folder'|'note', rel, children? }
877
+ * 排序:目录在前、笔记在后,各自按名字(中文用 localeCompare)
878
+ */
879
+ /**
880
+ * 把一张图片存进工作区的 assets/ 目录,返回相对路径。
881
+ * 用相对路径而不是 ndb 内部的 attachment:<digest>,是为了让文件在
882
+ * Obsidian / VS Code 等其它编辑器里也能直接显示。
883
+ */
884
+ saveImageToWorkspace(srcPath, base64, noteRel) {
885
+ try {
886
+ let buf = null
887
+ let ext = '.png'
888
+ if (base64) {
889
+ const m = /^data:image\/([a-z.+-]+);base64,/i.exec(base64)
890
+ if (!m) return { ok: false, message: '不是图片数据' }
891
+ // svg+xml 这类复合类型要归一化,否则 CT 表查不到、落盘成 .bin
892
+ const sub = m[1].toLowerCase()
893
+ ext = '.' + (sub === 'jpeg' ? 'jpg' : sub === 'svg+xml' ? 'svg' : sub)
894
+ buf = Buffer.from(base64.slice(m[0].length), 'base64')
895
+ } else if (srcPath) {
896
+ if (!fs.existsSync(srcPath)) return { ok: false, message: '图片文件不存在' }
897
+ ext = (path.extname(srcPath) || '.png').toLowerCase()
898
+ if (!['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'].includes(ext)) {
899
+ return { ok: false, message: '不支持的图片格式' }
900
+ }
901
+ buf = fs.readFileSync(srcPath)
902
+ } else {
903
+ return { ok: false, message: '没有图片数据' }
904
+ }
905
+ if (buf.length > 20 * 1024 * 1024) return { ok: false, message: '图片大小超过 20 M' }
906
+
907
+ const crypto = require('crypto')
908
+ const digest = crypto.createHash('md5').update(buf).digest('hex')
909
+ const CT = {
910
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
911
+ webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp',
912
+ }
913
+ const EXT = {
914
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif',
915
+ 'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/bmp': 'bmp',
916
+ }
917
+ const ct = CT[ext.replace('.', '')] || 'application/octet-stream'
918
+ const fileExt = EXT[ct] || 'bin'
919
+
920
+ const ndb = window.__NDB
921
+ if (!ndb || typeof ndb.postAttachment !== 'function') {
922
+ return { ok: false, message: '附件存储不可用' }
923
+ }
924
+ const r = ndb.postAttachment('attachment/' + digest, buf, ct)
925
+ if (!r || r.error || !r.ok) return { ok: false, message: (r && r.message) || '附件保存失败' }
926
+
927
+ // 补一条元数据:getAttachmentTempPath 靠 content_type 推扩展名,
928
+ // 不写的话临时缓存文件会是裸 digest,没有后缀
929
+ try {
930
+ ndb.put({ _id: 'attachment/' + digest, _attachments: [{ digest: digest, content_type: ct }] })
931
+ } catch (e) {}
932
+
933
+ // markdown 里写「相对当前笔记」的路径:其他 markdown 工具按 md 所在目录解析,
934
+ // 只有这种写法才能跨工具显示。attachment: 是私有协议,只有本程序认识。
935
+ const root = this._repoRoot()
936
+ const attachAbs = path.join(root, '.cmark', 'attachments', digest + '.' + fileExt)
937
+ // noteRel 为空时按工作区根目录算,不能落到父目录(那样会算出 ./Notes/... 这种错路径)
938
+ const noteAbs = noteRel ? path.join(workspace, noteRel) : path.join(workspace, '__root__.md')
939
+ let rel = path.relative(path.dirname(noteAbs), attachAbs).split(path.sep).join('/')
940
+ if (rel && !rel.startsWith('.')) rel = './' + rel
941
+
942
+ return { ok: true, rel: rel, digest: digest }
943
+ } catch (e) {
944
+ return { ok: false, message: e.message }
945
+ }
946
+ },
947
+
948
+ /**
949
+ * 扫描未被任何笔记引用的附件
950
+ *
951
+ * 删图片、换图片、删整篇笔记都不会动 .cmark/attachments 里的文件,
952
+ * 这里把「磁盘上有、但没有任何 md 引用」的挑出来,只统计不删除。
953
+ */
954
+ scanAttachments() {
955
+ try {
956
+ const root = this._repoRoot()
957
+ const attachDir = path.join(root, '.cmark', 'attachments')
958
+ if (!fs.existsSync(attachDir)) {
959
+ return { ok: true, total: 0, usedCount: 0, unused: [], unusedBytes: 0 }
960
+ }
961
+
962
+ const used = new Set()
963
+ const MD = /\.(md|markdown)$/i
964
+ const RE_ATTACH = /attachment:([a-f0-9]{16,64})/gi
965
+ const RE_PATH = /attachments\/([a-f0-9]{16,64})\.[a-z0-9]+/gi
966
+
967
+ const walk = (dir) => {
968
+ let list = []
969
+ try {
970
+ list = fs.readdirSync(dir, { withFileTypes: true })
971
+ } catch (e) {
972
+ return
973
+ }
974
+ for (const ent of list) {
975
+ if (ent.name.startsWith('.')) continue
976
+ if (ent.name === 'node_modules') continue
977
+ const abs = path.join(dir, ent.name)
978
+ if (ent.isDirectory()) {
979
+ walk(abs)
980
+ continue
981
+ }
982
+ if (!MD.test(ent.name)) continue
983
+ let txt = ''
984
+ try {
985
+ txt = fs.readFileSync(abs, 'utf8')
986
+ } catch (e2) {
987
+ // 有一篇读不出来就无法判断引用关系,宁可整次扫描失败,也不能误判成"无引用"
988
+ throw new Error('无法读取 ' + abs + ':' + e2.message)
989
+ }
990
+ let m
991
+ RE_ATTACH.lastIndex = 0
992
+ while ((m = RE_ATTACH.exec(txt))) used.add(m[1].toLowerCase())
993
+ RE_PATH.lastIndex = 0
994
+ while ((m = RE_PATH.exec(txt))) used.add(m[1].toLowerCase())
995
+ }
996
+ }
997
+ // 必须从仓库根扫:附件存放在 <仓库根>/.cmark/attachments,
998
+ // 而工作目录常常只是仓库的子目录(如 Notes/)。只扫 workspace 会漏掉
999
+ // 仓库里其它位置的引用,把还在用的附件当成垃圾移走。
1000
+ walk(this._repoRoot())
1001
+
1002
+ let files = []
1003
+ try {
1004
+ files = fs.readdirSync(attachDir)
1005
+ } catch (e) {
1006
+ return { ok: false, message: e.message }
1007
+ }
1008
+
1009
+ const unused = []
1010
+ let unusedBytes = 0
1011
+ let allBytes = 0
1012
+ for (const f of files) {
1013
+ const abs = path.join(attachDir, f)
1014
+ let stt
1015
+ try {
1016
+ stt = fs.statSync(abs)
1017
+ } catch (e) {
1018
+ continue
1019
+ }
1020
+ if (!stt.isFile()) continue
1021
+ allBytes += stt.size
1022
+ const digest = f.replace(/\.[^.]+$/, '').toLowerCase()
1023
+ if (!used.has(digest)) {
1024
+ unusedBytes += stt.size
1025
+ unused.push({ file: f, size: stt.size })
1026
+ }
1027
+ }
1028
+
1029
+ return {
1030
+ ok: true,
1031
+ total: files.length,
1032
+ usedCount: files.length - unused.length,
1033
+ unused: unused,
1034
+ unusedBytes: unusedBytes,
1035
+ allBytes: allBytes,
1036
+ }
1037
+ } catch (e) {
1038
+ return { ok: false, message: e.message }
1039
+ }
1040
+ },
1041
+
1042
+ /** 清理未引用附件:直接删除。附件也在 git 里,删错了可从历史取回 */
1043
+ cleanAttachments() {
1044
+ try {
1045
+ const scan = this.scanAttachments()
1046
+ if (!scan.ok) return scan
1047
+ if (!scan.unused.length) return { ok: true, deleted: 0, moved: 0, bytes: 0 }
1048
+
1049
+ const root = this._repoRoot()
1050
+ const attachDir = path.join(root, '.cmark', 'attachments')
1051
+
1052
+ let deleted = 0
1053
+ let bytes = 0
1054
+ for (const it of scan.unused) {
1055
+ try {
1056
+ fs.unlinkSync(path.join(attachDir, it.file))
1057
+ deleted++
1058
+ bytes += it.size
1059
+ } catch (e) {}
1060
+ }
1061
+ // deleted 是新字段名,moved 留着兼容旧调用方
1062
+ return { ok: true, deleted: deleted, moved: deleted, bytes: bytes }
1063
+ } catch (e) {
1064
+ return { ok: false, message: e.message }
1065
+ }
1066
+ },
1067
+
1068
+ /** 选一个导出目录:Electron 渲染进程禁用 window.prompt,必须走宿主对话框 */
1069
+ pickExportDir() {
1070
+ try {
1071
+ const r = window.ztools.showOpenDialog({
1072
+ title: '选择导出目录',
1073
+ properties: ['openDirectory', 'createDirectory'],
1074
+ })
1075
+ const dir = Array.isArray(r) ? r[0] : r
1076
+ if (!dir) return { ok: false, canceled: true }
1077
+ return { ok: true, dir }
1078
+ } catch (e) {
1079
+ return { ok: false, message: e.message }
1080
+ }
1081
+ },
1082
+
1083
+ /** 选一张图片(走宿主对话框,不会把插件窗口挤到后台) */
1084
+ pickImageFile() {
1085
+ try {
1086
+ const r = window.ztools.showOpenDialog({
1087
+ title: '选择图片',
1088
+ properties: ['openFile'],
1089
+ filters: [
1090
+ { name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'] },
1091
+ ],
1092
+ })
1093
+ const f = Array.isArray(r) ? r[0] : r
1094
+ if (!f) return { ok: false, canceled: true }
1095
+ return { ok: true, path: f }
1096
+ } catch (e) {
1097
+ return { ok: false, message: '打开文件选择器失败:' + e.message }
1098
+ }
1099
+ },
1100
+
1101
+ /**
1102
+ * 保存目录树顺序到 .cmark/order.txt
1103
+ *
1104
+ * order.txt 的约定(沙盒里实测的真实库格式):
1105
+ * 目录项写裸名(IWAN、SDIWAN)
1106
+ * 笔记项写 note/<相对仓库根的路径>
1107
+ * 而 listTree 给出的 id 是纯相对路径,所以这里先做一次 id 转换再交给 ndb。
1108
+ */
1109
+ saveOrder(tree) {
1110
+ try {
1111
+ const conv = (list) =>
1112
+ (list || []).map((n) => ({
1113
+ id: n.type === 'note' ? 'note/' + n.rel : n.rel,
1114
+ name: n.name,
1115
+ children: n.children && n.children.length ? conv(n.children) : undefined,
1116
+ }))
1117
+ const z = window.__ZNOTES
1118
+ if (!z || typeof z.saveTreeOrder !== 'function') return { ok: false, message: '排序接口不可用' }
1119
+ const r = z.saveTreeOrder(conv(tree))
1120
+ // 底层返回 false 时不能假装成功,否则用户以为排好了其实没写盘
1121
+ if (r === false || (r && r.ok === false)) return { ok: false, message: (r && r.message) || '排序保存失败' }
1122
+ return { ok: true }
1123
+ } catch (e) {
1124
+ return { ok: false, message: e.message }
1125
+ }
1126
+ },
1127
+
1128
+ /**
1129
+ * 仓库根下的 .cmark 目录。
1130
+ * workspace 可能只是仓库的一个子目录(比如仓库是 D:\CMark、笔记在 D:\CMark\Notes),
1131
+ * 元数据必须跟 ndb.js 的 REPO_META 指向同一个位置,否则会凭空在笔记目录里造一个 .cmark。
1132
+ */
1133
+ repoMetaDir() {
1134
+ let cur = workspace
1135
+ for (let d = 0; d < 5 && cur && cur !== path.dirname(cur); d++) {
1136
+ try {
1137
+ if (fs.existsSync(path.join(cur, '.git'))) return path.join(cur, '.cmark')
1138
+ } catch (e) {}
1139
+ cur = path.dirname(cur)
1140
+ }
1141
+ return path.join(workspace, '.cmark')
1142
+ },
1143
+
1144
+ /** 判断工作区里某个相对路径还在不在(用来挡住"给已删除的笔记补写") */
1145
+ exists(rel) {
1146
+ try {
1147
+ return fs.existsSync(this.absOf(rel))
1148
+ } catch (e) {
1149
+ return false
1150
+ }
1151
+ },
1152
+
1153
+ /** 在系统资源管理器里定位某个文件/文件夹 */
1154
+ /** 在资源管理器中定位某个笔记/文件夹(同样走 Electron shell,不走 ztools) */
1155
+ shellShowItem(rel) {
1156
+ try {
1157
+ const abs = rel ? this.absOf(rel) : workspace
1158
+ const { shell } = require('electron')
1159
+ if (!fs.existsSync(abs)) {
1160
+ // 路径不在了就退回目录,至少给个可用的落点
1161
+ if (shell && shell.openPath) shell.openPath(workspace)
1162
+ return { ok: false, message: '路径不存在' }
1163
+ }
1164
+ try {
1165
+ const st = fs.statSync(abs)
1166
+ if (st.isDirectory()) {
1167
+ if (shell && shell.openPath) shell.openPath(abs)
1168
+ } else if (shell && shell.showItemInFolder) {
1169
+ shell.showItemInFolder(abs)
1170
+ } else if (shell && shell.openPath) {
1171
+ shell.openPath(require('path').dirname(abs))
1172
+ }
1173
+ } catch (e) {}
1174
+ return { ok: true }
1175
+ } catch (e) {
1176
+ return { ok: false, message: String(e && e.message) }
1177
+ }
1178
+ },
1179
+
1180
+ /** 复制一份(同目录,名字加 -副本 后缀,重名自动编号) */
1181
+ duplicateNode(rel) {
1182
+ try {
1183
+ const abs = this.absOf(rel)
1184
+ if (!fs.existsSync(abs)) return { ok: false, message: '文件不存在' }
1185
+ const dir = path.dirname(abs)
1186
+ const ext = path.extname(abs)
1187
+ const base = path.basename(abs, ext)
1188
+ let name = base + '-副本'
1189
+ let i = 2
1190
+ while (fs.existsSync(path.join(dir, name + ext))) {
1191
+ name = base + '-副本 ' + i
1192
+ i++
1193
+ }
1194
+ const target = path.join(dir, name + ext)
1195
+ if (fs.statSync(abs).isDirectory()) {
1196
+ fs.cpSync(abs, target, { recursive: true })
1197
+ } else {
1198
+ fs.copyFileSync(abs, target)
1199
+ }
1200
+ // 注:大目录用 cpSync 会阻塞 preload 线程,但这里是用户主动操作且通常是单篇笔记,
1201
+ // 真碰到大目录再改成异步队列
1202
+ const relTarget = path.relative(workspace, target).replace(/\\/g, '/')
1203
+ if (window.__ZNOTES && window.__ZNOTES.rescan) window.__ZNOTES.rescan()
1204
+ return { ok: true, rel: relTarget }
1205
+ } catch (e) {
1206
+ return { ok: false, message: e.message }
1207
+ }
1208
+ },
1209
+
1210
+ /** 全局搜索:遍历工作区里所有 Markdown,返回命中笔记与片段 */
1211
+ searchNotes(query, limit) {
1212
+ const q = String(query || '').trim()
1213
+ if (!q) return { ok: true, data: [] }
1214
+ const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 60))
1215
+ const lower = q.toLowerCase()
1216
+ const esc = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1217
+ const re = new RegExp(esc, 'gi')
1218
+ const out = []
1219
+ const walk = (dir) => {
1220
+ if (out.length >= lim) return
1221
+ let entries = []
1222
+ try {
1223
+ entries = fs.readdirSync(dir, { withFileTypes: true })
1224
+ } catch (e) {
1225
+ return
1226
+ }
1227
+ for (const e of entries) {
1228
+ if (out.length >= lim) return
1229
+ if (e.name.startsWith('.')) continue
1230
+ const abs = path.join(dir, e.name)
1231
+ if (e.isDirectory()) {
1232
+ walk(abs)
1233
+ continue
1234
+ }
1235
+ if (!/\.(md|markdown)$/i.test(e.name)) continue
1236
+ let text = ''
1237
+ try {
1238
+ text = fs.readFileSync(abs, 'utf8')
1239
+ } catch (e) {
1240
+ continue
1241
+ }
1242
+ const low = text.toLowerCase()
1243
+ if (low.indexOf(lower) < 0) continue
1244
+ const idx = low.indexOf(lower)
1245
+ const start = Math.max(0, idx - 40)
1246
+ const snippet = text
1247
+ .slice(start, Math.min(text.length, idx + lower.length + 90))
1248
+ .replace(/\r?\n/g, ' ')
1249
+ .trim()
1250
+ const count = (low.match(re) || []).length
1251
+ out.push({
1252
+ rel: path.relative(workspace, abs).replace(/\\/g, '/'),
1253
+ name: e.name.replace(/\.(md|markdown)$/i, ''),
1254
+ snippet,
1255
+ count,
1256
+ })
1257
+ }
1258
+ }
1259
+ try {
1260
+ walk(workspace)
1261
+ } catch (e) {
1262
+ return { ok: false, message: e.message }
1263
+ }
1264
+ out.sort((a, b) => b.count - a.count)
1265
+ return { ok: true, data: out }
1266
+ },
1267
+
1268
+ /** 用系统默认浏览器打开外链(编辑器里的 <a> 走这里) */
1269
+ openExternal(url) {
1270
+ try {
1271
+ const u = String(url || '')
1272
+ if (!/^https?:\/\//i.test(u)) return
1273
+ if (window.ztools && window.ztools.shellOpenExternal) window.ztools.shellOpenExternal(u)
1274
+ else require('electron').shell.openExternal(u)
1275
+ } catch (e) {}
1276
+ },
1277
+
1278
+ /** 工作区内的相对路径图片 → file:// 绝对地址,供 <img src> 使用 */
1279
+ /** 把仓库内的图片读成 data URI,导出时内嵌,避免导出文件依赖本机绝对路径 */
1280
+ readFileAsDataUri(srcPath) {
1281
+ try {
1282
+ const raw = decodeURIComponent(String(srcPath || '').replace(/^file:\/\/(\/)?/, ''))
1283
+ if (!raw) return null
1284
+ // 必须 path.resolve 规范化:调用方给的可能是 D:/x/y(正斜杠),
1285
+ // 而 _repoRoot() 返回的是 D:\\x\\y,直接 startsWith 会必然失败
1286
+ const abs = path.resolve(raw)
1287
+ const root = this._repoRoot() ? path.resolve(this._repoRoot()) : ''
1288
+ if (root && !abs.toLowerCase().startsWith(root.toLowerCase())) return null
1289
+ if (!fs.existsSync(abs)) return null
1290
+ const st = fs.statSync(abs)
1291
+ if (!st.isFile() || st.size > 8 * 1024 * 1024) return null
1292
+ const ext = path.extname(abs).toLowerCase().replace('.', '')
1293
+ const mime =
1294
+ {
1295
+ png: 'image/png',
1296
+ jpg: 'image/jpeg',
1297
+ jpeg: 'image/jpeg',
1298
+ gif: 'image/gif',
1299
+ webp: 'image/webp',
1300
+ svg: 'image/svg+xml',
1301
+ bmp: 'image/bmp',
1302
+ }[ext] || 'application/octet-stream'
1303
+ return 'data:' + mime + ';base64,' + fs.readFileSync(abs).toString('base64')
1304
+ } catch (e) {
1305
+ return null
1306
+ }
1307
+ },
1308
+
1309
+ resolveImageUrl(src, noteRel) {
1310
+ try {
1311
+ const raw = String(src || '')
1312
+ if (!raw) return ''
1313
+ if (/^(https?:|data:|file:)/i.test(raw)) return raw
1314
+
1315
+ // 老数据:V1.0.0 用私有协议存,继续兼容读取
1316
+ if (raw.startsWith('attachment:')) {
1317
+ const digest = raw.slice('attachment:'.length)
1318
+ // digest 必须是纯 hex。不校验的话 `attachment:../../../../Users/x/secret.png`
1319
+ // 会被 getAttachmentTempPath 拼进 path.join 读出任意本地文件
1320
+ if (!/^[a-f0-9]{16,64}$/i.test(digest)) return ''
1321
+ const s = window.services
1322
+ const p = s && s.getAttachmentTempPath ? s.getAttachmentTempPath(digest) : null
1323
+ return p ? 'file:///' + p.replace(/\\/g, '/') : ''
1324
+ }
1325
+
1326
+ // 相对路径要多基线探测:笔记里有三种并存写法
1327
+ // a) 相对笔记 ../.cmark/attachments/x.png
1328
+ // b) 相对工作区 assets/x.png
1329
+ // c) 相对仓库根 .cmark/attachments/x.png
1330
+ // 只按笔记目录解析会让 b/c 两种一律失败并静默返回空串。
1331
+ // 不能用 absOf —— 它把路径限制在笔记目录内,而 .cmark 在上一级的仓库根。
1332
+ const root = this._repoRoot()
1333
+ const noteDir = path.dirname(noteRel ? path.join(workspace, noteRel) : path.join(workspace, '__root__.md'))
1334
+ // 多基线依次探测。笔记里三种写法并存,只按笔记目录解析会让另外两种静默失败:
1335
+ // a) 相对笔记 ../.cmark/attachments/x.png
1336
+ // b) 相对工作区 assets/x.png
1337
+ // c) 相对仓库根 .cmark/attachments/x.png
1338
+ const bases = [noteDir, workspace, root, path.join(workspace, 'assets'), path.join(root || workspace, '.cmark', 'attachments')]
1339
+ for (const b of bases) {
1340
+ if (!b) continue
1341
+ const abs = path.resolve(b, raw)
1342
+ if (root && abs !== root && !abs.startsWith(root + path.sep)) continue
1343
+ if (fs.existsSync(abs)) return 'file:///' + abs.replace(/\\/g, '/')
1344
+ }
1345
+ return ''
1346
+ } catch (e) {
1347
+ return ''
1348
+ }
1349
+ },
1350
+
1351
+ listTree() {
1352
+ const MAX_FILES = 20000
1353
+ let files = 0
1354
+ let truncated = false
1355
+
1356
+ const build = (dirAbs, dirRel, depth) => {
1357
+ if (depth > 24 || truncated) return []
1358
+ let list
1359
+ try {
1360
+ list = fs.readdirSync(dirAbs, { withFileTypes: true })
1361
+ } catch (e) {
1362
+ return []
1363
+ }
1364
+ const folders = []
1365
+ const notes = []
1366
+ for (const it of list) {
1367
+ if (it.name.startsWith('.')) continue
1368
+ // 图片附件目录,图片通过 assets/xxx.png 直接引用,不作为笔记目录展示
1369
+ if (it.name === 'assets') continue
1370
+ const rel = dirRel ? dirRel + '/' + it.name : it.name
1371
+ if (it.isDirectory()) {
1372
+ folders.push({
1373
+ id: rel,
1374
+ name: it.name,
1375
+ type: 'folder',
1376
+ rel,
1377
+ children: build(path.join(dirAbs, it.name), rel, depth + 1),
1378
+ })
1379
+ } else if (/\.(md|markdown)$/i.test(it.name)) {
1380
+ if (++files > MAX_FILES) {
1381
+ truncated = true
1382
+ break
1383
+ }
1384
+ let mtime = 0
1385
+ try {
1386
+ mtime = fs.statSync(path.join(dirAbs, it.name)).mtimeMs
1387
+ } catch (e) {}
1388
+ notes.push({
1389
+ id: rel,
1390
+ name: it.name.replace(/\.(md|markdown)$/i, ''),
1391
+ type: 'note',
1392
+ rel,
1393
+ mtime,
1394
+ })
1395
+ }
1396
+ }
1397
+ folders.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
1398
+ notes.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
1399
+ // 再按 .cmark/order.txt 里记的先后排一遍:拖拽排序的结果靠它生效,
1400
+ // 没记录过的条目保持字典序并排在后面
1401
+ const order = {}
1402
+ try {
1403
+ const z = window.__ZNOTES
1404
+ const o = z && z.readTreeOrder ? z.readTreeOrder() : {}
1405
+ Object.assign(order, o || {})
1406
+ } catch (e) {}
1407
+ const list2 = order[dirRel] || []
1408
+ const idx = new Map()
1409
+ list2.forEach((id, i) => idx.set(String(id), i))
1410
+ const keyOf = (n) => (n.type === 'note' ? 'note/' + n.rel : n.name)
1411
+ const byOrder = (a, b) => {
1412
+ const ia = idx.has(keyOf(a)) ? idx.get(keyOf(a)) : 1e9
1413
+ const ib = idx.has(keyOf(b)) ? idx.get(keyOf(b)) : 1e9
1414
+ return ia === ib ? 0 : ia - ib
1415
+ }
1416
+ return folders.sort(byOrder).concat(notes.sort(byOrder))
1417
+ }
1418
+
1419
+ const children = build(workspace, '', 0)
1420
+ return { workspace, children, truncated, count: files }
1421
+ },
1422
+ }
1423
+
1424
+ // 让渲染层尽早知道工作区在哪(避免首帧空白)
1425
+ try {
1426
+ const z = window.ztools
1427
+ if (z && z.setExpendHeight) z.setExpendHeight(600)
1428
+ } catch (e) {}
1429
+
1430
+ /* ==================================================================
1431
+ * ZTools 工具接口(对应 plugin.json 里的 tools 段)
1432
+ * AI 侧通过这些工具读写笔记、把 Markdown 渲染成图片/PDF
1433
+ * ================================================================== */
1434
+ ;(function registerTools() {
1435
+ const z = window.ztools
1436
+ if (!z || typeof z.registerTool !== 'function') return
1437
+
1438
+ const bridge = window.__CMARK
1439
+ const nameOf = (rel) => String(rel).split('/').pop().replace(/\.(md|markdown)$/i, '')
1440
+
1441
+ /** 精简 Markdown → HTML(preload 里没有 marked,够 AI 内容落地即可) */
1442
+ function miniMarkdownToHtml(md) {
1443
+ const esc = (t) =>
1444
+ String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1445
+
1446
+ const blocks = []
1447
+ let text = String(md || '').replace(/\r\n/g, '\n')
1448
+
1449
+ // 先把代码块挖出来,避免里面的符号被当行内格式
1450
+ text = text.replace(/```([a-zA-Z0-9+#-]*)\n([\s\S]*?)```/g, (_m, lang, code) => {
1451
+ blocks.push('<pre style="background:#f6f8fa;padding:12px;border-radius:4px;overflow:auto"><code>' + esc(code) + '</code></pre>')
1452
+ return '\u0000B' + (blocks.length - 1) + '\u0000'
1453
+ })
1454
+
1455
+ const lines = text.split('\n')
1456
+ const out = []
1457
+ let inList = null
1458
+ const closeList = () => {
1459
+ if (inList) {
1460
+ out.push('</' + inList + '>')
1461
+ inList = null
1462
+ }
1463
+ }
1464
+
1465
+ for (const raw of lines) {
1466
+ const line = raw
1467
+ const ph = /^\u0000B(\d+)\u0000$/.exec(line.trim())
1468
+ if (ph) {
1469
+ closeList()
1470
+ out.push(blocks[Number(ph[1])])
1471
+ continue
1472
+ }
1473
+ if (!line.trim()) {
1474
+ closeList()
1475
+ continue
1476
+ }
1477
+ const h = /^(#{1,6})\s+(.*)$/.exec(line)
1478
+ if (h) {
1479
+ closeList()
1480
+ out.push('<h' + h[1].length + '>' + inline(h[2]) + '</h' + h[1].length + '>')
1481
+ continue
1482
+ }
1483
+ if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
1484
+ closeList()
1485
+ out.push('<hr/>')
1486
+ continue
1487
+ }
1488
+ const li = /^\s*([-*+]|\d+\.)\s+(.*)$/.exec(line)
1489
+ if (li) {
1490
+ const tag = /\d/.test(li[1]) ? 'ol' : 'ul'
1491
+ if (inList !== tag) {
1492
+ closeList()
1493
+ out.push('<' + tag + '>')
1494
+ inList = tag
1495
+ }
1496
+ out.push('<li>' + inline(li[2]) + '</li>')
1497
+ continue
1498
+ }
1499
+ if (/^\s*>\s?/.test(line)) {
1500
+ closeList()
1501
+ out.push('<blockquote>' + inline(line.replace(/^\s*>\s?/, '')) + '</blockquote>')
1502
+ continue
1503
+ }
1504
+ if (/^\s*\|.*\|\s*$/.test(line)) {
1505
+ closeList()
1506
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim())
1507
+ if (/^[\s:|-]+$/.test(cells.join('|'))) {
1508
+ // 分隔行:把上一层刚输出的 tr 里的 td 换成 th,保证首行是表头
1509
+ const last = out.length - 1
1510
+ if (last >= 0 && out[last].startsWith('<tr>')) {
1511
+ out[last] = out[last].replace(/<td /g, '<th ').replace(/<\/td>/g, '</th>')
1512
+ }
1513
+ continue
1514
+ }
1515
+ out.push('<tr>' + cells.map((c) => '<td style="border:1px solid #dfe2e5;padding:4px 8px">' + inline(c) + '</td>').join('') + '</tr>')
1516
+ continue
1517
+ }
1518
+ closeList()
1519
+ out.push('<p>' + inline(line) + '</p>')
1520
+ }
1521
+ closeList()
1522
+
1523
+ function inline(t) {
1524
+ let v = esc(t)
1525
+ v = v.replace(/`([^`]+)`/g, '<code style="background:#f0f0f0;padding:1px 4px;border-radius:3px">$1</code>')
1526
+ v = v.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
1527
+ v = v.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
1528
+ v = v.replace(/~~([^~]+)~~/g, '<del>$1</del>')
1529
+ v = v.replace(/==([^=]+)==/g, '<mark>$1</mark>')
1530
+ v = v.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
1531
+ return v
1532
+ }
1533
+
1534
+ // 连续 <tr> 包一层 table
1535
+ let html = out.join('\n')
1536
+ html = html.replace(/(<tr>[\s\S]*?<\/tr>)+/g, (m) => '<table style="border-collapse:collapse;margin:8px 0">' + m + '</table>')
1537
+ return html
1538
+ }
1539
+
1540
+ /** 把 Markdown 渲染成图片/PDF,返回文件路径 */
1541
+ function renderToFile(markdown, format) {
1542
+ return new Promise((resolve) => {
1543
+ const S = window.services
1544
+ if (!S || typeof S.exportFile !== 'function') {
1545
+ resolve({ ok: false, message: '当前环境不支持渲染导出' })
1546
+ return
1547
+ }
1548
+ // preload 环境拿不到 marked(渲染层才有),这里用一个够用的精简转换:
1549
+ // 标题 / 粗斜体 / 行内代码 / 代码块 / 列表 / 引用 / 分割线 / 表格 / 链接。
1550
+ // 精确渲染仍以前端导出为准,这条路径主要服务 AI 生成内容的落地。
1551
+ const html = miniMarkdownToHtml(markdown)
1552
+ S.exportFile(
1553
+ format === 'pdf' ? 'pdf' : 'image',
1554
+ html,
1555
+ '',
1556
+ (err, filePath) => {
1557
+ if (err) resolve({ ok: false, message: err.message })
1558
+ else resolve({ ok: true, filePath })
1559
+ }
1560
+ )
1561
+ })
1562
+ }
1563
+
1564
+ z.registerTool('markdown_notes_search', async (args) => {
1565
+ const query = String(args && args.query ? args.query : '').trim()
1566
+ const limit = Math.max(1, Math.min(50, Number(args && args.limit) || 10))
1567
+ if (!query) return { notes: [], total: 0, hasMore: false }
1568
+ const r = bridge.searchNotes(query, limit)
1569
+ const list = (r && r.ok && r.data) || []
1570
+ const notes = list.map((h) => ({
1571
+ id: h.rel,
1572
+ title: h.name,
1573
+ snippet: h.snippet,
1574
+ matchedField: 'content',
1575
+ wordCount: String(h.snippet || '').length,
1576
+ }))
1577
+ return { notes, total: notes.length, hasMore: list.length >= limit }
1578
+ })
1579
+
1580
+ z.registerTool('markdown_notes_get', async (args) => {
1581
+ const id = String(args && args.id ? args.id : '')
1582
+ const format = (args && args.format) || 'text'
1583
+ if (!id) return { id: '', title: '', message: '缺少 id' }
1584
+ const content = bridge.readNote(id)
1585
+ const title = nameOf(id)
1586
+ const st = bridge.statNote ? bridge.statNote(id) : null
1587
+ const mtime = st && st.ok && st.mtime ? Math.round(st.mtime) : Date.now()
1588
+ if (format === 'text') {
1589
+ return { id, title, content, mimeType: 'text/markdown', updateAt: mtime }
1590
+ }
1591
+ const r = await renderToFile(content, format)
1592
+ if (!r.ok) return { id, title, message: r.message }
1593
+ return {
1594
+ id,
1595
+ title,
1596
+ filePath: r.filePath,
1597
+ mimeType: format === 'pdf' ? 'application/pdf' : 'image/png',
1598
+ updateAt: mtime,
1599
+ }
1600
+ })
1601
+
1602
+ z.registerTool('markdown_notes_create', async (args) => {
1603
+ const title = String((args && args.title) || '未命名').slice(0, 50)
1604
+ const content = String((args && args.content) || '')
1605
+ const r = bridge.createNote('', title, content)
1606
+ return { success: !!(r && r.ok), message: r && r.ok ? '已创建笔记:' + title : (r && r.message) || '创建失败' }
1607
+ })
1608
+
1609
+ z.registerTool('markdown_render', async (args) => {
1610
+ const markdown = String((args && args.markdown) || '')
1611
+ const format = (args && args.format) === 'pdf' ? 'pdf' : 'image'
1612
+ const r = await renderToFile(markdown, format)
1613
+ return r.ok ? { filePath: r.filePath } : { filePath: '', message: r.message }
1614
+ })
1615
+ })()