fish-dock-plugin-filemgr 0.0.0-stage → 1.0.4

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.
@@ -0,0 +1,33 @@
1
+ # 弹出 Windows 原生"选择文件"多选对话框,把选中的绝对路径(每行一个)写到 stdout。
2
+ # 取消或出错时不输出任何内容,退出码 2。
3
+ # 使用 FolderPicker.cs 的 IFileOpenDialog + FOS_ALLOWMULTISELECT,
4
+ # 与 pick-folder.ps1 一致(同一套 COM 代码,已验证能弹窗在前台)。
5
+ param(
6
+ [string]$Path = '',
7
+ [string]$Title = '选择文件'
8
+ )
9
+
10
+ # 关键:同 pick-folder.ps1,中文路径必须用 UTF-8 输出
11
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
12
+ $ErrorActionPreference = 'Stop'
13
+ $here = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
14
+
15
+ try {
16
+ if (-not ('FishDockPick.FolderPicker' -as [type])) {
17
+ . (Join-Path $here 'FolderPicker.ps1')
18
+ Add-Type -TypeDefinition $FolderPickerSource -Language CSharp
19
+ }
20
+ if ([Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') {
21
+ [Threading.Thread]::CurrentThread.SetApartmentState('STA')
22
+ }
23
+ $picked = [FishDockPick.FolderPicker]::PickFiles($Path, $Title)
24
+ } catch {
25
+ [Console]::Error.Write($_.Exception.Message)
26
+ exit 2
27
+ }
28
+
29
+ if ($null -eq $picked -or $picked.Count -eq 0) { exit 2 }
30
+ foreach ($p in $picked) {
31
+ [Console]::Out.WriteLine($p)
32
+ }
33
+ exit 0
@@ -0,0 +1,34 @@
1
+ # 弹出 Windows 原生"选择文件夹"对话框,把选中的绝对路径写到 stdout。
2
+ # 取消或出错时不输出任何内容,退出码 2。
3
+ # 用 IFileOpenDialog + FOS_PICKFOLDERS(见同目录 FolderPicker.cs),
4
+ # 与 FISH Dock 宿主 paper.rs:588 是同一个 COM 接口,外观即系统"选择文件夹"。
5
+ param(
6
+ [string]$Initial = '',
7
+ [string]$Title = '选择文件夹'
8
+ )
9
+
10
+ # 关键:PowerShell 默认按控制台代码页(中文系统是 GBK/936)往 stdout 写字节,
11
+ # 而 Node 端按 UTF-8 解码 —— 中文路径会整段变成乱码,导致选完的文件夹既存不进
12
+ # 根目录列表(fs.statSync 失败被过滤),也打不开(tree 报「不在允许的根目录内」)。
13
+ # 所以必须在输出前把 Console 的输出编码钉成无 BOM 的 UTF-8。
14
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
15
+ $ErrorActionPreference = 'Stop'
16
+ $here = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
17
+
18
+ try {
19
+ if (-not ('FishDockPick.FolderPicker' -as [type])) {
20
+ . (Join-Path $here 'FolderPicker.ps1')
21
+ Add-Type -TypeDefinition $FolderPickerSource -Language CSharp
22
+ }
23
+ if ([Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') {
24
+ [Threading.Thread]::CurrentThread.SetApartmentState('STA')
25
+ }
26
+ $picked = [FishDockPick.FolderPicker]::Pick($Initial, $Title)
27
+ } catch {
28
+ [Console]::Error.Write($_.Exception.Message)
29
+ exit 2
30
+ }
31
+
32
+ if ([string]::IsNullOrWhiteSpace($picked)) { exit 2 }
33
+ [Console]::Out.Write($picked)
34
+ exit 0
@@ -0,0 +1,456 @@
1
+ // 文件读取与解析:所有路径先过白名单,再交给宿主页面渲染。
2
+ // 不发起任何网络请求,不启动子进程;纯 fs + zlib。
3
+ import fs from 'node:fs'
4
+ import fsp from 'node:fs/promises'
5
+ import path from 'node:path'
6
+ import os from 'node:os'
7
+ import zlib from 'node:zlib'
8
+
9
+ // 协议单帧上限 1 MB,这里留出 JSON 包装与转义的余量:1 MB 正文编码后必然超帧,
10
+ // 会导致 SDK 拒绝发送(旧版本甚至因此 close() 自杀)。900 KB 是安全值。
11
+ export const MAX_TEXT = 800 * 1024
12
+ export const MAX_ENTRIES = 2000 // 目录列表条目上限,防止超大目录把结果撑爆协议帧
13
+ export const MAX_OFFICE = 200 * 1024 * 1024 // office/zip 是整文件读入内存,超过 200 MB 直接拒绝
14
+ export const MAX_RAW = 360 * 1024 // 单帧 base64 后约 480KB,留在 1MB 协议帧以内
15
+
16
+ const MIME = {
17
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
18
+ '.webp': 'image/webp', '.bmp': 'image/bmp', '.avif': 'image/avif', '.ico': 'image/x-icon',
19
+ '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.mp4': 'video/mp4', '.webm': 'video/webm',
20
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac',
21
+ '.woff2': 'font/woff2', '.ttf': 'font/ttf',
22
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
23
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
24
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
25
+ '.zip': 'application/zip',
26
+ '.md': 'text/markdown', '.markdown': 'text/markdown', '.mdown': 'text/markdown',
27
+ '.txt': 'text/plain', '.log': 'text/plain', '.csv': 'text/csv', '.tsv': 'text/tab-separated-values',
28
+ '.json': 'application/json', '.jsonc': 'application/json', '.html': 'text/html', '.htm': 'text/html',
29
+ '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.cjs': 'text/javascript',
30
+ '.xml': 'application/xml', '.yml': 'application/yaml', '.yaml': 'application/yaml',
31
+ '.toml': 'application/toml', '.ini': 'text/plain', '.svg': 'image/svg+xml',
32
+ }
33
+
34
+ const MD_EXT = new Set(['md', 'markdown', 'mdown', 'mkd'])
35
+ const CODE_EXT = new Set([
36
+ 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'json', 'jsonc', 'css', 'scss', 'less', 'html', 'htm',
37
+ 'xml', 'yml', 'yaml', 'toml', 'ini', 'conf', 'env', 'sh', 'bash', 'zsh', 'ps1', 'psm1', 'bat',
38
+ 'cmd', 'py', 'rb', 'go', 'rs', 'java', 'kt', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'php', 'swift',
39
+ 'lua', 'pl', 'r', 'dart', 'sql', 'graphql', 'vue', 'svelte', 'diff', 'patch',
40
+ ])
41
+ const TEXT_EXT = new Set(['txt', 'text', 'csv', 'tsv', 'rtf', 'srt', 'vtt', 'nfo', 'readme', 'license', 'log'])
42
+
43
+ export function mimeOf(file) {
44
+ return MIME[path.extname(file).toLowerCase()] || 'application/octet-stream'
45
+ }
46
+
47
+ export function kindOf(ext) {
48
+ const e = String(ext || '').toLowerCase()
49
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'ico', 'svg'].includes(e)) return 'image'
50
+ if (e === 'pdf') return 'pdf'
51
+ if (['mp4', 'webm', 'mkv', 'mov', 'avi'].includes(e)) return 'video'
52
+ if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(e)) return 'audio'
53
+ if (['docx', 'xlsx', 'pptx', 'zip'].includes(e)) return 'office'
54
+ if (MD_EXT.has(e)) return 'markdown'
55
+ if (e === 'html' || e === 'htm') return 'html'
56
+ if (e === 'json') return 'json'
57
+ if (CODE_EXT.has(e)) return 'code'
58
+ if (TEXT_EXT.has(e)) return 'text'
59
+ return 'unsupported'
60
+ }
61
+
62
+ function looksBinary(buf) {
63
+ const n = Math.min(buf.length, 8192)
64
+ if (!n) return false
65
+ let bad = 0
66
+ for (let i = 0; i < n; i++) {
67
+ const b = buf[i]
68
+ if (b === 0) return true
69
+ if (b < 7 || (b > 13 && b < 32)) bad++
70
+ }
71
+ return bad / n > 0.3
72
+ }
73
+
74
+ function decode(buf) {
75
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
76
+ return { text: buf.subarray(3).toString('utf8'), encoding: 'utf8-bom' }
77
+ }
78
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
79
+ return { text: buf.subarray(2).toString('utf16le'), encoding: 'utf16le' }
80
+ }
81
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
82
+ const swapped = Buffer.from(buf.subarray(2))
83
+ swapped.swap16()
84
+ return { text: swapped.toString('utf16le'), encoding: 'utf16be' }
85
+ }
86
+ const utf8 = buf.toString('utf8')
87
+ if (!utf8.includes('\ufffd')) return { text: utf8, encoding: 'utf8' }
88
+ return { text: buf.toString('latin1'), encoding: 'latin1' }
89
+ }
90
+
91
+ // ---------------------------------------------------------------- 根目录白名单
92
+
93
+ function windowsDrives() {
94
+ const out = []
95
+ for (let c = 65; c <= 90; c++) {
96
+ const d = String.fromCharCode(c) + ':\\'
97
+ try { fs.accessSync(d); out.push({ label: d.slice(0, 1) + ':', path: d }) } catch {}
98
+ }
99
+ return out
100
+ }
101
+
102
+ // 打开文件夹时的默认起始位置;存在的排在最前,成为首页默认目录。
103
+ const PREFERRED = ['F:\\AI REP']
104
+
105
+ export function roots(extra = []) {
106
+ const list = []
107
+ const seen = new Set()
108
+ const push = (label, p) => {
109
+ if (!p) return
110
+ let abs
111
+ try { abs = path.resolve(p) } catch { return }
112
+ const key = abs.toLowerCase()
113
+ if (seen.has(key)) return
114
+ try { if (!fs.statSync(abs).isDirectory()) return } catch { return }
115
+ seen.add(key)
116
+ list.push({ label, path: abs })
117
+ }
118
+ for (const dir of PREFERRED) push(dir, dir)
119
+ for (const d of windowsDrives()) push(d.label, d.path)
120
+ push('用户目录', os.homedir())
121
+ push('桌面', path.join(os.homedir(), 'Desktop'))
122
+ push('文档', path.join(os.homedir(), 'Documents'))
123
+ push('下载', path.join(os.homedir(), 'Downloads'))
124
+ for (const dir of extra) push(dir, dir)
125
+ return list
126
+ }
127
+
128
+ function insideRoot(abs, list) {
129
+ const low = abs.toLowerCase()
130
+ return list.some(r => {
131
+ const rl = r.path.toLowerCase()
132
+ if (low === rl) return true
133
+ return low.startsWith(rl.endsWith('\\') ? rl : rl + '\\')
134
+ })
135
+ }
136
+
137
+ export function resolveSafe(input, list) {
138
+ if (!input || typeof input !== 'string') throw new Error('缺少路径参数')
139
+ const raw = input.trim()
140
+ if (!raw) throw new Error('路径为空')
141
+ if (raw.startsWith('\\\\')) throw new Error('不支持 UNC 路径')
142
+ const abs = path.resolve(raw)
143
+ if (!insideRoot(abs, list)) throw new Error('该路径不在允许的根目录内:' + abs)
144
+ return abs
145
+ }
146
+
147
+ // ---------------------------------------------------------------- zip(docx/xlsx/pptx)
148
+
149
+ export function readZip(abs) {
150
+ const buf = fs.readFileSync(abs)
151
+ let eocd = -1
152
+ const from = Math.max(0, buf.length - 65558)
153
+ for (let i = buf.length - 22; i >= from; i--) {
154
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break }
155
+ }
156
+ if (eocd < 0) throw new Error('不是有效的 zip 容器')
157
+ const count = buf.readUInt16LE(eocd + 10)
158
+ let p = buf.readUInt32LE(eocd + 16)
159
+ const entries = new Map()
160
+ for (let i = 0; i < count; i++) {
161
+ if (p + 46 > buf.length || buf.readUInt32LE(p) !== 0x02014b50) break
162
+ const method = buf.readUInt16LE(p + 10)
163
+ const compSize = buf.readUInt32LE(p + 20)
164
+ const nameLen = buf.readUInt16LE(p + 28)
165
+ const extraLen = buf.readUInt16LE(p + 30)
166
+ const commentLen = buf.readUInt16LE(p + 32)
167
+ const localOff = buf.readUInt32LE(p + 42)
168
+ const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8')
169
+ entries.set(name, { method, compSize, localOff })
170
+ p += 46 + nameLen + extraLen + commentLen
171
+ }
172
+ function read(name) {
173
+ const e = entries.get(name)
174
+ if (!e) return null
175
+ const lh = e.localOff
176
+ if (buf.readUInt32LE(lh) !== 0x04034b50) return null
177
+ const nameLen = buf.readUInt16LE(lh + 26)
178
+ const extraLen = buf.readUInt16LE(lh + 28)
179
+ const start = lh + 30 + nameLen + extraLen
180
+ const raw = buf.subarray(start, start + e.compSize)
181
+ if (e.method === 0) return raw
182
+ if (e.method === 8) { try { return zlib.inflateRawSync(raw) } catch { return null } }
183
+ return null
184
+ }
185
+ return { names: [...entries.keys()], read }
186
+ }
187
+
188
+ const unxml = s => s
189
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
190
+ .replace(/&quot;/g, '"').replace(/&apos;/g, "'")
191
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
192
+ .replace(/&amp;/g, '&')
193
+
194
+ function docxToBlocks(zip) {
195
+ const xml = zip.read('word/document.xml')
196
+ if (!xml) throw new Error('docx 里没有 word/document.xml')
197
+ const text = xml.toString('utf8')
198
+ const blocks = []
199
+ const paraRe = /<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g
200
+ let m
201
+ while ((m = paraRe.exec(text))) {
202
+ const inner = m[1]
203
+ const style = /<w:pStyle\s+w:val="([^"]+)"/.exec(inner)
204
+ let line = ''
205
+ const runRe = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\/>|<w:br\/>/g
206
+ let r
207
+ while ((r = runRe.exec(inner))) {
208
+ if (r[0] === '<w:tab/>') line += '\t'
209
+ else if (r[0] === '<w:br/>') line += '\n'
210
+ else line += unxml(r[1])
211
+ }
212
+ const s = style ? style[1] : ''
213
+ const level = /^Heading([1-6])$/i.exec(s) || /^([1-6])$/.exec(s)
214
+ if (level) blocks.push({ type: 'heading', level: Number(level[1]), text: line.trim() })
215
+ else if (line.trim()) blocks.push({ type: 'para', text: line })
216
+ else blocks.push({ type: 'gap' })
217
+ }
218
+ return blocks
219
+ }
220
+
221
+ function pptxToBlocks(zip) {
222
+ const slides = zip.names
223
+ .filter(n => /^ppt\/slides\/slide\d+\.xml$/.test(n))
224
+ .sort((a, b) => Number(/(\d+)/.exec(a)[1]) - Number(/(\d+)/.exec(b)[1]))
225
+ const out = []
226
+ for (const name of slides) {
227
+ const xml = zip.read(name)
228
+ if (!xml) continue
229
+ const lines = []
230
+ const re = /<a:t>([\s\S]*?)<\/a:t>/g
231
+ let m
232
+ while ((m = re.exec(xml.toString('utf8')))) {
233
+ const t = unxml(m[1]).trim()
234
+ if (t) lines.push(t)
235
+ }
236
+ out.push({ index: Number(/(\d+)/.exec(name)[1]), lines })
237
+ }
238
+ return out
239
+ }
240
+
241
+ function colToIndex(ref) {
242
+ const m = /^([A-Z]+)/.exec(ref || '')
243
+ if (!m) return 0
244
+ let n = 0
245
+ for (const ch of m[1]) n = n * 26 + (ch.charCodeAt(0) - 64)
246
+ return n - 1
247
+ }
248
+
249
+ function xlsxToSheets(zip) {
250
+ const shared = []
251
+ const ss = zip.read('xl/sharedStrings.xml')
252
+ if (ss) {
253
+ const re = /<si>([\s\S]*?)<\/si>/g
254
+ let m
255
+ while ((m = re.exec(ss.toString('utf8')))) {
256
+ let line = ''
257
+ const tr = /<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g
258
+ let r
259
+ while ((r = tr.exec(m[1]))) line += unxml(r[1])
260
+ shared.push(line)
261
+ }
262
+ }
263
+ const wb = zip.read('xl/workbook.xml')
264
+ const rels = zip.read('xl/_rels/workbook.xml.rels')
265
+ const relMap = new Map()
266
+ if (rels) {
267
+ const re = /<Relationship\b[^>]*Id="([^"]+)"[^>]*Target="([^"]+)"/g
268
+ let m
269
+ while ((m = re.exec(rels.toString('utf8')))) relMap.set(m[1], m[2])
270
+ }
271
+ const meta = []
272
+ if (wb) {
273
+ const re = /<sheet\b[^>]*name="([^"]*)"[^>]*r:id="([^"]+)"/g
274
+ let m
275
+ while ((m = re.exec(wb.toString('utf8')))) {
276
+ let target = relMap.get(m[2]) || ''
277
+ if (target.startsWith('/')) target = target.slice(1)
278
+ else if (target && !target.startsWith('xl/')) target = 'xl/' + target
279
+ meta.push({ name: unxml(m[1]), file: target })
280
+ }
281
+ }
282
+ const valid = meta.filter(s => s.file && zip.read(s.file))
283
+ let sheetsMeta
284
+ if (valid.length) sheetsMeta = valid
285
+ else {
286
+ sheetsMeta = zip.names
287
+ .filter(n => /^xl\/worksheets\/sheet\d+\.xml$/.test(n)).sort()
288
+ .map(n => ({ name: n.replace(/^.*\/|\.xml$/g, ''), file: n }))
289
+ }
290
+ const sheets = []
291
+ for (const m of sheetsMeta) {
292
+ const xml = zip.read(m.file)
293
+ if (!xml) continue
294
+ const t = xml.toString('utf8')
295
+ const rows = []
296
+ const rowRe = /<row\b[^>]*>([\s\S]*?)<\/row>/g
297
+ let rm
298
+ while ((rm = rowRe.exec(t))) {
299
+ const cells = []
300
+ const cellRe = /<c\b([^>]*)>([\s\S]*?)<\/c>|<c\b([^>]*)\/>/g
301
+ let cm
302
+ while ((cm = cellRe.exec(rm[1]))) {
303
+ if (cm[3] !== undefined) {
304
+ const ref = /\br="([^"]+)"/.exec(cm[3])
305
+ if (ref) cells[colToIndex(ref[1])] = ''
306
+ continue
307
+ }
308
+ const attrs = cm[1], body = cm[2] || ''
309
+ const ref = /\br="([^"]+)"/.exec(attrs)
310
+ const type = /\bt="([^"]+)"/.exec(attrs)
311
+ const idx = ref ? colToIndex(ref[1]) : cells.length
312
+ let value = ''
313
+ if (type && type[1] === 's') {
314
+ const v = /<v>([\s\S]*?)<\/v>/.exec(body)
315
+ if (v) value = shared[Number(v[1])] ?? ''
316
+ } else if (type && type[1] === 'inlineStr') {
317
+ const is = /<is>([\s\S]*?)<\/is>/.exec(body)
318
+ if (is) {
319
+ const tr = /<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g
320
+ let r
321
+ while ((r = tr.exec(is[1]))) value += unxml(r[1])
322
+ }
323
+ } else {
324
+ const v = /<v>([\s\S]*?)<\/v>/.exec(body)
325
+ if (v) value = unxml(v[1])
326
+ }
327
+ cells[idx] = value
328
+ }
329
+ if (cells.length) rows.push(Array.from(cells, c => c ?? ''))
330
+ }
331
+ sheets.push({ name: m.name, rows })
332
+ }
333
+ return sheets
334
+ }
335
+
336
+ // ---------------------------------------------------------------- 对外读取接口
337
+
338
+ async function readRange(abs, start, end) {
339
+ const fh = await fsp.open(abs, 'r')
340
+ try {
341
+ const len = end - start + 1
342
+ const buf = Buffer.alloc(len)
343
+ await fh.read(buf, 0, len, start)
344
+ return buf
345
+ } finally { await fh.close() }
346
+ }
347
+
348
+ export async function tree(input, list) {
349
+ const abs = resolveSafe(input, list)
350
+ const st = await fsp.stat(abs)
351
+ if (!st.isDirectory()) throw new Error('不是目录:' + abs)
352
+ const dirents = await fsp.readdir(abs, { withFileTypes: true })
353
+ const limited = dirents.length > MAX_ENTRIES
354
+ const entries = []
355
+ for (const d of dirents) {
356
+ if (entries.length >= MAX_ENTRIES) break
357
+ let isDir = d.isDirectory(), size = 0, mtime = 0
358
+ try {
359
+ const s = await fsp.stat(path.join(abs, d.name))
360
+ isDir = s.isDirectory(); size = isDir ? 0 : s.size; mtime = s.mtimeMs
361
+ } catch { continue }
362
+ const full = path.join(abs, d.name)
363
+ entries.push({
364
+ name: d.name, path: full, isDir, size, mtime,
365
+ ext: isDir ? '' : path.extname(d.name).slice(1).toLowerCase(),
366
+ })
367
+ }
368
+ entries.sort((a, b) => {
369
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
370
+ return a.name.localeCompare(b.name, 'zh-CN', { numeric: true })
371
+ })
372
+ const parent = path.dirname(abs)
373
+ const atRoot = list.some(r => path.resolve(r.path).toLowerCase() === abs.toLowerCase())
374
+ return { path: abs, parent: parent === abs || atRoot ? null : parent, entries, truncated: limited, total: dirents.length }
375
+ }
376
+
377
+ export async function meta(input, list) {
378
+ const abs = resolveSafe(input, list)
379
+ const s = await fsp.stat(abs)
380
+ const isDir = s.isDirectory()
381
+ const ext = isDir ? '' : path.extname(abs).slice(1).toLowerCase()
382
+ return {
383
+ name: path.basename(abs), path: abs, isDir,
384
+ size: isDir ? 0 : s.size, mtime: s.mtimeMs, ext,
385
+ kind: isDir ? 'dir' : kindOf(ext), mime: isDir ? '' : mimeOf(abs),
386
+ }
387
+ }
388
+
389
+ export async function text(input, list) {
390
+ const abs = resolveSafe(input, list)
391
+ const st = await fsp.stat(abs)
392
+ if (st.isDirectory()) throw new Error('这是目录,不是文件')
393
+ const ext = path.extname(abs).slice(1).toLowerCase()
394
+ let kind = kindOf(ext)
395
+ const head = await readRange(abs, 0, Math.max(0, Math.min(st.size, 8192) - 1))
396
+ if (kind === 'unsupported' && !looksBinary(head)) kind = 'text'
397
+ if (kind === 'unsupported' || ['image', 'pdf', 'video', 'audio', 'office'].includes(kind)) {
398
+ return { kind: kind === 'unsupported' ? 'binary' : kind, content: '', truncated: false, size: st.size, mtime: st.mtimeMs, encoding: 'binary' }
399
+ }
400
+ const readLen = Math.min(st.size, MAX_TEXT)
401
+ const buf = readLen ? await readRange(abs, 0, readLen - 1) : Buffer.alloc(0)
402
+ const { text: body, encoding } = decode(buf)
403
+ return {
404
+ kind, content: body, truncated: st.size > readLen, limit: MAX_TEXT,
405
+ size: st.size, mtime: st.mtimeMs, lang: ext === 'htm' ? 'html' : ext, encoding,
406
+ }
407
+ }
408
+
409
+ // docx 段落文本没有天然上限,一本厚教材解压后的纯文本可能几 MB。
410
+ // 超过协议帧预算前先在段落级截断,页面还能正常显示前文,而不是整条命令失败。
411
+ const BLOCK_TEXT_BUDGET = 480 * 1024
412
+ function limitBlocks(blocks) {
413
+ let used = 0
414
+ const out = []
415
+ for (const b of blocks) {
416
+ const len = (b && typeof b.text === 'string') ? b.text.length : 0
417
+ if (used + len > BLOCK_TEXT_BUDGET) {
418
+ out.push({ type: 'para', text: `……内容过长,后面 ${blocks.length - out.length} 段已省略(单次最多显示约 ${Math.round(BLOCK_TEXT_BUDGET / 1024)} KB 文字)` })
419
+ return { blocks: out, truncated: true }
420
+ }
421
+ used += len
422
+ out.push(b)
423
+ }
424
+ return { blocks: out, truncated: false }
425
+ }
426
+
427
+ export async function office(input, list) {
428
+ const abs = resolveSafe(input, list)
429
+ const st = await fsp.stat(abs)
430
+ if (st.isDirectory()) throw new Error('这是目录')
431
+ // readZip 是整文件读入内存,超大容器会直接把插件进程 OOM 掉,先挡住。
432
+ if (st.size > MAX_OFFICE) throw new Error(`文件过大(${Math.round(st.size / 1024 / 1024)} MB),暂不支持解析`)
433
+ const ext = path.extname(abs).slice(1).toLowerCase()
434
+ const zip = readZip(abs)
435
+ if (ext === 'docx') { const r = limitBlocks(docxToBlocks(zip)); return { kind: 'docx', size: st.size, mtime: st.mtimeMs, blocks: r.blocks, truncated: r.truncated } }
436
+ if (ext === 'pptx') return { kind: 'pptx', size: st.size, mtime: st.mtimeMs, slides: pptxToBlocks(zip) }
437
+ if (ext === 'xlsx') return { kind: 'xlsx', size: st.size, mtime: st.mtimeMs, sheets: xlsxToSheets(zip) }
438
+ const names = zip.names.filter(n => !n.endsWith('/')).slice(0, 3000)
439
+ return { kind: 'zip', size: st.size, mtime: st.mtimeMs, total: zip.names.length, names }
440
+ }
441
+
442
+ // 二进制分块:offset/limit 由页面控制,单块不超过 MAX_RAW
443
+ export async function rawChunk(input, list, offset = 0, limit = MAX_RAW) {
444
+ const abs = resolveSafe(input, list)
445
+ const st = await fsp.stat(abs)
446
+ if (st.isDirectory()) throw new Error('这是目录,不是文件')
447
+ const start = Math.max(0, Math.min(Number(offset) || 0, st.size))
448
+ const len = Math.max(1, Math.min(Number(limit) || MAX_RAW, MAX_RAW, st.size - start))
449
+ const buf = start < st.size ? await readRange(abs, start, start + len - 1) : Buffer.alloc(0)
450
+ return {
451
+ offset: start, size: st.size, length: buf.length,
452
+ done: start + buf.length >= st.size,
453
+ mime: mimeOf(abs),
454
+ base64: buf.toString('base64'),
455
+ }
456
+ }
@@ -0,0 +1,123 @@
1
+ // FISH Dock 插件协议 v1。stdout 专用于协议,业务日志请写 stderr。
2
+ const CATEGORIES = new Set(['credentials','roster','keys','config','exports','backups','tmp']);
3
+ const DEFAULT_LIMIT = 1024 * 1024;
4
+ export function createPlugin({ id, input = process.stdin, output = process.stdout,
5
+ activate = async () => {}, deactivate = async () => {}, onEvent = async () => {}, commands = {},
6
+ onError = () => {}, maxLine = DEFAULT_LIMIT, requestTimeout = 5000 } = {}) {
7
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id ?? '')) throw new Error('插件 ID 格式无效');
8
+ if (!Number.isSafeInteger(maxLine) || maxLine < 128 || maxLine > DEFAULT_LIMIT) throw new Error('协议帧大小无效');
9
+ if (!Number.isSafeInteger(requestTimeout) || requestTimeout < 1 || requestTimeout > 30000) throw new Error('宿主调用时限无效');
10
+ let buffer = Buffer.alloc(0), closed = false, active = false, hello = null;
11
+ let sequence = 0, queued = 0, chain = Promise.resolve();
12
+ const pending = new Map();
13
+ function close() {
14
+ if (closed) return;
15
+ closed = true; active = false;
16
+ input.off('data', receive); input.off('end', ended); input.off('error', failed);
17
+ output.off('error', failed);
18
+ input.pause();
19
+ for (const { reject, timer } of pending.values()) { clearTimeout(timer); reject(new Error('插件会话已关闭')); }
20
+ pending.clear(); buffer = Buffer.alloc(0);
21
+ }
22
+ function failed() { close(); onError(new Error('插件通信已中断')); }
23
+ function send(value) {
24
+ if (closed) throw new Error('插件会话已关闭');
25
+ const bytes = Buffer.from(JSON.stringify(value) + '\n');
26
+ if (bytes.length > maxLine || output.writableLength > maxLine * 4) { close(); throw new Error('插件通信队列或帧超过限制'); }
27
+ output.write(bytes);
28
+ }
29
+ function host(method, params) {
30
+ if (!hello?.capabilities.includes('storage')) return Promise.reject(new Error('插件未声明存储能力'));
31
+ if (closed || pending.size >= 64) return Promise.reject(new Error('宿主请求队列已满或已关闭'));
32
+ const requestId = `sdk-${++sequence}`;
33
+ return new Promise((resolve, reject) => {
34
+ const timer = setTimeout(() => { pending.delete(requestId); reject(new Error('宿主调用超时')); }, requestTimeout);
35
+ pending.set(requestId, { resolve, reject, timer });
36
+ try { send({ id: requestId, method, params }); }
37
+ catch (error) { clearTimeout(timer); pending.delete(requestId); reject(error); }
38
+ });
39
+ }
40
+ function file(category, name) {
41
+ if (!CATEGORIES.has(category) || typeof name !== 'string' || name.length > 128 || !/^[a-zA-Z0-9_.-]+$/.test(name)
42
+ || name.includes('..') || name.toLowerCase() === 'readme.md' || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)) throw new Error('存储类别或文件名无效');
43
+ return { category, name };
44
+ }
45
+ const context = Object.freeze({
46
+ get dataDirectory() { return hello?.dataDirectory; },
47
+ get storageIdentity() { return hello?.storage; },
48
+ secrets: Object.freeze({
49
+ async read(category, name) {
50
+ const params=file(category,name);if(!['credentials','keys'].includes(category))throw new Error('加密凭据仅允许 credentials 或 keys 类别');
51
+ return (await host('host.storage.readSecret',params)).value;
52
+ },
53
+ async write(category,name,value) {
54
+ const params=file(category,name);if(!['credentials','keys'].includes(category)||typeof value!=='string'||Buffer.byteLength(value)>16384)throw new Error('加密凭据类别或大小无效');
55
+ await host('host.storage.writeSecret',{...params,value});
56
+ },
57
+ }),
58
+ storage: Object.freeze({
59
+ async read(category, name) { const result = await host('host.storage.read', file(category,name)); return result.value; },
60
+ async write(category, name, value) {
61
+ if (typeof value !== 'string') throw new Error('存储写入内容应为文本');
62
+ await host('host.storage.write', { ...file(category,name), value });
63
+ },
64
+ }),
65
+ });
66
+ async function dispatch(row) {
67
+ if (closed) return;
68
+ const requestId = row.id;
69
+ if (!['string','number'].includes(typeof requestId) || typeof row.method !== 'string') throw new Error('插件请求格式无效');
70
+ const reply = result => send({ id: requestId, result: result ?? null });
71
+ const error = (code, message) => send({ id: requestId, error: { code, message } });
72
+ if (row.method === 'host.hello') {
73
+ if (hello || row.params?.protocol !== 1 || row.params?.pluginId !== id || !Array.isArray(row.params.capabilities)) return error('BAD_HANDSHAKE','宿主握手身份或协议无效');
74
+ hello = Object.freeze({ ...row.params, capabilities: Object.freeze([...row.params.capabilities]) });
75
+ return reply({ type:'plugin.hello', protocol:1, pluginId:id });
76
+ }
77
+ if (!hello) return error('HANDSHAKE_REQUIRED','请先完成宿主握手');
78
+ try {
79
+ if (row.method === 'plugin.activate') { if (!active) { await activate(context); active=true; } return reply({active:true}); }
80
+ if (row.method === 'plugin.deactivate') { if (active) { active=false; await deactivate(context); } return reply({active:false}); }
81
+ if (!active) return error('NOT_ACTIVE','插件尚未启用');
82
+ if (row.method === 'plugin.event') {
83
+ if (!hello.capabilities.includes('events')) return error('CAPABILITY_REQUIRED','插件未声明事件能力');
84
+ return reply(await onEvent(row.params ?? {},context));
85
+ }
86
+ if (!Object.hasOwn(commands,row.method) || typeof commands[row.method] !== 'function') return error('UNKNOWN_METHOD','插件方法未注册');
87
+ return reply(await commands[row.method](row.params ?? {},context));
88
+ } catch { return error('COMMAND_FAILED','插件操作失败,请检查配置及运行状态'); }
89
+ }
90
+ function accept(bytes) {
91
+ let row;
92
+ try { row = JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes)); }
93
+ catch { throw new Error('插件协议包含无效 JSON 或 UTF-8'); }
94
+ if (!row || typeof row !== 'object' || Array.isArray(row)) throw new Error('插件协议应为 JSON 对象');
95
+ if (!Object.hasOwn(row,'method')) {
96
+ const waiter = pending.get(row.id);
97
+ if (!waiter) throw new Error('宿主响应编号不匹配');
98
+ pending.delete(row.id); clearTimeout(waiter.timer);
99
+ if (Object.hasOwn(row,'result') === Object.hasOwn(row,'error')) return waiter.reject(new Error('宿主响应结构无效'));
100
+ if (row.error) waiter.reject(new Error('宿主存储操作失败')); else waiter.resolve(row.result);
101
+ return;
102
+ }
103
+ if (++queued > 64) throw new Error('插件请求队列超过限制');
104
+ chain = chain.then(() => dispatch(row)).catch(() => { close(); onError(new Error('插件协议处理失败')); }).finally(() => queued--);
105
+ }
106
+ function receive(chunk) {
107
+ try {
108
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
109
+ let offset = 0;
110
+ while (offset < bytes.length) {
111
+ const newline = bytes.indexOf(10,offset), end = newline < 0 ? bytes.length : newline;
112
+ if (buffer.length + end - offset + 1 > maxLine) throw new Error('插件协议帧超过限制');
113
+ buffer = Buffer.concat([buffer,bytes.subarray(offset,end)]);
114
+ if (newline < 0) break;
115
+ if (!buffer.length) throw new Error('插件协议包含空帧');
116
+ accept(buffer); buffer=Buffer.alloc(0); offset=newline+1;
117
+ }
118
+ } catch (error) { close(); onError(error); }
119
+ }
120
+ function ended() { const partial=buffer.length>0; close(); if (partial) onError(new Error('插件协议尾帧缺少换行')); }
121
+ input.on('data',receive); input.on('end',ended); input.on('error',failed); output.on('error',failed);
122
+ return Object.freeze({ close, get closed() { return closed; } });
123
+ }