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