@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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +185 -2
- package/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/assets/abnfDiagram-WEYOIA3C-BgXsTF7a.js +1 -0
- package/assets/architectureDiagram-ZHVYKT53-BsCWa_qC.js +36 -0
- package/assets/blockDiagram-RJAE7QA3-wbIxEiYN.js +140 -0
- package/assets/c4Diagram-XDPPPE54-5zdyxFvA.js +38 -0
- package/assets/chunk-2E7WHVMD-F85Lca-i.js +88 -0
- package/assets/chunk-2Q5K7J3B-CjpAZ0OI.js +1 -0
- package/assets/chunk-4EMNKR2T-C8Kbh83c.js +1 -0
- package/assets/chunk-55XCU76K-BINcFiLN.js +1 -0
- package/assets/chunk-5VM5RSS4-Diy5h2LS.js +15 -0
- package/assets/chunk-JWPE2WC7-BXnjDdh1.js +1 -0
- package/assets/chunk-VQ5W62QI-BM7ZZo1I.js +1 -0
- package/assets/chunk-XXDRQBXY-CW8CjB1w.js +1 -0
- package/assets/chunk-ZCHMQSOR-C7ATz1L1.js +2 -0
- package/assets/classDiagram-v2-4MYWR3UA-CMoqNk-b.js +217 -0
- package/assets/cose-bilkent-JH36ORCC-6rqR5iJk.js +1 -0
- package/assets/cynefin-EF2NZ3EQ-DypLIJxl.js +141 -0
- package/assets/cynefinDiagram-WF24UDRP-8b2CEBlR.js +62 -0
- package/assets/cytoscape.esm-CjX6Hyah.js +331 -0
- package/assets/dagre-NK4GIZVX-Dv6vuUbR.js +4 -0
- package/assets/diagram-CBK3MWTC-DJgMDeYd.js +24 -0
- package/assets/diagram-I5XIIV2D-BB9cMoRW.js +24 -0
- package/assets/diagram-ODIL2UCW-BVcRtsdy.js +30 -0
- package/assets/diagram-PFBEJUXE-BbGSOtJx.js +3 -0
- package/assets/diagram-RJA3I4VV-CzkY_8Fo.js +41 -0
- package/assets/diagram-TYUNFSNR-YTl5QdfL.js +200 -0
- package/assets/ebnfDiagram-V4P3TTPT-COVjPAjE.js +1 -0
- package/assets/echarts-BAPDkhe3.js +61 -0
- package/assets/elk-QONTBCTQ-DHsjY8Z_.js +27 -0
- package/assets/erDiagram-KYQIGQAA-CUAbTUl1.js +99 -0
- package/assets/flowDiagram-IUGLLWBU-CWsiqTQC.js +213 -0
- package/assets/ganttDiagram-R7GPNTJY-DetBW0Wm.js +292 -0
- package/assets/gitGraphDiagram-PDYL7XHY-ByrdgJmw.js +106 -0
- package/assets/graph-D8UmR1vK.js +1 -0
- package/assets/index-BLONSItV.css +1 -0
- package/assets/index-Cz6qaZbV.js +744 -0
- package/assets/infoDiagram-MVDRKAX5-BM48fjhU.js +2 -0
- package/assets/ishikawaDiagram-HPEHV2GE-DtHYywHZ.js +70 -0
- package/assets/journeyDiagram-BOSHW7O7-DJ1LiRh3.js +139 -0
- package/assets/kanban-definition-FNSA4HC5-DPPt8pwJ.js +89 -0
- package/assets/katex-CQL9J12G.js +257 -0
- package/assets/markdown-DVehI1Sg.js +314 -0
- package/assets/mermaid-C_q9pug0.js +312 -0
- package/assets/mindmap-definition-6OY57UG3-zbqYOEY6.js +98 -0
- package/assets/pegDiagram-ECIUJNBX-e7QG7TMq.js +1 -0
- package/assets/pieDiagram-GNMPY74T-Duf6YP0N.js +39 -0
- package/assets/quadrantDiagram-D57K4MC3-B2eYoUUy.js +7 -0
- package/assets/railroadDiagram-Y72NRBL7-BWCzPcI_.js +1 -0
- package/assets/requirementDiagram-EQYSW5RA-HhPMEqB1.js +84 -0
- package/assets/sankeyDiagram-SY24JHO5-CMUTwFBB.js +40 -0
- package/assets/sequenceDiagram-BLEHNHQU-C9wR2ll7.js +169 -0
- package/assets/sizeCapture-INFHLROL-DXfz37S6.js +1 -0
- package/assets/stateDiagram-v2-A6IESKWZ-D_gtonhE.js +291 -0
- package/assets/swimlanes-B3ENRKFQ-DdJOuuMV.js +1 -0
- package/assets/swimlanesDiagram-AYQRV4AK-BWt3HxHV.js +8 -0
- package/assets/timeline-definition-JXYKGLGB-DQsdwJe-.js +120 -0
- package/assets/usecaseDiagram-WPOLBNNX-DTRyFq7d.js +370 -0
- package/assets/vennDiagram-Q3Y3IDCW-dihYszm0.js +34 -0
- package/assets/wardleyDiagram-IVAIDKWA-BoPpgYfK.js +78 -0
- package/assets/xychartDiagram-4URNHDU4-DoLjYxeB.js +7 -0
- package/export-file/github-markdown.css +550 -0
- package/export-file/index.html +33 -0
- package/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/fonts/KaTeX_Main-Bold.woff +0 -0
- package/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/fonts/KaTeX_Main-Italic.woff +0 -0
- package/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/fonts/KaTeX_Main-Regular.woff +0 -0
- package/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/fonts/KaTeX_Math-Italic.woff +0 -0
- package/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/fonts/KaTeX_Script-Regular.woff +0 -0
- package/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- package/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- package/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/index.html +15 -0
- package/katex.min.css +1 -0
- package/logo.png +0 -0
- package/package.json +31 -4
- package/plugin.json +241 -0
- package/preload/branch-bar.js +159 -0
- package/preload/git-panel.js +1311 -0
- package/preload/git-service.js +1403 -0
- package/preload/ndb.js +1407 -0
- package/preload/package.json +3 -0
- package/preload/rename-note.js +151 -0
- package/preload/services.js +432 -0
- package/preload/settings-panel.js +847 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
})()
|