sloth-d2c-mcp 1.0.4-beta100

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.
Files changed (34) hide show
  1. package/README.md +83 -0
  2. package/cli/run.js +328 -0
  3. package/cli/sloth-server.log +1622 -0
  4. package/dist/build/config-manager/index.js +240 -0
  5. package/dist/build/core/prompt-builder.js +366 -0
  6. package/dist/build/core/sampling.js +375 -0
  7. package/dist/build/core/types.js +1 -0
  8. package/dist/build/index.js +852 -0
  9. package/dist/build/interceptor/client.js +142 -0
  10. package/dist/build/interceptor/vscode.js +143 -0
  11. package/dist/build/interceptor/web.js +28 -0
  12. package/dist/build/plugin/index.js +4 -0
  13. package/dist/build/plugin/loader.js +349 -0
  14. package/dist/build/plugin/manager.js +129 -0
  15. package/dist/build/plugin/types.js +6 -0
  16. package/dist/build/server.js +2116 -0
  17. package/dist/build/socket-client.js +166 -0
  18. package/dist/build/socket-server.js +260 -0
  19. package/dist/build/utils/client-capabilities.js +143 -0
  20. package/dist/build/utils/extract.js +168 -0
  21. package/dist/build/utils/file-manager.js +868 -0
  22. package/dist/build/utils/image-matcher.js +154 -0
  23. package/dist/build/utils/logger.js +90 -0
  24. package/dist/build/utils/opencv-loader.js +70 -0
  25. package/dist/build/utils/prompt-parser.js +46 -0
  26. package/dist/build/utils/tj.js +139 -0
  27. package/dist/build/utils/update.js +100 -0
  28. package/dist/build/utils/utils.js +184 -0
  29. package/dist/build/utils/vscode-logger.js +133 -0
  30. package/dist/build/utils/webpack-substitutions.js +196 -0
  31. package/dist/interceptor-web/dist/build-report.json +18 -0
  32. package/dist/interceptor-web/dist/detail.html +1 -0
  33. package/dist/interceptor-web/dist/index.html +1 -0
  34. package/package.json +96 -0
@@ -0,0 +1,868 @@
1
+ import { promises as fs } from 'fs';
2
+ import * as path from 'path';
3
+ import envPaths from 'env-paths';
4
+ import { Logger } from '../utils/logger.js';
5
+ import { httpServer, socketServer } from '../server.js';
6
+ /**
7
+ * 通用文件管理器
8
+ * 可以保存任何类型的文件,使用fileKey和nodeId组织目录结构
9
+ */
10
+ export class FileManager {
11
+ paths; // 应用路径配置
12
+ baseDir; // 基础存储目录
13
+ _workspaceRoot = null; // MCP 工作目录根路径
14
+ _uuid = '';
15
+ constructor(appName) {
16
+ this.paths = envPaths(appName);
17
+ this.baseDir = path.join(this.paths.data, 'files');
18
+ }
19
+ get workspaceRoot() {
20
+ const isMainProcess = httpServer !== null;
21
+ if (isMainProcess && this._uuid) {
22
+ const workspaceRoot = socketServer?.getTokenExtraField(this._uuid, 'workspaceRoot');
23
+ return workspaceRoot || this._workspaceRoot;
24
+ }
25
+ return this._workspaceRoot;
26
+ }
27
+ set workspaceRoot(rootPath) {
28
+ this._workspaceRoot = rootPath;
29
+ }
30
+ /**
31
+ * 设置工作目录根路径(用于 MCP 工作项目)
32
+ * @param rootPath - 工作目录根路径
33
+ */
34
+ setWorkspaceRoot(rootPath) {
35
+ this.workspaceRoot = rootPath;
36
+ Logger.log(`已设置工作目录根路径: ${rootPath}`);
37
+ }
38
+ getWorkspaceRoot() {
39
+ return this.workspaceRoot || './';
40
+ }
41
+ /**
42
+ * 生成文件路径
43
+ * @param fileKey - Figma文件的key
44
+ * @param nodeId - 节点ID(可选)
45
+ * @param filename - 文件名
46
+ * @returns 完整的文件路径
47
+ */
48
+ getFilePath(fileKey, nodeId, filename) {
49
+ // 清理文件名中的特殊字符
50
+ const cleanFileKey = fileKey.replace(/[^a-zA-Z0-9\u4e00-\u9fff\u3400-\u4dbf-_]/g, '_');
51
+ const cleanNodeId = nodeId ? nodeId.replace(/[^a-zA-Z0-9\u4e00-\u9fff\u3400-\u4dbf-_]/g, '_') : 'root';
52
+ const cleanFilename = filename.replace(/[^a-zA-Z0-9\u4e00-\u9fff\u3400-\u4dbf-_.]/g, '_');
53
+ return path.join(this.baseDir, cleanFileKey, cleanNodeId, cleanFilename);
54
+ }
55
+ /**
56
+ * 生成工作目录中的文件路径(保存到 .sloth 文件夹)
57
+ * @param fileKey - Figma文件的key
58
+ * @param nodeId - 节点ID(可选)
59
+ * @param filename - 文件名
60
+ * @returns 完整的文件路径
61
+ */
62
+ getWorkspaceFilePath(fileKey, nodeId, filename, opts = { skipParsePath: false }) {
63
+ const { skipParsePath = false } = opts;
64
+ if (!this.workspaceRoot) {
65
+ console.error('工作目录根路径未设置,使用默认目录:', this.baseDir);
66
+ return this.getFilePath(fileKey, nodeId, filename);
67
+ }
68
+ // 清理文件名中的特殊字符
69
+ const cleanFileKey = fileKey.replace(/[^a-zA-Z0-9\u4e00-\u9fff\u3400-\u4dbf-_]/g, '_');
70
+ const cleanNodeId = nodeId ? nodeId.replace(/[^a-zA-Z0-9\u4e00-\u9fff\u3400-\u4dbf-_]/g, '_') : 'root';
71
+ const cleanFilename = filename.replace(/[^a-zA-Z0-9\u4e00-\u9fff\u3400-\u4dbf-_.]/g, '_');
72
+ if (skipParsePath) {
73
+ return path.join(this.workspaceRoot, '.sloth', fileKey, nodeId || 'root', cleanFilename);
74
+ }
75
+ return path.join(this.workspaceRoot, '.sloth', cleanFileKey, cleanNodeId, cleanFilename);
76
+ }
77
+ /**
78
+ * 获取工作目录下特定 fileKey 的目录路径
79
+ * @param fileKey - Figma 文件的 key
80
+ * @returns string | null
81
+ */
82
+ getWorkspaceFileKeyDirPath(fileKey) {
83
+ if (!this.workspaceRoot)
84
+ return null;
85
+ const cleanFileKey = fileKey.replace(/[^a-zA-Z0-9-_]/g, '_');
86
+ return path.join(this.workspaceRoot, '.sloth', cleanFileKey);
87
+ }
88
+ /**
89
+ * 保存文件内容
90
+ * @param fileKey - Figma文件的key
91
+ * @param nodeId - 节点ID(可选)
92
+ * @param filename - 文件名
93
+ * @param content - 文件内容
94
+ * @param useWorkspaceDir - 是否使用工作目录
95
+ */
96
+ async saveFile(fileKey, nodeId, filename, content, opts = { useWorkspaceDir: false, skipParsePath: false }) {
97
+ const { useWorkspaceDir = false, ...restOpts } = opts;
98
+ try {
99
+ const filePath = useWorkspaceDir ? this.getWorkspaceFilePath(fileKey, nodeId, filename, restOpts) : this.getFilePath(fileKey, nodeId, filename);
100
+ // 确保目录存在
101
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
102
+ // 保存文件内容
103
+ await fs.writeFile(filePath, content, 'utf-8');
104
+ Logger.log(`文件已保存: ${filePath}`);
105
+ }
106
+ catch (error) {
107
+ Logger.error(`保存文件失败: ${error}`);
108
+ throw error;
109
+ }
110
+ }
111
+ /**
112
+ * 读取文件内容
113
+ * @param fileKey - Figma文件的key
114
+ * @param nodeId - 节点ID(可选)
115
+ * @param filename - 文件名
116
+ * @param useWorkspaceDir - 是否使用工作目录
117
+ * @returns Promise<string> - 文件内容,如果文件不存在则返回空字符串
118
+ */
119
+ async loadFile(fileKey, nodeId, filename, opts = { useWorkspaceDir: false, skipParsePath: false }) {
120
+ const { useWorkspaceDir = false, ...restOpts } = opts;
121
+ try {
122
+ const filePath = useWorkspaceDir ? this.getWorkspaceFilePath(fileKey, nodeId, filename, restOpts) : this.getFilePath(fileKey, nodeId, filename);
123
+ Logger.log(`加载文件: ${filePath}, workspaceRoot: ${this.workspaceRoot}`);
124
+ const content = await fs.readFile(filePath, 'utf-8');
125
+ return content;
126
+ }
127
+ catch (err) {
128
+ if (err.code === 'ENOENT') {
129
+ Logger.log(`文件不存在: ${filename}`);
130
+ return '';
131
+ }
132
+ Logger.error(`读取文件失败: ${err.toString()}`);
133
+ throw err;
134
+ }
135
+ }
136
+ /**
137
+ * 检查文件是否存在
138
+ * @param fileKey - Figma文件的key
139
+ * @param nodeId - 节点ID(可选)
140
+ * @param filename - 文件名
141
+ * @returns Promise<boolean> - 如果文件存在则返回true
142
+ */
143
+ async exists(fileKey, nodeId, filename) {
144
+ try {
145
+ const filePath = this.getFilePath(fileKey, nodeId, filename);
146
+ await fs.access(filePath);
147
+ return true;
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ /**
154
+ * 删除文件
155
+ * @param fileKey - Figma文件的key
156
+ * @param nodeId - 节点ID(可选)
157
+ * @param filename - 文件名
158
+ */
159
+ async deleteFile(fileKey, nodeId, filename) {
160
+ try {
161
+ const filePath = this.getFilePath(fileKey, nodeId, filename);
162
+ await fs.unlink(filePath);
163
+ Logger.log(`文件已删除: ${filePath}`);
164
+ }
165
+ catch (err) {
166
+ if (err.code !== 'ENOENT') {
167
+ Logger.error(`删除文件失败: ${err.toString()}`);
168
+ throw err; // 如果不是文件不存在错误,则抛出异常
169
+ }
170
+ }
171
+ }
172
+ /**
173
+ * 清理特定fileKey的所有文件
174
+ * @param fileKey - Figma文件的key
175
+ */
176
+ async cleanupFileKey(fileKey) {
177
+ try {
178
+ const cleanFileKey = fileKey.replace(/[^a-zA-Z0-9-_]/g, '_');
179
+ const fileKeyDir = path.join(this.baseDir, cleanFileKey);
180
+ await fs.rm(fileKeyDir, { recursive: true, force: true });
181
+ Logger.log(`文件Key目录已清理: ${fileKeyDir}`);
182
+ }
183
+ catch (error) {
184
+ Logger.error(`清理文件Key目录失败: ${error}`);
185
+ }
186
+ }
187
+ /**
188
+ * 清理特定fileKey和nodeId的所有文件
189
+ * @param fileKey - Figma文件的key
190
+ * @param nodeId - 节点ID(可选)
191
+ */
192
+ async cleanupNode(fileKey, nodeId) {
193
+ try {
194
+ const cleanFileKey = fileKey.replace(/[^a-zA-Z0-9-_]/g, '_');
195
+ const cleanNodeId = nodeId ? nodeId.replace(/[^a-zA-Z0-9-_]/g, '_') : 'root';
196
+ const nodeDir = path.join(this.baseDir, cleanFileKey, cleanNodeId);
197
+ await fs.rm(nodeDir, { recursive: true, force: true });
198
+ Logger.log(`节点目录已清理: ${nodeDir}`);
199
+ }
200
+ catch (error) {
201
+ Logger.error(`清理节点目录失败: ${error}`);
202
+ }
203
+ }
204
+ /**
205
+ * 清理整个存储目录
206
+ */
207
+ async cleanup() {
208
+ try {
209
+ await fs.rm(this.baseDir, { recursive: true, force: true });
210
+ Logger.log(`存储目录已清理: ${this.baseDir}`);
211
+ }
212
+ catch (error) {
213
+ Logger.error(`清理存储目录失败: ${error}`);
214
+ }
215
+ }
216
+ /**
217
+ * 获取文件的完整路径(公共方法)
218
+ * @param fileKey - Figma文件的key
219
+ * @param nodeId - 节点ID(可选)
220
+ * @param filename - 文件名
221
+ * @returns string - 文件的完整路径
222
+ */
223
+ getFilePathPublic(fileKey, nodeId, filename) {
224
+ return this.getFilePath(fileKey, nodeId, filename);
225
+ }
226
+ /**
227
+ * 获取基础存储目录路径
228
+ * @returns string - 基础存储目录的完整路径
229
+ */
230
+ getBaseDir() {
231
+ return this.baseDir;
232
+ }
233
+ /**
234
+ * 获取特定fileKey的目录路径
235
+ * @param fileKey - Figma文件的key
236
+ * @returns string - fileKey目录的完整路径
237
+ */
238
+ getFileKeyDir(fileKey) {
239
+ const cleanFileKey = fileKey.replace(/[^a-zA-Z0-9-_]/g, '_');
240
+ return path.join(this.baseDir, cleanFileKey);
241
+ }
242
+ /**
243
+ * 获取特定fileKey和nodeId的目录路径
244
+ * @param fileKey - Figma文件的key
245
+ * @param nodeId - 节点ID(可选)
246
+ * @returns string - 节点目录的完整路径
247
+ */
248
+ getNodeDir(fileKey, nodeId) {
249
+ const cleanFileKey = fileKey.replace(/[^a-zA-Z0-9-_]/g, '_');
250
+ const cleanNodeId = nodeId ? nodeId.replace(/[^a-zA-Z0-9-_]/g, '_') : 'root';
251
+ return path.join(this.baseDir, cleanFileKey, cleanNodeId);
252
+ }
253
+ /**
254
+ * 保存 groupsData 到特定的 nodeId 文件(保存到工作目录的 .sloth 文件夹)
255
+ * @param fileKey - Figma文件的key
256
+ * @param nodeId - 节点ID(可选)
257
+ * @param groupsData - 分组数据
258
+ */
259
+ async saveGroupsData(fileKey, nodeId, groupsData) {
260
+ await this.saveFile(fileKey, nodeId, 'groupsData.json', JSON.stringify(groupsData, null, 2), { useWorkspaceDir: true, skipParsePath: false });
261
+ }
262
+ /**
263
+ * 加载 groupsData 从特定的 nodeId 文件
264
+ * @param fileKey - Figma文件的key
265
+ * @param nodeId - 节点ID(可选)
266
+ * @returns Promise<any[]> - 分组数据,如果文件不存在则返回空数组
267
+ */
268
+ async loadGroupsData(fileKey, nodeId) {
269
+ try {
270
+ const content = await this.loadFile(fileKey, nodeId, 'groupsData.json', { useWorkspaceDir: true, skipParsePath: false });
271
+ return content ? JSON.parse(content) : [];
272
+ }
273
+ catch (error) {
274
+ Logger.log(`加载 groupsData 失败,返回空数组: ${error}`);
275
+ return [];
276
+ }
277
+ }
278
+ /**
279
+ * 保存 promptSetting 到特定的 nodeId 文件(保存到工作目录的 .sloth 文件夹)
280
+ * @param fileKey - Figma文件的key
281
+ * @param nodeId - 节点ID(可选)
282
+ * @param promptSetting - 提示词设置对象,格式为 { [key: string]: string }
283
+ */
284
+ /**
285
+ * 将 camelCase 转换为 kebab-case
286
+ */
287
+ camelToKebab(str) {
288
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
289
+ }
290
+ /**
291
+ * 将 kebab-case 转换为 camelCase
292
+ */
293
+ kebabToCamel(str) {
294
+ return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
295
+ }
296
+ async savePromptSetting(fileKey, nodeId, promptSetting) {
297
+ fileKey = '.';
298
+ nodeId = '.';
299
+ // 按 key 存储为独立的 md 文件
300
+ if (promptSetting && typeof promptSetting === 'object') {
301
+ for (const [key, value] of Object.entries(promptSetting)) {
302
+ if (typeof value === 'string' && value) {
303
+ // 将 camelCase key 转换为 kebab-case 文件名
304
+ const fileName = this.camelToKebab(key);
305
+ Logger.log(`fileName: ${fileName}`, 'this.workspaceRoot: ', this.workspaceRoot);
306
+ // 直接构建完整路径到 .sloth/prompt/xx.md
307
+ const filePath = path.join(this.workspaceRoot, '.sloth', 'd2c-prompt', `${fileName}.md`);
308
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
309
+ await fs.writeFile(filePath, value, 'utf-8');
310
+ Logger.log(`Prompt 文件已保存: ${filePath}`);
311
+ }
312
+ }
313
+ }
314
+ }
315
+ /**
316
+ * 加载 promptSetting 从特定的 nodeId 文件
317
+ * @param fileKey - Figma文件的key
318
+ * @param nodeId - 节点ID(可选)
319
+ * @returns Promise<any> - 提示词设置对象,如果文件不存在则返回 null
320
+ */
321
+ async loadPromptSetting(fileKey, nodeId) {
322
+ fileKey = '.';
323
+ nodeId = '.';
324
+ try {
325
+ // 兼容老格式:尝试读取 promptSetting.json
326
+ const oldContent = await this.loadFile(fileKey, nodeId, 'promptSetting.json', { useWorkspaceDir: true, skipParsePath: true });
327
+ if (oldContent) {
328
+ Logger.log('检测到老格式 promptSetting.json,准备删除');
329
+ // 删除老文件
330
+ const filePath = this.getWorkspaceFilePath(fileKey, nodeId, 'promptSetting.json', { skipParsePath: true });
331
+ await fs.unlink(filePath).catch((err) => Logger.log(`删除老文件失败: ${err}`));
332
+ return JSON.parse(oldContent);
333
+ }
334
+ // 读取新格式:所有 .md 文件,直接从 .sloth/d2c-prompt/ 目录读取
335
+ const dirPath = path.join(this.workspaceRoot, '.sloth', 'd2c-prompt');
336
+ const files = await fs.readdir(dirPath).catch(() => []);
337
+ const result = {};
338
+ for (const file of files) {
339
+ if (file.endsWith('.md')) {
340
+ // 将 kebab-case 文件名转换回 camelCase key
341
+ const kebabKey = file.replace(/\.md$/, '');
342
+ const key = this.kebabToCamel(kebabKey);
343
+ const filePath = path.join(dirPath, file);
344
+ const content = await fs.readFile(filePath, 'utf-8').catch(() => '');
345
+ if (content) {
346
+ result[key] = content;
347
+ }
348
+ }
349
+ }
350
+ return Object.keys(result).length > 0 ? result : null;
351
+ }
352
+ catch (error) {
353
+ Logger.log(`加载 promptSetting 失败,返回 null: ${error}`);
354
+ return null;
355
+ }
356
+ }
357
+ /**
358
+ * 保存 configSetting 到特定的 nodeId 文件(保存到工作目录的 .sloth 文件夹)
359
+ * @param fileKey - Figma文件的key
360
+ * @param nodeId - 节点ID(可选)
361
+ * @param configSetting - 配置设置
362
+ */
363
+ async saveConfigSetting(fileKey, nodeId, configSetting) {
364
+ fileKey = '.';
365
+ nodeId = '.';
366
+ await this.saveFile(fileKey, nodeId, 'configSetting.json', JSON.stringify(configSetting, null, 2), { useWorkspaceDir: true, skipParsePath: true });
367
+ }
368
+ /**
369
+ * 加载 configSetting 从特定的 nodeId 文件
370
+ * @param fileKey - Figma文件的key
371
+ * @param nodeId - 节点ID(可选)
372
+ * @returns Promise<any> - 配置设置,如果文件不存在则返回 null
373
+ */
374
+ async loadConfigSetting(fileKey, nodeId) {
375
+ fileKey = '.';
376
+ nodeId = '.';
377
+ try {
378
+ const content = await this.loadFile(fileKey, nodeId, 'configSetting.json', { useWorkspaceDir: true, skipParsePath: true });
379
+ return content ? JSON.parse(content) : null;
380
+ }
381
+ catch (error) {
382
+ Logger.log(`加载 configSetting 失败,返回 null: ${error}`);
383
+ return null;
384
+ }
385
+ }
386
+ /**
387
+ * 列出指定 fileKey 下的所有 nodeId(目录名)
388
+ * @param fileKey - Figma 文件的 key
389
+ */
390
+ async listNodeIds(fileKey) {
391
+ const workspaceDir = this.getWorkspaceFileKeyDirPath(fileKey);
392
+ const targetDir = workspaceDir || this.getFileKeyDir(fileKey);
393
+ try {
394
+ const dirents = await fs.readdir(targetDir, { withFileTypes: true });
395
+ return dirents.filter((d) => d.isDirectory()).map((d) => d.name);
396
+ }
397
+ catch {
398
+ return [];
399
+ }
400
+ }
401
+ /**
402
+ * 列出所有 fileKey
403
+ * @returns Promise<string[]> - 所有 fileKey 的列表
404
+ */
405
+ async listAllFileKeys() {
406
+ try {
407
+ if (!this.workspaceRoot) {
408
+ Logger.log('workspaceRoot 未设置,无法列出所有 fileKey');
409
+ return [];
410
+ }
411
+ const slothDir = path.join(this.workspaceRoot, '.sloth');
412
+ const entries = await fs.readdir(slothDir, { withFileTypes: true });
413
+ const fileKeys = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
414
+ Logger.log(`找到 ${fileKeys.length} 个 fileKey:`, fileKeys);
415
+ return fileKeys;
416
+ }
417
+ catch {
418
+ return [];
419
+ }
420
+ }
421
+ /**
422
+ * 加载指定 fileKey 下所有 nodeId 的 groupsData
423
+ * @param fileKey - Figma 文件的 key
424
+ */
425
+ async loadAllGroupsData(fileKey) {
426
+ const nodeIds = await this.listNodeIds(fileKey);
427
+ const results = [];
428
+ Logger.log(`加载指定 fileKey 下所有 nodeId 的 groupsData: ${fileKey}, nodeIds: ${nodeIds}`);
429
+ if (nodeIds.length === 0) {
430
+ const groups = await this.loadGroupsData(fileKey, undefined);
431
+ if (groups.length > 0) {
432
+ results.push({ nodeId: 'root', groups });
433
+ }
434
+ return results;
435
+ }
436
+ for (const nodeId of nodeIds) {
437
+ const normalizedNodeId = nodeId === 'root' ? undefined : nodeId;
438
+ Logger.log(`加载指定 fileKey 下 nodeId 的 groupsData: ${fileKey}, nodeId: ${nodeId}`);
439
+ const groups = await this.loadGroupsData(fileKey, normalizedNodeId);
440
+ if (groups && groups.length > 0) {
441
+ results.push({ nodeId, groups });
442
+ }
443
+ }
444
+ return results;
445
+ }
446
+ /**
447
+ * 加载整个项目所有 fileKey 的所有 groupsData(用于跨文件组件匹配)
448
+ * @returns Promise<Array<{ fileKey: string; nodeId: string; groups: any[] }>>
449
+ */
450
+ async loadAllProjectGroupsData() {
451
+ const fileKeys = await this.listAllFileKeys();
452
+ const results = [];
453
+ Logger.log(`开始加载整个项目的 groupsData,共 ${fileKeys.length} 个 fileKey`);
454
+ for (const fileKey of fileKeys) {
455
+ const groupsDataByNode = await this.loadAllGroupsData(fileKey);
456
+ for (const { nodeId, groups } of groupsDataByNode) {
457
+ if (groups && groups.length > 0) {
458
+ results.push({ fileKey, nodeId, groups });
459
+ }
460
+ }
461
+ }
462
+ Logger.log(`项目 groupsData 加载完成,共 ${results.length} 个节点有分组数据`);
463
+ return results;
464
+ }
465
+ /**
466
+ * 获取 screenshots 目录路径
467
+ * @param fileKey - Figma文件的key
468
+ * @param nodeId - 节点ID(可选)
469
+ * @returns string - screenshots 目录的完整路径
470
+ */
471
+ getScreenshotsDir(fileKey, nodeId) {
472
+ const nodeDir = this.getWorkspaceFilePath(fileKey, nodeId, '');
473
+ return path.join(nodeDir, 'screenshots');
474
+ }
475
+ /**
476
+ * 保存截图文件
477
+ * @param fileKey - Figma文件的key
478
+ * @param nodeId - 节点ID(可选)
479
+ * @param hash - 截图文件的哈希值(作为文件名)
480
+ * @param buffer - 截图文件的二进制数据
481
+ */
482
+ async saveScreenshot(fileKey, nodeId, hash, buffer) {
483
+ try {
484
+ const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
485
+ // 确保目录存在
486
+ await fs.mkdir(screenshotsDir, { recursive: true });
487
+ const filePath = path.join(screenshotsDir, `${hash}.png`);
488
+ await fs.writeFile(filePath, buffer);
489
+ Logger.log(`截图已保存: ${filePath}`);
490
+ }
491
+ catch (error) {
492
+ Logger.error(`保存截图失败: ${error}`);
493
+ throw error;
494
+ }
495
+ }
496
+ /**
497
+ * 加载截图文件
498
+ * @param fileKey - Figma文件的key
499
+ * @param nodeId - 节点ID(可选)
500
+ * @param hash - 截图文件的哈希值
501
+ * @returns Promise<Buffer> - 截图文件的二进制数据
502
+ */
503
+ async loadScreenshot(fileKey, nodeId, hash) {
504
+ try {
505
+ const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
506
+ const filePath = path.join(screenshotsDir, `${hash}.png`);
507
+ const buffer = await fs.readFile(filePath);
508
+ return buffer;
509
+ }
510
+ catch (err) {
511
+ if (err.code === 'ENOENT') {
512
+ Logger.log(`截图文件不存在: ${hash}.png`);
513
+ return null;
514
+ }
515
+ Logger.error(`加载截图失败: ${err.toString()}`);
516
+ throw err;
517
+ }
518
+ }
519
+ /**
520
+ * 检查截图文件是否存在
521
+ * @param fileKey - Figma文件的key
522
+ * @param nodeId - 节点ID(可选)
523
+ * @param hash - 截图文件的哈希值
524
+ * @returns Promise<boolean> - 如果文件存在则返回 true
525
+ */
526
+ async screenshotExists(fileKey, nodeId, hash) {
527
+ try {
528
+ const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
529
+ const filePath = path.join(screenshotsDir, `${hash}.png`);
530
+ await fs.access(filePath);
531
+ return true;
532
+ }
533
+ catch {
534
+ return false;
535
+ }
536
+ }
537
+ /**
538
+ * 获取截图文件的完整路径
539
+ * @param fileKey - Figma文件的key
540
+ * @param nodeId - 节点ID(可选)
541
+ * @param hash - 截图文件的哈希值
542
+ * @returns string - 截图文件的完整路径
543
+ */
544
+ getScreenshotPath(fileKey, nodeId, hash) {
545
+ const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
546
+ return path.join(screenshotsDir, `${hash}.png`);
547
+ }
548
+ /**
549
+ * 删除截图文件
550
+ * @param fileKey - Figma文件的key
551
+ * @param nodeId - 节点ID(可选)
552
+ * @param hash - 截图文件的哈希值
553
+ */
554
+ async deleteScreenshot(fileKey, nodeId, hash) {
555
+ try {
556
+ const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
557
+ const filePath = path.join(screenshotsDir, `${hash}.png`);
558
+ await fs.unlink(filePath);
559
+ Logger.log(`截图已删除: ${filePath}`);
560
+ }
561
+ catch (err) {
562
+ if (err.code !== 'ENOENT') {
563
+ Logger.error(`删除截图失败: ${err.toString()}`);
564
+ throw err;
565
+ }
566
+ }
567
+ }
568
+ /**
569
+ * 清理旧的截图文件(根据时间戳)
570
+ * @param fileKey - Figma文件的key
571
+ * @param nodeId - 节点ID(可选)
572
+ * @param maxAgeDays - 最大保留天数
573
+ */
574
+ async cleanupOldScreenshots(fileKey, nodeId, maxAgeDays = 30) {
575
+ try {
576
+ const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
577
+ // 检查目录是否存在
578
+ try {
579
+ await fs.access(screenshotsDir);
580
+ }
581
+ catch {
582
+ // 目录不存在,直接返回
583
+ return;
584
+ }
585
+ const files = await fs.readdir(screenshotsDir);
586
+ const now = Date.now();
587
+ const maxAge = maxAgeDays * 24 * 60 * 60 * 1000;
588
+ let deletedCount = 0;
589
+ for (const file of files) {
590
+ const filePath = path.join(screenshotsDir, file);
591
+ const stats = await fs.stat(filePath);
592
+ const age = now - stats.mtimeMs;
593
+ if (age > maxAge) {
594
+ await fs.unlink(filePath);
595
+ deletedCount++;
596
+ }
597
+ }
598
+ if (deletedCount > 0) {
599
+ Logger.log(`清理了 ${deletedCount} 个旧截图文件(超过 ${maxAgeDays} 天)`);
600
+ }
601
+ }
602
+ catch (error) {
603
+ Logger.error(`清理旧截图失败: ${error}`);
604
+ }
605
+ }
606
+ /**
607
+ * 读取组件数据库
608
+ * @returns Promise<StoredComponent[]> - 组件列表
609
+ */
610
+ async loadComponentsDatabase() {
611
+ const workspaceRoot = this.getWorkspaceRoot();
612
+ if (!workspaceRoot) {
613
+ Logger.warn('工作目录根路径未设置,无法加载组件数据库');
614
+ return [];
615
+ }
616
+ const componentsPath = path.join(workspaceRoot, '.sloth', 'components.json');
617
+ try {
618
+ const content = await fs.readFile(componentsPath, 'utf-8');
619
+ // 验证文件内容不为空
620
+ if (!content || !content.trim()) {
621
+ Logger.warn('components.json 文件为空,返回空数组');
622
+ return [];
623
+ }
624
+ const components = JSON.parse(content);
625
+ // 验证数据格式
626
+ if (!Array.isArray(components)) {
627
+ Logger.warn('components.json 格式错误(不是数组),返回空数组');
628
+ return [];
629
+ }
630
+ Logger.log(`成功加载 ${components.length} 个组件`);
631
+ return components;
632
+ }
633
+ catch (error) {
634
+ const err = error;
635
+ if (err.code === 'ENOENT') {
636
+ // 文件不存在,返回空数组
637
+ Logger.log('components.json 不存在,将创建新文件');
638
+ return [];
639
+ }
640
+ if (err instanceof SyntaxError) {
641
+ Logger.error('components.json JSON 解析失败:', err.message);
642
+ return [];
643
+ }
644
+ Logger.error('读取组件数据库失败:', err);
645
+ throw error;
646
+ }
647
+ }
648
+ /**
649
+ * 保存组件数据库
650
+ * @param components - 要保存的组件列表
651
+ */
652
+ async saveComponentsDatabase(components) {
653
+ const workspaceRoot = this.getWorkspaceRoot();
654
+ const slothDir = path.join(workspaceRoot, '.sloth');
655
+ const componentsPath = path.join(slothDir, 'components.json');
656
+ // 确保目录存在
657
+ try {
658
+ await fs.mkdir(slothDir, { recursive: true });
659
+ }
660
+ catch (error) {
661
+ Logger.error('创建 .sloth 目录失败:', error);
662
+ throw new Error(`无法创建目录: ${slothDir}`);
663
+ }
664
+ // 写入数据
665
+ try {
666
+ const jsonContent = JSON.stringify(components, null, 2);
667
+ await fs.writeFile(componentsPath, jsonContent, 'utf-8');
668
+ Logger.log(`✅ 已保存 ${components.length} 个组件到 components.json`);
669
+ }
670
+ catch (error) {
671
+ Logger.error('写入组件数据库失败:', error);
672
+ throw new Error(`无法写入文件: ${componentsPath}`);
673
+ }
674
+ }
675
+ /**
676
+ * 根据截图 hash 在整个 .sloth 目录下搜索截图文件
677
+ * @param hash - 截图文件的哈希值
678
+ * @returns Promise<string | null> - 截图文件的完整路径,如果未找到则返回 null
679
+ */
680
+ async findScreenshotByHash(hash) {
681
+ const workspaceRoot = this.getWorkspaceRoot();
682
+ if (!workspaceRoot) {
683
+ Logger.warn('工作目录根路径未设置,无法搜索截图');
684
+ return null;
685
+ }
686
+ const slothDir = path.join(workspaceRoot, '.sloth');
687
+ try {
688
+ await fs.access(slothDir);
689
+ }
690
+ catch {
691
+ Logger.warn('.sloth 目录不存在');
692
+ return null;
693
+ }
694
+ const targetFilename = `${hash}.png`;
695
+ // 递归搜索 .sloth 目录下所有 screenshots 文件夹
696
+ const searchDir = async (dir) => {
697
+ try {
698
+ const entries = await fs.readdir(dir, { withFileTypes: true });
699
+ for (const entry of entries) {
700
+ const fullPath = path.join(dir, entry.name);
701
+ if (entry.isDirectory()) {
702
+ // 如果是 screenshots 目录,检查是否有目标文件
703
+ if (entry.name === 'screenshots') {
704
+ const screenshotPath = path.join(fullPath, targetFilename);
705
+ try {
706
+ await fs.access(screenshotPath);
707
+ return screenshotPath;
708
+ }
709
+ catch {
710
+ // 文件不存在,继续搜索
711
+ }
712
+ }
713
+ else {
714
+ // 递归搜索子目录
715
+ const result = await searchDir(fullPath);
716
+ if (result)
717
+ return result;
718
+ }
719
+ }
720
+ }
721
+ return null;
722
+ }
723
+ catch (error) {
724
+ Logger.error(`搜索目录失败 ${dir}:`, error);
725
+ return null;
726
+ }
727
+ };
728
+ return searchDir(slothDir);
729
+ }
730
+ /**
731
+ * 保存 absolute.html 到 .sloth 目录,支持版本管理
732
+ * 如果内容不一致,将当前版本移动到带时间戳的文件夹中
733
+ * @param fileKey - Figma文件的key
734
+ * @param nodeId - 节点ID(可选)
735
+ * @param content - 文件内容
736
+ */
737
+ async saveAbsoluteHtml(fileKey, nodeId, content, useWorkspaceDir = false) {
738
+ const filename = 'absolute.html';
739
+ // 先尝试加载现有文件
740
+ const existingContent = await this.loadFile(fileKey, nodeId, filename, { useWorkspaceDir });
741
+ // 如果存在且内容不一致,先归档旧版本
742
+ if (existingContent && existingContent.trim() !== content.trim()) {
743
+ await this.archiveAbsoluteHtml(fileKey, nodeId, existingContent);
744
+ Logger.log(`检测到 absolute.html 内容变化,已归档旧版本`);
745
+ }
746
+ // 保存新内容
747
+ await this.saveFile(fileKey, nodeId, filename, content, { useWorkspaceDir });
748
+ }
749
+ /**
750
+ * 归档 absolute.html 到带时间戳的文件夹
751
+ * @param fileKey - Figma文件的key
752
+ * @param nodeId - 节点ID(可选)
753
+ * @param content - 要归档的内容
754
+ */
755
+ async archiveAbsoluteHtml(fileKey, nodeId, content) {
756
+ if (!this.workspaceRoot) {
757
+ Logger.warn('工作目录根路径未设置,无法归档');
758
+ return;
759
+ }
760
+ // 生成时间戳文件夹名:YYYYMMDD_HHmmss
761
+ const now = new Date();
762
+ const timestamp = now.getFullYear().toString() +
763
+ (now.getMonth() + 1).toString().padStart(2, '0') +
764
+ now.getDate().toString().padStart(2, '0') +
765
+ '_' +
766
+ now.getHours().toString().padStart(2, '0') +
767
+ now.getMinutes().toString().padStart(2, '0') +
768
+ now.getSeconds().toString().padStart(2, '0');
769
+ const cleanFileKey = fileKey.replace(/[^a-zA-Z0-9-_]/g, '_');
770
+ const cleanNodeId = nodeId ? nodeId.replace(/[^a-zA-Z0-9-_]/g, '_') : 'root';
771
+ // 归档路径:.sloth/{fileKey}/{nodeId}/history/{timestamp}/absolute.html
772
+ const archiveDir = path.join(this.workspaceRoot, '.sloth', cleanFileKey, cleanNodeId, 'history', timestamp);
773
+ try {
774
+ await fs.mkdir(archiveDir, { recursive: true });
775
+ const archivePath = path.join(archiveDir, 'absolute.html');
776
+ await fs.writeFile(archivePath, content, 'utf-8');
777
+ // 同时复制 index.png 截图(如果存在)
778
+ const screenshotsDir = this.getScreenshotsDir(fileKey, nodeId);
779
+ const indexScreenshotPath = path.join(screenshotsDir, 'index.png');
780
+ try {
781
+ await fs.access(indexScreenshotPath);
782
+ const archiveScreenshotPath = path.join(archiveDir, 'index.png');
783
+ await fs.copyFile(indexScreenshotPath, archiveScreenshotPath);
784
+ Logger.log(`截图已归档: ${archiveScreenshotPath}`);
785
+ }
786
+ catch {
787
+ // 截图不存在,跳过
788
+ }
789
+ Logger.log(`absolute.html 已归档到: ${archivePath}`);
790
+ }
791
+ catch (error) {
792
+ Logger.error(`归档 absolute.html 失败: ${error}`);
793
+ }
794
+ }
795
+ /**
796
+ * 加载 absolute.html
797
+ * @param fileKey - Figma文件的key
798
+ * @param nodeId - 节点ID(可选)
799
+ * @returns Promise<string> - 文件内容
800
+ */
801
+ async loadAbsoluteHtml(fileKey, nodeId, useWorkspaceDir = false) {
802
+ return this.loadFile(fileKey, nodeId, 'absolute.html', { useWorkspaceDir });
803
+ }
804
+ /**
805
+ * 列出 absolute.html 的所有历史版本
806
+ * @param fileKey - Figma文件的key
807
+ * @param nodeId - 节点ID(可选)
808
+ * @returns Promise<Array<{ timestamp: string; path: string }>> - 历史版本列表
809
+ */
810
+ async listAbsoluteHtmlHistory(fileKey, nodeId) {
811
+ if (!this.workspaceRoot) {
812
+ return [];
813
+ }
814
+ const cleanFileKey = fileKey.replace(/[^a-zA-Z0-9-_]/g, '_');
815
+ const cleanNodeId = nodeId ? nodeId.replace(/[^a-zA-Z0-9-_]/g, '_') : 'root';
816
+ const historyDir = path.join(this.workspaceRoot, '.sloth', cleanFileKey, cleanNodeId, 'history');
817
+ try {
818
+ const entries = await fs.readdir(historyDir, { withFileTypes: true });
819
+ const versions = [];
820
+ for (const entry of entries) {
821
+ if (entry.isDirectory()) {
822
+ const htmlPath = path.join(historyDir, entry.name, 'absolute.html');
823
+ const screenshotPath = path.join(historyDir, entry.name, 'index.png');
824
+ try {
825
+ await fs.access(htmlPath);
826
+ let screenshot;
827
+ try {
828
+ await fs.access(screenshotPath);
829
+ screenshot = screenshotPath;
830
+ }
831
+ catch {
832
+ // 截图不存在
833
+ }
834
+ versions.push({
835
+ timestamp: entry.name,
836
+ path: htmlPath,
837
+ screenshotPath: screenshot,
838
+ });
839
+ }
840
+ catch {
841
+ // html 文件不存在,跳过
842
+ }
843
+ }
844
+ }
845
+ // 按时间戳倒序排列(最新的在前)
846
+ versions.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
847
+ return versions;
848
+ }
849
+ catch {
850
+ return [];
851
+ }
852
+ }
853
+ /**
854
+ * 创建一个临时的 FileManager 代理,仅在本次链式调用中使用指定的 uuid
855
+ * 不会影响原始实例的 _uuid 值
856
+ * @param uuid - 临时使用的 uuid
857
+ * @returns 带有临时 uuid 的 FileManager 代理对象
858
+ */
859
+ withUUID(uuid) {
860
+ if (!uuid) {
861
+ return this;
862
+ }
863
+ const proxy = Object.create(this);
864
+ proxy._uuid = uuid;
865
+ return proxy;
866
+ }
867
+ }
868
+ export default FileManager;