playlist-admin 2.6.81 → 2.6.83

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.
@@ -1,452 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- import readline from 'node:readline';
5
- import { spawn } from 'node:child_process';
6
- import { sleep } from './util.js';
7
-
8
- // PowerShell P/Invoke worker — Windows UI automation (user32.dll).
9
- const WORKER_CS = String.raw`
10
- using System;
11
- using System.Text;
12
- using System.Runtime.InteropServices;
13
- public class U {
14
- public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
15
- [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam);
16
- [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int maxCount);
17
- [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd);
18
- [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
19
- [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
20
- [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int cmd);
21
- [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
22
- [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
23
- [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd);
24
- [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
25
- [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo);
26
- [DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extraInfo);
27
- [DllImport("user32.dll")] public static extern short GetAsyncKeyState(int vKey);
28
- [DllImport("user32.dll")] public static extern IntPtr GetDC(IntPtr hwnd);
29
- [DllImport("user32.dll")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr dc);
30
- [DllImport("gdi32.dll")] public static extern uint GetPixel(IntPtr dc, int x, int y);
31
- [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
32
- public static IntPtr FindSpotube() {
33
- IntPtr found = IntPtr.Zero;
34
- EnumWindows((h, l) => {
35
- int len = GetWindowTextLength(h);
36
- if (len > 0) {
37
- StringBuilder sb = new StringBuilder(len + 1);
38
- GetWindowText(h, sb, len + 1);
39
- if (sb.ToString().ToLower().Contains("spotube")) { found = h; return false; }
40
- }
41
- return true;
42
- }, IntPtr.Zero);
43
- return found;
44
- }
45
- public static int[] Rect(IntPtr h) { RECT r; GetWindowRect(h, out r); return new int[] { r.Left, r.Top, r.Right, r.Bottom }; }
46
- public static void Click(int x, int y) {
47
- SetCursorPos(x, y);
48
- mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero); // LEFTDOWN
49
- mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero); // LEFTUP
50
- }
51
- public static void Key(byte vk, bool up) {
52
- keybd_event(vk, 0, up ? (byte)0x0002 : (byte)0, UIntPtr.Zero);
53
- }
54
- public static void TypeChar(int ch) {
55
- keybd_event(0, (byte)(ch & 0xFF), 0x0004, UIntPtr.Zero); // KEYEVENTF_UNICODE down
56
- keybd_event(0, (byte)(ch & 0xFF), 0x0004 | 0x0002, UIntPtr.Zero); // up
57
- }
58
- public static bool EscDown() { return (GetAsyncKeyState(0x1B) & 0x8000) != 0; }
59
- public static int Pixel(int x, int y) {
60
- IntPtr dc = GetDC(IntPtr.Zero);
61
- uint p = GetPixel(dc, x, y);
62
- ReleaseDC(IntPtr.Zero, dc);
63
- return (int)((p & 0xFF) + ((p >> 8) & 0xFF) + ((p >> 16) & 0xFF)) / 3;
64
- }
65
- public static void Activate(IntPtr h) {
66
- if (IsIconic(h)) ShowWindow(h, 9); // SW_RESTORE
67
- SetForegroundWindow(h);
68
- }
69
- }
70
- `;
71
-
72
- const PS_SCRIPT = `
73
- $ErrorActionPreference = 'Stop'
74
- Add-Type -TypeDefinition @'
75
- ${WORKER_CS}
76
- '@ -Language CSharp
77
- $reader = [System.IO.StreamReader]::new([System.Console]::OpenStandardInput())
78
- while ($true) {
79
- $line = $reader.ReadLine()
80
- if ($null -eq $line) { break }
81
- try {
82
- $req = $line | ConvertFrom-Json
83
- $cmd = $req.cmd
84
- $r = $null
85
- switch ($cmd) {
86
- 'find' { $r = @([U]::FindSpotube().ToInt64()) }
87
- 'rect' { $r = [U]::Rect([IntPtr]$req.args[0]) }
88
- 'activate' { [U]::Activate([IntPtr]$req.args[0]); $r = @($true) }
89
- 'foreground'{ $r = @([U]::GetForegroundWindow().ToInt64()) }
90
- 'minimize' { [U]::ShowWindow([IntPtr]$req.args[0], 6); $r = @($true) }
91
- 'maximize' { [U]::ShowWindow([IntPtr]$req.args[0], 3); $r = @($true) }
92
- 'click' { [U]::Click([int]$req.args[0], [int]$req.args[1]); $r = @($true) }
93
- 'vk' { [U]::Key([byte]$req.args[0], [bool]$req.args[1]); $r = @($true) }
94
- 'type' { [U]::TypeChar([int]$req.args[0]); $r = @($true) }
95
- 'pixel' { $r = @([U]::Pixel([int]$req.args[0], [int]$req.args[1])) }
96
- 'esc' { $r = @([U]::EscDown()) }
97
- 'sleep' {
98
- $ms = [int]$req.args[0]
99
- $sw = [System.Diagnostics.Stopwatch]::StartNew()
100
- $aborted = $false
101
- while ($sw.ElapsedMilliseconds -lt $ms) {
102
- if ([U]::EscDown()) { $aborted = $true; break }
103
- Start-Sleep -Milliseconds 20
104
- }
105
- $r = @($aborted)
106
- }
107
- 'exit' { break }
108
- }
109
- [Console]::Out.WriteLine(("{0}" -f (@{ ok = $true; data = $r } | ConvertTo-Json -Compress -Depth 5)))
110
- [Console]::Out.Flush()
111
- if ($cmd -eq 'exit') { break }
112
- } catch {
113
- [Console]::Out.WriteLine(("{0}" -f (@{ ok = $false; error = $_.Exception.Message } | ConvertTo-Json -Compress)))
114
- [Console]::Out.Flush()
115
- }
116
- }
117
- `;
118
-
119
- const defaultCoords = {
120
- sidebar_library: [30, 280],
121
- library_filter: [200, 100],
122
- first_playlist_card: [100, 240],
123
- three_dot_menu: [1300, 140],
124
- download_all_offset: [-30, 30],
125
- confirm_button: [960, 600],
126
- skip_detect: [960, 600],
127
- skip: [960, 600],
128
- skip_all: [960, 600],
129
- };
130
-
131
- function stateFile() {
132
- return path.join(
133
- process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
134
- 'Playlist Administrator', 'data', 'spotube_download_state.json');
135
- }
136
-
137
- function loadState() {
138
- try {
139
- if (!fs.existsSync(stateFile())) return {};
140
- return JSON.parse(fs.readFileSync(stateFile(), 'utf-8'));
141
- } catch { return {}; }
142
- }
143
-
144
- function saveState(state) {
145
- try {
146
- fs.mkdirSync(path.dirname(stateFile()), { recursive: true });
147
- fs.writeFileSync(stateFile(), JSON.stringify(state), 'utf-8');
148
- } catch {}
149
- }
150
-
151
- class PsWorker {
152
- constructor() {
153
- const scriptFile = path.join(os.tmpdir(), `pa_spotube_worker_${process.pid}.ps1`);
154
- fs.writeFileSync(scriptFile, PS_SCRIPT, 'utf-8');
155
- this._scriptFile = scriptFile;
156
- this.proc = spawn(
157
- 'powershell', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptFile],
158
- { windowsHide: true, shell: false },
159
- );
160
- this.pending = [];
161
- this.rl = readline.createInterface({ input: this.proc.stdout });
162
- this.rl.on('line', (line) => {
163
- const p = this.pending.shift();
164
- if (!p) return;
165
- try { p.resolve(JSON.parse(line)); } catch { p.resolve({ ok: false, error: 'bad json' }); }
166
- });
167
- this.proc.on('error', () => {});
168
- this.proc.stdin.write(`${JSON.stringify({ cmd: 'find' })}\n`); // warm up (also compiles C#)
169
- }
170
-
171
- call(cmd, args = []) {
172
- return new Promise((resolve) => {
173
- const t = setTimeout(() => resolve({ ok: false, error: 'timeout' }), 30000);
174
- this.pending.push({ resolve: (r) => { clearTimeout(t); resolve(r); } });
175
- try {
176
- this.proc.stdin.write(`${JSON.stringify({ cmd, args })}\n`);
177
- } catch {
178
- resolve({ ok: false, error: 'worker closed' });
179
- }
180
- });
181
- }
182
-
183
- async close() {
184
- try {
185
- this.proc.stdin.write(`${JSON.stringify({ cmd: 'exit' })}\n`);
186
- await sleep(300);
187
- this.proc.kill();
188
- } catch {}
189
- try { fs.unlinkSync(this._scriptFile); } catch {}
190
- }
191
- }
192
-
193
- export class SpotubeController {
194
- constructor({ libraryPath, coords = {} }) {
195
- this.libraryPath = libraryPath;
196
- this._coords = { ...defaultCoords, ...coords };
197
- this._prevHwnd = null;
198
- this._aborted = false;
199
- this.worker = null;
200
- }
201
-
202
- async _w() {
203
- if (!this.worker) this.worker = new PsWorker();
204
- return this.worker;
205
- }
206
-
207
- _coord(key) {
208
- return this._coords[key] || defaultCoords[key] || [0, 0];
209
- }
210
-
211
- async hwnd() {
212
- const w = await this._w();
213
- const r = await w.call('find');
214
- return r.ok && r.data && r.data[0] ? r.data[0] : null;
215
- }
216
-
217
- async isRunning() {
218
- return (await this.hwnd()) != null;
219
- }
220
-
221
- async _clientOrigin() {
222
- const h = await this.hwnd();
223
- if (!h) return [0, 0];
224
- const w = await this._w();
225
- const r = await w.call('rect', [h]);
226
- if (!r.ok || !r.data) return [0, 0];
227
- return [r.data[0], r.data[1]];
228
- }
229
-
230
- async _absolute(key) {
231
- const c = this._coord(key);
232
- const origin = await this._clientOrigin();
233
- return [origin[0] + c[0], origin[1] + c[1]];
234
- }
235
-
236
- async activate() {
237
- const h = await this.hwnd();
238
- if (!h) throw new Error('Spotube not found');
239
- const w = await this._w();
240
- const fg = await w.call('foreground');
241
- if (fg.ok && fg.data && fg.data[0]) this._prevHwnd = fg.data[0];
242
- await w.call('activate', [h]);
243
- await this._aWait(300);
244
- }
245
-
246
- async restorePrevious() {
247
- if (this._prevHwnd) {
248
- const w = await this._w();
249
- await w.call('activate', [this._prevHwnd]);
250
- this._prevHwnd = null;
251
- }
252
- }
253
-
254
- async minimize() {
255
- const h = await this.hwnd();
256
- if (h) { const w = await this._w(); await w.call('minimize', [h]); }
257
- }
258
-
259
- async maximize() {
260
- const h = await this.hwnd();
261
- if (h) { const w = await this._w(); await w.call('maximize', [h]); }
262
- }
263
-
264
- async click(x, y) {
265
- const w = await this._w();
266
- await w.call('click', [x, y]);
267
- await this._aWait(200);
268
- }
269
-
270
- async doubleClick(x, y) {
271
- await this.click(x, y);
272
- await sleep(100);
273
- await this.click(x, y);
274
- await this._aWait(300);
275
- }
276
-
277
- async sendChars(text) {
278
- const w = await this._w();
279
- for (const ch of [...text]) {
280
- if (this._aborted) return;
281
- await w.call('type', [ch.codePointAt(0)]);
282
- await sleep(30);
283
- }
284
- }
285
-
286
- async sendBackspaces(count) {
287
- const w = await this._w();
288
- for (let i = 0; i < count; i++) {
289
- if (this._aborted) return;
290
- await w.call('vk', [0x08, false]);
291
- await w.call('vk', [0x08, true]);
292
- await sleep(20);
293
- }
294
- }
295
-
296
- async sendEscape() {
297
- const w = await this._w();
298
- await w.call('vk', [0x1B, false]);
299
- await w.call('vk', [0x1B, true]);
300
- await this._aWait(100);
301
- }
302
-
303
- async pixelBrightness(x, y) {
304
- const w = await this._w();
305
- const r = await w.call('pixel', [x, y]);
306
- return r.ok && r.data ? r.data[0] : 0;
307
- }
308
-
309
- async _aWait(ms) {
310
- const w = await this._w();
311
- const r = await w.call('sleep', [ms]);
312
- if (r.ok && r.data && r.data[0]) this._aborted = true;
313
- }
314
-
315
- async hasSkipDialog() {
316
- const pos = await this._absolute('skip_detect');
317
- return (await this.pixelBrightness(pos[0], pos[1])) > 200;
318
- }
319
-
320
- async _checkAborted() {
321
- if (this._aborted) {
322
- await this.sendEscape();
323
- await this.minimize();
324
- await this.restorePrevious();
325
- return true;
326
- }
327
- return false;
328
- }
329
-
330
- async _stepPlaylist(name, { checkSkip = false } = {}) {
331
- await this._clickSidebarLibrary();
332
- await this._wait(800);
333
- if (await this._checkAborted()) return;
334
- await this._filterPlaylists(name);
335
- await this._wait(1500);
336
- if (await this._checkAborted()) return;
337
- await this._clickFirstPlaylist();
338
- await this._wait(2000);
339
- if (await this._checkAborted()) return;
340
- await this._clickThreeDot();
341
- await this._wait(800);
342
- if (await this._checkAborted()) return;
343
- await this._clickDownloadAll();
344
- await this._wait(800);
345
- if (await this._checkAborted()) return;
346
- await this._clickConfirm();
347
- if (checkSkip) {
348
- for (let i = 0; i < 5; i++) {
349
- await this._wait(2000);
350
- if (await this._checkAborted()) return;
351
- if (await this.hasSkipDialog()) {
352
- await this._clickSkip();
353
- await this._wait(200);
354
- await this._clickSkipAll();
355
- break;
356
- }
357
- }
358
- }
359
- }
360
-
361
- async _clickSidebarLibrary() {
362
- const pos = await this._absolute('sidebar_library');
363
- await this.click(pos[0], pos[1]);
364
- }
365
-
366
- async _filterPlaylists(name) {
367
- const pos = await this._absolute('library_filter');
368
- await this.click(pos[0], pos[1]);
369
- await this._aWait(200);
370
- await this.sendBackspaces(80);
371
- await this._aWait(50);
372
- await this.sendChars(name);
373
- }
374
-
375
- async _clickFirstPlaylist() {
376
- const pos = await this._absolute('first_playlist_card');
377
- await this.doubleClick(pos[0], pos[1]);
378
- }
379
-
380
- async _clickThreeDot() {
381
- const pos = await this._absolute('three_dot_menu');
382
- await this.click(pos[0], pos[1]);
383
- }
384
-
385
- async _clickDownloadAll() {
386
- const base = await this._absolute('three_dot_menu');
387
- const offset = this._coord('download_all_offset');
388
- await this.click(base[0] + offset[0], base[1] + offset[1]);
389
- }
390
-
391
- async _clickConfirm() {
392
- const pos = await this._absolute('confirm_button');
393
- await this.click(pos[0], pos[1]);
394
- }
395
-
396
- async _clickSkip() {
397
- const pos = await this._absolute('skip');
398
- await this.click(pos[0], pos[1]);
399
- }
400
-
401
- async _clickSkipAll() {
402
- const pos = await this._absolute('skip_all');
403
- await this.click(pos[0], pos[1]);
404
- }
405
-
406
- async downloadPlaylist(name) {
407
- if (await this._checkAborted()) return;
408
- if (!(await this.isRunning())) throw new Error('Spotube is not running');
409
- await this.maximize();
410
- await this.activate();
411
- await this._stepPlaylist(name, { checkSkip: true });
412
- await this.minimize();
413
- await this.restorePrevious();
414
- }
415
-
416
- async downloadAll(names) {
417
- this._aborted = false;
418
- if (!(await this.isRunning())) throw new Error('Spotube is not running');
419
- await this.maximize();
420
- await this.activate();
421
- for (let i = 0; i < names.length; i++) {
422
- if (this._aborted) break;
423
- await this._stepPlaylist(names[i], { checkSkip: i === 0 });
424
- }
425
- await this.minimize();
426
- await this.restorePrevious();
427
- }
428
-
429
- async moveDownloads(resolvedDownloadPath) {
430
- const src = resolvedDownloadPath;
431
- const dst = path.join(this.libraryPath, 'm4a');
432
- if (!fs.existsSync(src)) return 0;
433
- fs.mkdirSync(dst, { recursive: true });
434
- let moved = 0;
435
- const walk = (dir) => {
436
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
437
- if (this._aborted) return;
438
- const full = path.join(dir, e.name);
439
- if (e.isDirectory()) walk(full);
440
- else if (e.isFile() && /\.(m4a|mp3|flac|wav|webm)$/i.test(e.name)) {
441
- const target = path.join(dst, e.name);
442
- if (!fs.existsSync(target)) {
443
- fs.renameSync(full, target);
444
- moved++;
445
- }
446
- }
447
- }
448
- };
449
- walk(src);
450
- return moved;
451
- }
452
- }
package/cli/lib/util.js DELETED
@@ -1,139 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { spawn, execFile } from 'node:child_process';
4
-
5
- export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
-
7
- export function run(cmd, args, opts = {}) {
8
- return new Promise((resolve) => {
9
- execFile(cmd, args, { windowsHide: true, timeout: opts.timeout, encoding: opts.encoding ?? 'utf8' },
10
- (err, stdout, stderr) => {
11
- resolve({ code: err ? (err.code ?? 1) : 0, stdout: stdout ?? '', stderr: stderr ?? '' });
12
- });
13
- });
14
- }
15
-
16
- export function runStream(cmd, args, { onLine, onStderr, timeout } = {}) {
17
- return new Promise((resolve) => {
18
- const p = spawn(cmd, args, { windowsHide: true, shell: false });
19
- let stdout = '';
20
- let stderr = '';
21
- if (onLine) {
22
- let buf = '';
23
- p.stdout.on('data', (d) => {
24
- buf += d.toString('utf-8');
25
- let i;
26
- while ((i = buf.indexOf('\n')) >= 0) {
27
- const line = buf.slice(0, i).replace(/\r$/, '');
28
- buf = buf.slice(i + 1);
29
- onLine(line);
30
- }
31
- });
32
- p.stdout.on('end', () => { if (buf.trim()) onLine(buf); });
33
- } else {
34
- p.stdout.on('data', (d) => { stdout += d.toString('utf-8'); });
35
- }
36
- p.stderr.on('data', (d) => {
37
- const s = d.toString('utf-8');
38
- stderr += s;
39
- onStderr?.(s);
40
- });
41
- const t = timeout ? setTimeout(() => { try { p.kill(); } catch {} }, timeout) : null;
42
- p.on('close', (code) => {
43
- if (t) clearTimeout(t);
44
- resolve({ code: code ?? 0, stdout, stderr });
45
- });
46
- p.on('error', () => resolve({ code: 1, stdout, stderr }));
47
- });
48
- }
49
-
50
- export function resolveFfmpeg(config) {
51
- const cfg = config.ffmpegPath || '';
52
- if (cfg && !cfg.includes('\\') && !cfg.includes('/')) return cfg;
53
- if (cfg) {
54
- const abs = path.isAbsolute(cfg) ? cfg : path.resolve(process.cwd(), cfg);
55
- if (fs.existsSync(abs)) return abs;
56
- }
57
- for (const dir of (process.env.PATH || '').split(';')) {
58
- if (!dir.trim()) continue;
59
- const cand = path.join(dir.trim(), 'ffmpeg.exe');
60
- if (fs.existsSync(cand)) return cand;
61
- }
62
- return 'ffmpeg';
63
- }
64
-
65
- export function resolveFfprobe(ffmpeg) {
66
- if (ffmpeg !== 'ffmpeg' && !ffmpeg.toLowerCase().endsWith('ffmpeg.exe')) {
67
- const cand = path.join(path.dirname(ffmpeg), 'ffprobe.exe');
68
- if (fs.existsSync(cand)) return cand;
69
- }
70
- for (const dir of (process.env.PATH || '').split(';')) {
71
- if (!dir.trim()) continue;
72
- const cand = path.join(dir.trim(), 'ffprobe.exe');
73
- if (fs.existsSync(cand)) return cand;
74
- }
75
- return 'ffprobe';
76
- }
77
-
78
- export async function walkDir(dir, opts = {}) {
79
- const result = [];
80
- if (!fs.existsSync(dir)) return result;
81
- const stack = [dir];
82
- while (stack.length) {
83
- const cur = stack.pop();
84
- let entries;
85
- try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
86
- for (const e of entries) {
87
- const full = path.join(cur, e.name);
88
- if (e.isDirectory()) {
89
- stack.push(full);
90
- } else if (e.isFile()) {
91
- if (opts.filter && !opts.filter(full)) continue;
92
- result.push(full);
93
- }
94
- }
95
- }
96
- return result;
97
- }
98
-
99
- export function relativePath(absPath, relativeTo) {
100
- const absParts = absPath.replaceAll('\\', '/').split('/');
101
- const relParts = relativeTo.replaceAll('\\', '/').split('/');
102
- let common = 0;
103
- while (common < absParts.length && common < relParts.length &&
104
- absParts[common].toLowerCase() === relParts[common].toLowerCase()) {
105
- common++;
106
- }
107
- const up = Array(relParts.length - common).fill('..');
108
- const down = absParts.slice(common);
109
- return [...up, ...down].join('/');
110
- }
111
-
112
- export function stemOf(p) {
113
- const name = p.replace(/[\\/]/g, '\\').split('\\').pop() ?? '';
114
- return name.replace(/\.[^.]+$/, '');
115
- }
116
-
117
- export function normalizeFileName(name) {
118
- return name
119
- .replace(/\s*\[[\w-]{11}\]/g, '')
120
- .replace(/[<>:"/\\|?*&]/g, '_')
121
- .trim();
122
- }
123
-
124
- export function isInternalPlaylist(name) {
125
- const n = name.toLowerCase();
126
- if (n.includes('_removed songs')) return true;
127
- return ['_unsorted', 'single tracks', '_unsorted_songs'].some((m) => n.includes(m));
128
- }
129
-
130
- export function readJson(p, fallback = null) {
131
- try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return fallback; }
132
- }
133
-
134
- export function writeJson(p, data) {
135
- try {
136
- fs.mkdirSync(path.dirname(p), { recursive: true });
137
- fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8');
138
- } catch {}
139
- }
package/cli/lib/zhcn.js DELETED
@@ -1,68 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
-
5
- // Port of lib/services/chinese_converter.dart — loads assets/zhcdict.json
6
- // and does longest-prefix replacement.
7
- let s2t = null;
8
- let t2s = null;
9
- let sKeys = null;
10
- let tKeys = null;
11
-
12
- export function loadDict() {
13
- if (s2t) return;
14
- const here = path.dirname(fileURLToPath(import.meta.url));
15
- // project root is 3 levels up from cli/lib/
16
- const root = path.resolve(here, '..', '..');
17
- const candidates = [
18
- path.join(root, 'assets', 'zhcdict.json'),
19
- path.join(process.cwd(), 'assets', 'zhcdict.json'),
20
- ];
21
- let data = null;
22
- for (const c of candidates) {
23
- if (fs.existsSync(c)) {
24
- try { data = JSON.parse(fs.readFileSync(c, 'utf-8')); break; } catch {}
25
- }
26
- }
27
- if (!data) return;
28
- s2t = {};
29
- t2s = {};
30
- for (const [k, v] of Object.entries(data)) {
31
- if (typeof v !== 'string') continue;
32
- s2t[k] = v;
33
- t2s[v] = k;
34
- }
35
- sKeys = Object.keys(s2t).sort((a, b) => b.length - a.length);
36
- tKeys = Object.keys(t2s).sort((a, b) => b.length - a.length);
37
- }
38
-
39
- export const isLoaded = () => s2t !== null;
40
-
41
- function convert(text, dict, sortedKeys) {
42
- if (!text) return text;
43
- let out = '';
44
- let i = 0;
45
- outer:
46
- while (i < text.length) {
47
- for (const key of sortedKeys) {
48
- if (text.startsWith(key, i)) {
49
- out += dict[key];
50
- i += key.length;
51
- continue outer;
52
- }
53
- }
54
- out += text[i];
55
- i++;
56
- }
57
- return out;
58
- }
59
-
60
- export function toSimplified(text) {
61
- loadDict();
62
- return convert(text, t2s ?? {}, tKeys ?? []);
63
- }
64
-
65
- export function toTraditional(text) {
66
- loadDict();
67
- return convert(text, s2t ?? {}, sKeys ?? []);
68
- }