dsh-design-studio 0.0.0-stage → 0.3.2

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/lib/history.js ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * 设计版本:本地文件预览的目录,每轮对话前后各存一份,能翻回去看、和现在对比、回到那一版。
3
+ *
4
+ * <base>/<目录哈希>/index.json [{ n, at, label, files: { 相对路径: sha1 } }],旧的在前
5
+ * <base>/<目录哈希>/objects/<sha1> 文件内容(按内容存,几版之间没改的文件只存一份)
6
+ *
7
+ * 只存做设计用的文件(HTML / CSS / JS / 图片 / 字体,单个 ≤ 2MB,一共 ≤ 30MB、≤ 300 个),
8
+ * 跳过 node_modules、.git、隐藏目录、exports/(导出的图片)和 refs/(参考图)。目录太大(多半是整个项目)就不存。
9
+ */
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+ import crypto from 'node:crypto'
13
+
14
+ const KEEP_EXT = /\.(html?|css|m?js|json|svg|txt|md|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf)$/i
15
+ const SKIP_DIR = /^(node_modules|\.git|\.next|\.cache|\.turbo|dist|build|exports|refs)$|^\./
16
+ const MAX_FILE = 2 * 1024 * 1024
17
+ const MAX_TOTAL = 30 * 1024 * 1024
18
+ const MAX_FILES = 300
19
+ const MAX_SCAN = 3000 // 最多看这么多个目录项,再多就当不是设计目录
20
+ const MAX_DEPTH = 3
21
+ const MAX_VERSIONS = 40
22
+
23
+ const HASH = /^[0-9a-f]{40}$/
24
+
25
+ const sha1 = (buf) => crypto.createHash('sha1').update(buf).digest('hex')
26
+
27
+ /** 某一版里这个文件的内容哈希:只认它自己的键(toString / __proto__ 这种名字不会摸到原型上去),格式不对给 null */
28
+ function hashOf(v, key) {
29
+ const files = v && v.files
30
+ if (!files || typeof files !== 'object' || !Object.hasOwn(files, key)) return null
31
+ return typeof files[key] === 'string' && HASH.test(files[key]) ? files[key] : null
32
+ }
33
+
34
+ /** dest 写下去会落在哪个真实目录里(还没建的几层往上找已有的那层);那层是坏掉的软链接给 null */
35
+ function realParent(dest) {
36
+ let dir = path.dirname(dest)
37
+ for (;;) {
38
+ try { fs.lstatSync(dir); break } catch { /* 还没有这一层 */ }
39
+ const up = path.dirname(dir)
40
+ if (up === dir) return null
41
+ dir = up
42
+ }
43
+ try { return fs.realpathSync(dir) } catch { return null }
44
+ }
45
+
46
+ /** 目录里要存的文件 → { 相对路径: 绝对路径 };不像设计目录(太大)给 null */
47
+ export function collect(root) {
48
+ const out = {}
49
+ let scanned = 0
50
+ let count = 0
51
+ let total = 0
52
+ const walk = (dir, rel, depth) => {
53
+ let list
54
+ try { list = fs.readdirSync(dir, { withFileTypes: true }) } catch { return true }
55
+ for (const d of list) {
56
+ if (++scanned > MAX_SCAN) return false
57
+ const r = rel ? rel + '/' + d.name : d.name
58
+ const abs = path.join(dir, d.name)
59
+ if (d.isDirectory()) {
60
+ if (SKIP_DIR.test(d.name) || depth >= MAX_DEPTH) continue
61
+ if (!walk(abs, r, depth + 1)) return false
62
+ continue
63
+ }
64
+ if (!d.isFile() || d.name.startsWith('.') || !KEEP_EXT.test(d.name)) continue
65
+ let st
66
+ try { st = fs.statSync(abs) } catch { continue }
67
+ if (st.size > MAX_FILE) continue
68
+ if (++count > MAX_FILES || (total += st.size) > MAX_TOTAL) return false
69
+ out[r] = abs
70
+ }
71
+ return true
72
+ }
73
+ return walk(root, '', 0) ? out : null
74
+ }
75
+
76
+ /** 两版之间改了哪些文件(新增、改了、删了) */
77
+ export function diffFiles(a = {}, b = {}) {
78
+ const changed = []
79
+ for (const k of Object.keys(b)) if (a[k] !== b[k]) changed.push(k)
80
+ for (const k of Object.keys(a)) if (!(k in b)) changed.push(k)
81
+ return changed.sort()
82
+ }
83
+
84
+ /**
85
+ * @param opts.base 存放目录
86
+ * @param opts.now 取时间(测试用)
87
+ */
88
+ export function createHistory({ base, now = Date.now, log = () => {} }) {
89
+ const dirOf = (root) => path.join(base, sha1(path.resolve(root)).slice(0, 16))
90
+ const sigs = new Map() // root → 上次存的时候各文件的大小 + 修改时间(没变就不用再读一遍算哈希)
91
+
92
+ function load(root) {
93
+ try {
94
+ const list = JSON.parse(fs.readFileSync(path.join(dirOf(root), 'index.json'), 'utf8'))
95
+ return Array.isArray(list) ? list : []
96
+ } catch { return [] }
97
+ }
98
+
99
+ function save(root, list) {
100
+ const dir = dirOf(root)
101
+ fs.mkdirSync(dir, { recursive: true })
102
+ const tmp = path.join(dir, `index.json.${process.pid}.tmp`)
103
+ fs.writeFileSync(tmp, JSON.stringify(list))
104
+ fs.renameSync(tmp, path.join(dir, 'index.json'))
105
+ }
106
+
107
+ /** 旧版本删掉后,没人用的文件内容一起清掉 */
108
+ function gc(root, list) {
109
+ const keep = new Set()
110
+ for (const v of list) for (const h of Object.values(v.files)) keep.add(h)
111
+ const objDir = path.join(dirOf(root), 'objects')
112
+ let names = []
113
+ try { names = fs.readdirSync(objDir) } catch { return }
114
+ for (const n of names) if (!keep.has(n)) fs.rmSync(path.join(objDir, n), { force: true })
115
+ }
116
+
117
+ /**
118
+ * 存一版:和最近一版一模一样就不存。
119
+ * @returns 新的一版 { n, at, label, files } / null(没变、不像设计目录、出错)
120
+ */
121
+ function snapshot(root, label = '') {
122
+ try {
123
+ return take(root, label, false)
124
+ } catch (e) {
125
+ log(e)
126
+ return null
127
+ }
128
+ }
129
+
130
+ /** snapshot 的本体:出错直接抛;strict 时目录太大(存不下)也抛 */
131
+ function take(root, label, strict) {
132
+ const found = collect(root)
133
+ if (!found && strict) throw new Error('目录太大(多半是整个项目),存不了现在的样子')
134
+ if (!found || !Object.keys(found).length) return null
135
+ let sig = ''
136
+ for (const [rel, abs] of Object.entries(found)) {
137
+ try { const st = fs.statSync(abs); sig += `${rel}:${st.size}:${st.mtimeMs}\n` } catch { /* 刚删 */ }
138
+ }
139
+ if (sigs.get(root) === sig && load(root).length) return null
140
+ const objDir = path.join(dirOf(root), 'objects')
141
+ fs.mkdirSync(objDir, { recursive: true })
142
+ const files = {}
143
+ for (const [rel, abs] of Object.entries(found)) {
144
+ let buf
145
+ try { buf = fs.readFileSync(abs) } catch { continue }
146
+ const h = sha1(buf)
147
+ const obj = path.join(objDir, h)
148
+ if (!fs.existsSync(obj)) fs.writeFileSync(obj, buf)
149
+ files[rel] = h
150
+ }
151
+ const list = load(root)
152
+ const last = list[list.length - 1]
153
+ if (last && !diffFiles(last.files, files).length) { sigs.set(root, sig); return null }
154
+ const v = { n: last ? last.n + 1 : 1, at: now(), label: String(label || '').slice(0, 80), files }
155
+ list.push(v)
156
+ const cut = list.length > MAX_VERSIONS ? list.splice(0, list.length - MAX_VERSIONS) : []
157
+ save(root, list)
158
+ sigs.set(root, sig) // 真存上了才记(存到一半出错,下次还得重存)
159
+ if (cut.length) { try { gc(root, list) } catch (e) { log(e) } }
160
+ return v
161
+ }
162
+
163
+ /** 给右侧栏的版本列表:新的在前,带改了哪些文件(最早的一版带一共几个文件) */
164
+ function list(root) {
165
+ const all = load(root)
166
+ return all.map((v, i) => ({
167
+ n: v.n,
168
+ at: v.at,
169
+ label: v.label,
170
+ changed: i ? diffFiles(all[i - 1].files, v.files) : [], // 最早的一版没得比
171
+ first: !i,
172
+ count: Object.keys(v.files).length,
173
+ screens: Object.keys(v.files).filter((f) => /^[^/]+\.html?$/i.test(f)),
174
+ })).reverse()
175
+ }
176
+
177
+ /** 某一版里某个文件存在哪(给预览服务器读);没有给 null */
178
+ function fileOf(root, n, rel) {
179
+ const v = load(root).find((x) => x && x.n === Number(n))
180
+ if (!v) return null
181
+ const key = String(rel || '').replace(/^\/+/, '') || 'index.html'
182
+ const h = hashOf(v, key) || (key.endsWith('/') ? hashOf(v, key + 'index.html') : null)
183
+ return h ? path.join(dirOf(root), 'objects', h) : null
184
+ }
185
+
186
+ /**
187
+ * 回到某一版:先把那一版的文件全读出来,再把现在的存一版(随时能再切回来),最后写回目录。
188
+ * 先读:存「回退前」那一版时满 40 版会裁掉最旧的,要回的正好是它的话内容会被一起清掉。
189
+ * 现在的存不下(目录太大、出错)就不回,免得盖掉没存过的改动;
190
+ * 要被盖掉的文件有没进存档的(单个超过 2MB、读不了)也整次不回,说明是哪几个。
191
+ * 那一版之后新加的文件不删;落到目录外的(父目录是指到外面的软链接)不写。
192
+ * @returns { restored: 文件数, saved: 回退前存的那一版 | null }
193
+ */
194
+ function restore(root, n) {
195
+ const v = load(root).find((x) => x && x.n === Number(n))
196
+ if (!v) throw new Error(`没有第 ${n} 版`)
197
+ const objDir = path.join(dirOf(root), 'objects')
198
+ const wanted = []
199
+ for (const rel of Object.keys(v.files || {})) {
200
+ const h = hashOf(v, rel)
201
+ let buf = null
202
+ try { buf = h && fs.readFileSync(path.join(objDir, h)) } catch { buf = null }
203
+ if (!buf) throw new Error(`第 ${n} 版的文件不全(${rel}),没有回退`)
204
+ wanted.push([rel, buf])
205
+ }
206
+ let saved
207
+ try {
208
+ saved = take(root, '回退前', true)
209
+ } catch (e) {
210
+ log(e)
211
+ throw new Error(`回退前没能先存一份现在的样子(${e.message}),没有回退`)
212
+ }
213
+ const realRoot = fs.realpathSync(root)
214
+ const inside = (p) => p === realRoot || p.startsWith(realRoot + path.sep)
215
+ const latest = load(root).at(-1) // 现在的样子(刚存的「回退前」,或和它一样的上一版)
216
+ const plan = []
217
+ const unsaved = []
218
+ for (const [rel, buf] of wanted) {
219
+ const dest = path.resolve(realRoot, rel)
220
+ if (!dest.startsWith(realRoot + path.sep)) continue // 不往目录外写
221
+ try { if (fs.lstatSync(dest).isSymbolicLink()) continue } catch { /* 现在没有这个文件 */ }
222
+ const parent = realParent(dest)
223
+ if (!parent || !inside(parent)) continue // 父目录是指到外面的软链接:不顺着写出去
224
+ let cur = null
225
+ try { cur = fs.readFileSync(dest) } catch { /* 现在没有这个文件 */ }
226
+ if (cur && cur.equals(buf)) continue
227
+ // 要盖掉的内容得在存档里(超过 2MB 的文件存档时被跳过,盖了就找不回来)
228
+ if (cur && hashOf(latest, rel) !== sha1(cur)) unsaved.push(rel)
229
+ plan.push([dest, buf])
230
+ }
231
+ if (unsaved.length) {
232
+ throw new Error(`${unsaved.slice(0, 5).join('、')}${unsaved.length > 5 ? ` 等 ${unsaved.length} 个文件` : ''}现在的内容没法存进版本(单个超过 2MB 或读不了),回退会把它盖掉、找不回来,所以没有回退`)
233
+ }
234
+ let restored = 0
235
+ for (const [dest, buf] of plan) {
236
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
237
+ fs.writeFileSync(dest, buf)
238
+ restored++
239
+ }
240
+ return { restored, saved }
241
+ }
242
+
243
+ return { snapshot, list, fileOf, restore, dirOf }
244
+ }