buddy-workbench 0.1.17 → 0.1.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-workbench",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "version": "node -e \"const v=process.env.npm_package_version; const fs=require('fs'); ['ui/package.json', 'ui/package-lock.json'].forEach(p=>{if(fs.existsSync(p)){const j=JSON.parse(fs.readFileSync(p)); j.version=v; if(j.packages&&j.packages['']){j.packages[''].version=v;} fs.writeFileSync(p, JSON.stringify(j,null,2)+'\\n');}});\" && git add ui/package.json ui/package-lock.json"
23
23
  },
24
24
  "dependencies": {
25
+ "@jsquash/jpeg": "^1.6.0",
25
26
  "axios": "^1.7.9",
26
27
  "express": "^5.1.0"
27
28
  }
@@ -0,0 +1,104 @@
1
+ import express from 'express';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { expandPath, scanFolder, executeOrganize, getHistoryList, undoSession } from '../services/file-organizer.js';
5
+
6
+ const router = express.Router();
7
+
8
+ // POST /api/file-organizer/scan
9
+ router.post('/scan', async (req, res, next) => {
10
+ try {
11
+ const { sourcePath, targetPath, includeSubfolders, skipOrganized } = req.body;
12
+ if (!sourcePath) {
13
+ return res.status(400).json({ error: '请指定需要整理的源文件夹路径' });
14
+ }
15
+
16
+ const result = await scanFolder({
17
+ sourcePath,
18
+ targetPath,
19
+ includeSubfolders: Boolean(includeSubfolders),
20
+ skipOrganized: skipOrganized !== false
21
+ });
22
+
23
+ res.json(result);
24
+ } catch (err) {
25
+ next(err);
26
+ }
27
+ });
28
+
29
+ // POST /api/file-organizer/execute
30
+ router.post('/execute', async (req, res, next) => {
31
+ try {
32
+ const { sourcePath, targetPath, files, operation = 'move' } = req.body;
33
+ if (!sourcePath || !targetPath) {
34
+ return res.status(400).json({ error: '必须同时提供源文件夹和目标文件夹路径' });
35
+ }
36
+ if (!Array.isArray(files) || files.length === 0) {
37
+ return res.status(400).json({ error: '请勾选需要整理的文件' });
38
+ }
39
+
40
+ const session = await executeOrganize({
41
+ sourcePath,
42
+ targetPath,
43
+ files,
44
+ operation
45
+ });
46
+
47
+ res.json(session);
48
+ } catch (err) {
49
+ next(err);
50
+ }
51
+ });
52
+
53
+ // GET /api/file-organizer/history
54
+ router.get('/history', (req, res) => {
55
+ try {
56
+ const list = getHistoryList();
57
+ res.json(list);
58
+ } catch (err) {
59
+ res.status(500).json({ error: err.message });
60
+ }
61
+ });
62
+
63
+ // POST /api/file-organizer/undo
64
+ router.post('/undo', async (req, res, next) => {
65
+ try {
66
+ const { sessionId } = req.body;
67
+ if (!sessionId) {
68
+ return res.status(400).json({ error: '必须提供 sessionId' });
69
+ }
70
+
71
+ const session = await undoSession(sessionId);
72
+ res.json(session);
73
+ } catch (err) {
74
+ next(err);
75
+ }
76
+ });
77
+
78
+ // POST /api/file-organizer/browse
79
+ router.post('/browse', async (req, res) => {
80
+ try {
81
+ const dirPath = expandPath(req.body.path || '~');
82
+ if (!fs.existsSync(dirPath)) {
83
+ return res.status(400).json({ error: '路径不存在' });
84
+ }
85
+
86
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
87
+ const subdirs = entries
88
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
89
+ .map(e => ({
90
+ name: e.name,
91
+ fullPath: path.join(dirPath, e.name)
92
+ }));
93
+
94
+ res.json({
95
+ currentPath: dirPath,
96
+ parentPath: path.dirname(dirPath),
97
+ subdirectories: subdirs
98
+ });
99
+ } catch (err) {
100
+ res.status(500).json({ error: err.message });
101
+ }
102
+ });
103
+
104
+ export default router;
@@ -178,13 +178,30 @@ router.post('/issues/clone', async (req, res) => {
178
178
  }
179
179
 
180
180
  try {
181
+ // Fetch current authenticated user's info from Jira
182
+ let assigneeField = null;
183
+ try {
184
+ const myselfUrl = `https://${jiraHost}/rest/api/2/myself`;
185
+ const myselfRes = await httpClient.get(myselfUrl, { headers });
186
+ if (myselfRes.status === 200 && myselfRes.data) {
187
+ if (myselfRes.data.accountId) {
188
+ assigneeField = { accountId: myselfRes.data.accountId };
189
+ } else if (myselfRes.data.name) {
190
+ assigneeField = { name: myselfRes.data.name };
191
+ }
192
+ }
193
+ } catch {
194
+ // Ignore if fetching current user fails
195
+ }
196
+
181
197
  const projectKey = issueKey ? issueKey.split('-')[0] : '';
182
198
  const url = `https://${jiraHost}/rest/api/2/issue`;
183
199
  const payload = {
184
200
  fields: {
185
201
  summary: summary.trim(),
186
202
  ...(projectKey ? { project: { key: projectKey } } : {}),
187
- issuetype: { name: 'Task' }
203
+ issuetype: { name: 'Task' },
204
+ ...(assigneeField ? { assignee: assigneeField } : {})
188
205
  }
189
206
  };
190
207
 
@@ -195,7 +212,19 @@ router.post('/issues/clone', async (req, res) => {
195
212
  }
196
213
 
197
214
  const created = response.data || {};
198
- res.status(201).json({ success: true, issue: { key: created.key || issueKey, id: created.id } });
215
+ const createdKey = created.key || issueKey;
216
+
217
+ // Fallback: Assign explicitly if not assigned during creation
218
+ if (createdKey && assigneeField) {
219
+ try {
220
+ const assignUrl = `https://${jiraHost}/rest/api/2/issue/${createdKey}/assignee`;
221
+ await httpClient.put(assignUrl, assigneeField, { headers });
222
+ } catch {
223
+ // Ignore fallback assign error
224
+ }
225
+ }
226
+
227
+ res.status(201).json({ success: true, issue: { key: createdKey, id: created.id } });
199
228
  } catch (error) {
200
229
  res.status(500).json({ error: error.message || 'Failed to clone Jira issue.' });
201
230
  }
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFile
4
4
  import { dirname, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { fileURLToPath } from 'node:url';
7
- import encodeMozjpeg, { init as initMozjpeg } from '../../ui/node_modules/@jsquash/jpeg/encode.js';
7
+ import encodeMozjpeg, { init as initMozjpeg } from '@jsquash/jpeg/encode.js';
8
8
  import { paths } from '../config.js';
9
9
  import { readSettings } from '../repositories/settings.js';
10
10
 
@@ -16,7 +16,7 @@ let mozjpegWasmModule = null;
16
16
 
17
17
  async function compressMozjpeg(rgbaBuf, width, height, quality = 75) {
18
18
  if (!mozjpegWasmModule) {
19
- const wasmPath = fileURLToPath(import.meta.resolve('../../ui/node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm'));
19
+ const wasmPath = fileURLToPath(import.meta.resolve('../../ui/dist/assets/mozjpeg_enc-DO-zoExo.wasm'));
20
20
  mozjpegWasmModule = await WebAssembly.compile(readFileSync(wasmPath));
21
21
  await initMozjpeg(mozjpegWasmModule);
22
22
  }
@@ -0,0 +1,426 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { paths } from '../config.js';
5
+
6
+ const HISTORY_FILE = path.join(paths.launchers ? path.dirname(paths.launchers) : 'data', 'file-organizer-history.json');
7
+
8
+ // Expand '~' to home directory
9
+ export function expandPath(filePath) {
10
+ if (!filePath || typeof filePath !== 'string') return '';
11
+ const trimmed = filePath.trim();
12
+ if (trimmed === '~' || trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
13
+ return path.join(os.homedir(), trimmed.slice(1));
14
+ }
15
+ return path.resolve(trimmed);
16
+ }
17
+
18
+ // Resolve target path: if relative, resolve against source directory
19
+ export function resolveTargetPath(targetPath, sourcePath) {
20
+ if (!targetPath || typeof targetPath !== 'string') return '';
21
+ const trimmed = targetPath.trim();
22
+ if (!trimmed) return '';
23
+
24
+ let expanded = trimmed;
25
+ if (trimmed === '~' || trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
26
+ expanded = path.join(os.homedir(), trimmed.slice(1));
27
+ }
28
+
29
+ if (path.isAbsolute(expanded)) {
30
+ return path.normalize(expanded);
31
+ }
32
+
33
+ const resolvedSource = expandPath(sourcePath);
34
+ return path.resolve(resolvedSource, trimmed);
35
+ }
36
+
37
+ // Category definition maps
38
+ const CATEGORY_CONFIG = {
39
+ screenshots: {
40
+ key: 'screenshots',
41
+ label: 'Screenshots',
42
+ subfolder: 'Screenshots',
43
+ extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'],
44
+ isMatch: (filename, ext) => {
45
+ if (!['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(ext)) return false;
46
+ const lower = filename.toLowerCase();
47
+ const patterns = [
48
+ 'screenshot', 'screen shot', '截图', '屏幕快照', '屏幕截图',
49
+ 'cleanshot', 'snipaste', 'screen recording', '录屏', '截屏',
50
+ 'shot_', 'scrn_'
51
+ ];
52
+ return patterns.some(p => lower.includes(p));
53
+ }
54
+ },
55
+ images: {
56
+ key: 'images',
57
+ label: 'Images',
58
+ subfolder: 'Images',
59
+ extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'heic', 'heif', 'psd', 'ai', 'tiff', 'raw', 'tga'],
60
+ isMatch: () => true
61
+ },
62
+ videos: {
63
+ key: 'videos',
64
+ label: 'Videos',
65
+ subfolder: 'Videos',
66
+ extensions: ['mp4', 'mkv', 'mov', 'avi', 'wmv', 'flv', 'webm', 'm4v', '3gp', 'rmvb', 'ts', 'mpg', 'mpeg'],
67
+ isMatch: () => true
68
+ },
69
+ audio: {
70
+ key: 'audio',
71
+ label: 'Audio',
72
+ subfolder: 'Audio',
73
+ extensions: ['mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg', 'wma', 'aiff', 'opus', 'mid', 'midi', 'amr'],
74
+ isMatch: () => true
75
+ },
76
+ documents: {
77
+ key: 'documents',
78
+ label: 'Documents',
79
+ subfolder: 'Documents',
80
+ extensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'pages', 'numbers', 'key', 'csv', 'epub', 'mobi', 'rtf', 'odt', 'ods', 'odp'],
81
+ isMatch: () => true
82
+ },
83
+ emails: {
84
+ key: 'emails',
85
+ label: 'Emails',
86
+ subfolder: 'Emails',
87
+ extensions: ['eml', 'msg', 'pst', 'mbox', 'emlx'],
88
+ isMatch: () => true
89
+ },
90
+ json: {
91
+ key: 'json',
92
+ label: 'JSON',
93
+ subfolder: 'JSON',
94
+ extensions: ['json', 'json5', 'jsonl', 'geojson'],
95
+ isMatch: () => true
96
+ },
97
+ code: {
98
+ key: 'code',
99
+ label: 'Code',
100
+ subfolder: 'Code',
101
+ extensions: [
102
+ 'js', 'jsx', 'ts', 'tsx', 'py', 'java', 'c', 'cpp', 'cc', 'h', 'hpp',
103
+ 'cs', 'go', 'rs', 'php', 'rb', 'html', 'htm', 'css', 'scss', 'less',
104
+ 'sh', 'zsh', 'bash', 'sql', 'yaml', 'yml', 'xml', 'vue', 'svelte',
105
+ 'swift', 'kt', 'kts', 'dart', 'lua', 'r', 'pl', 'm', 'mm', 'toml', 'ini', 'env'
106
+ ],
107
+ isMatch: () => true
108
+ },
109
+ archives: {
110
+ key: 'archives',
111
+ label: 'Archives',
112
+ subfolder: 'Archives',
113
+ extensions: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'tgz', 'z'],
114
+ isMatch: () => true
115
+ },
116
+ installers: {
117
+ key: 'installers',
118
+ label: 'Installers',
119
+ subfolder: 'Installers',
120
+ extensions: ['dmg', 'pkg', 'exe', 'msi', 'deb', 'rpm', 'apk', 'ipa', 'iso'],
121
+ isMatch: () => true
122
+ }
123
+ };
124
+
125
+ export function classifyFile(filename) {
126
+ const ext = path.extname(filename).slice(1).toLowerCase();
127
+
128
+ // First check screenshots specially
129
+ if (CATEGORY_CONFIG.screenshots.isMatch(filename, ext)) {
130
+ return {
131
+ key: CATEGORY_CONFIG.screenshots.key,
132
+ label: CATEGORY_CONFIG.screenshots.label,
133
+ subfolder: CATEGORY_CONFIG.screenshots.subfolder
134
+ };
135
+ }
136
+
137
+ // Check other categories by extension
138
+ for (const [key, cfg] of Object.entries(CATEGORY_CONFIG)) {
139
+ if (key === 'screenshots') continue;
140
+ if (cfg.extensions.includes(ext)) {
141
+ return {
142
+ key: cfg.key,
143
+ label: cfg.label,
144
+ subfolder: cfg.subfolder
145
+ };
146
+ }
147
+ }
148
+
149
+ return {
150
+ key: 'others',
151
+ label: 'Others',
152
+ subfolder: 'Others'
153
+ };
154
+ }
155
+
156
+ export async function scanFolder({
157
+ sourcePath,
158
+ targetPath = '',
159
+ includeSubfolders = false,
160
+ skipOrganized = true
161
+ }) {
162
+ const resolvedSource = expandPath(sourcePath);
163
+ if (!resolvedSource || !fs.existsSync(resolvedSource)) {
164
+ throw new Error(`Source directory does not exist: ${sourcePath}`);
165
+ }
166
+
167
+ const stat = await fs.promises.stat(resolvedSource);
168
+ if (!stat.isDirectory()) {
169
+ throw new Error(`Input path is not a valid directory: ${sourcePath}`);
170
+ }
171
+
172
+ const resolvedTarget = targetPath ? resolveTargetPath(targetPath, resolvedSource) : '';
173
+ const fileItems = [];
174
+
175
+ async function walk(dirPath, relativeDir = '') {
176
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
177
+
178
+ for (const entry of entries) {
179
+ const name = entry.name;
180
+ // Skip system hidden metadata
181
+ if (name === '.DS_Store' || name === '.git' || name === '.localized' || name === 'Thumbs.db') {
182
+ continue;
183
+ }
184
+
185
+ const fullPath = path.join(dirPath, name);
186
+ const relativePath = relativeDir ? path.join(relativeDir, name) : name;
187
+
188
+ if (entry.isDirectory()) {
189
+ // Skip rules for subdirectories
190
+ if (skipOrganized) {
191
+ const lowerName = name.toLowerCase();
192
+ if (lowerName.startsWith('organized') || lowerName.includes('_organized_')) {
193
+ continue;
194
+ }
195
+ if (resolvedTarget && fullPath.startsWith(resolvedTarget)) {
196
+ continue;
197
+ }
198
+ }
199
+
200
+ if (includeSubfolders) {
201
+ await walk(fullPath, relativePath);
202
+ }
203
+ } else if (entry.isFile()) {
204
+ const fileStat = await fs.promises.stat(fullPath);
205
+ const classification = classifyFile(name);
206
+
207
+ fileItems.push({
208
+ id: relativePath,
209
+ filename: name,
210
+ relativePath,
211
+ fullPath,
212
+ extension: path.extname(name).slice(1).toLowerCase(),
213
+ size: fileStat.size,
214
+ categoryKey: classification.key,
215
+ categoryLabel: classification.label,
216
+ subfolder: classification.subfolder,
217
+ selected: true
218
+ });
219
+ }
220
+ }
221
+ }
222
+
223
+ await walk(resolvedSource);
224
+
225
+ // Stats calculation
226
+ const stats = {
227
+ totalCount: fileItems.length,
228
+ totalSize: fileItems.reduce((acc, item) => acc + item.size, 0),
229
+ byCategory: {}
230
+ };
231
+
232
+ const categories = ['screenshots', 'images', 'videos', 'audio', 'documents', 'emails', 'json', 'code', 'archives', 'installers', 'others'];
233
+ for (const cat of categories) {
234
+ const items = fileItems.filter(f => f.categoryKey === cat);
235
+ const label = cat === 'others' ? 'Others' : CATEGORY_CONFIG[cat]?.label || cat;
236
+ const subfolder = cat === 'others' ? 'Others' : CATEGORY_CONFIG[cat]?.subfolder || cat;
237
+ stats.byCategory[cat] = {
238
+ key: cat,
239
+ label,
240
+ subfolder,
241
+ count: items.length,
242
+ size: items.reduce((acc, item) => acc + item.size, 0)
243
+ };
244
+ }
245
+
246
+ return {
247
+ sourcePath: resolvedSource,
248
+ files: fileItems,
249
+ stats
250
+ };
251
+ }
252
+
253
+ function getUniqueDestinationPath(targetDir, filename) {
254
+ let destPath = path.join(targetDir, filename);
255
+ if (!fs.existsSync(destPath)) {
256
+ return destPath;
257
+ }
258
+
259
+ const ext = path.extname(filename);
260
+ const baseName = path.basename(filename, ext);
261
+ let counter = 1;
262
+
263
+ while (fs.existsSync(destPath)) {
264
+ destPath = path.join(targetDir, `${baseName}_${counter}${ext}`);
265
+ counter++;
266
+ }
267
+ return destPath;
268
+ }
269
+
270
+ function loadHistory() {
271
+ try {
272
+ if (fs.existsSync(HISTORY_FILE)) {
273
+ const data = fs.readFileSync(HISTORY_FILE, 'utf-8');
274
+ return JSON.parse(data);
275
+ }
276
+ } catch (err) {
277
+ console.error('Failed to load history:', err);
278
+ }
279
+ return [];
280
+ }
281
+
282
+ function saveHistory(history) {
283
+ try {
284
+ const dir = path.dirname(HISTORY_FILE);
285
+ if (!fs.existsSync(dir)) {
286
+ fs.mkdirSync(dir, { recursive: true });
287
+ }
288
+ fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2), 'utf-8');
289
+ } catch (err) {
290
+ console.error('Failed to save history:', err);
291
+ }
292
+ }
293
+
294
+ export async function executeOrganize({
295
+ sourcePath,
296
+ targetPath,
297
+ files,
298
+ operation = 'move'
299
+ }) {
300
+ const resolvedSource = expandPath(sourcePath);
301
+ const resolvedTarget = resolveTargetPath(targetPath, resolvedSource);
302
+
303
+ if (!resolvedSource || !fs.existsSync(resolvedSource)) {
304
+ throw new Error(`Source directory does not exist: ${sourcePath}`);
305
+ }
306
+ if (!resolvedTarget) {
307
+ throw new Error('Target directory not specified');
308
+ }
309
+
310
+ // Ensure target folder exists
311
+ await fs.promises.mkdir(resolvedTarget, { recursive: true });
312
+
313
+ const records = [];
314
+ let successCount = 0;
315
+ let failCount = 0;
316
+
317
+ for (const item of files) {
318
+ try {
319
+ const categoryDir = path.join(resolvedTarget, item.subfolder || 'Others');
320
+ await fs.promises.mkdir(categoryDir, { recursive: true });
321
+
322
+ const destPath = getUniqueDestinationPath(categoryDir, item.filename);
323
+
324
+ if (operation === 'copy') {
325
+ await fs.promises.copyFile(item.fullPath, destPath);
326
+ } else {
327
+ await fs.promises.rename(item.fullPath, destPath);
328
+ }
329
+
330
+ records.push({
331
+ filename: item.filename,
332
+ originalPath: item.fullPath,
333
+ newPath: destPath,
334
+ categorySubfolder: item.subfolder,
335
+ operation,
336
+ status: 'success'
337
+ });
338
+ successCount++;
339
+ } catch (err) {
340
+ console.error(`Failed to ${operation} file ${item.fullPath}:`, err);
341
+ records.push({
342
+ filename: item.filename,
343
+ originalPath: item.fullPath,
344
+ categorySubfolder: item.subfolder,
345
+ operation,
346
+ status: 'failed',
347
+ error: err.message
348
+ });
349
+ failCount++;
350
+ }
351
+ }
352
+
353
+ // Save history session
354
+ const history = loadHistory();
355
+ const session = {
356
+ id: `organize_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
357
+ timestamp: new Date().toISOString(),
358
+ sourcePath: resolvedSource,
359
+ targetPath: resolvedTarget,
360
+ operation,
361
+ totalFiles: files.length,
362
+ successCount,
363
+ failCount,
364
+ status: 'completed',
365
+ records
366
+ };
367
+
368
+ history.unshift(session);
369
+ if (history.length > 50) history.pop();
370
+ saveHistory(history);
371
+
372
+ return session;
373
+ }
374
+
375
+ export function getHistoryList() {
376
+ return loadHistory();
377
+ }
378
+
379
+ export async function undoSession(sessionId) {
380
+ const history = loadHistory();
381
+ const index = history.findIndex(s => s.id === sessionId);
382
+ if (index === -1) {
383
+ throw new Error('History session record not found');
384
+ }
385
+
386
+ const session = history[index];
387
+ if (session.status === 'reverted') {
388
+ throw new Error('This organization session has already been reverted.');
389
+ }
390
+
391
+ let revertSuccessCount = 0;
392
+ let revertFailCount = 0;
393
+
394
+ for (const record of session.records) {
395
+ if (record.status !== 'success' || !record.newPath) continue;
396
+
397
+ try {
398
+ if (session.operation === 'move') {
399
+ if (fs.existsSync(record.newPath)) {
400
+ const parentDir = path.dirname(record.originalPath);
401
+ await fs.promises.mkdir(parentDir, { recursive: true });
402
+ await fs.promises.rename(record.newPath, record.originalPath);
403
+ revertSuccessCount++;
404
+ }
405
+ } else if (session.operation === 'copy') {
406
+ if (fs.existsSync(record.newPath)) {
407
+ await fs.promises.unlink(record.newPath);
408
+ revertSuccessCount++;
409
+ }
410
+ }
411
+ } catch (err) {
412
+ console.error(`Failed to revert ${record.filename}:`, err);
413
+ revertFailCount++;
414
+ }
415
+ }
416
+
417
+ session.status = 'reverted';
418
+ session.revertedAt = new Date().toISOString();
419
+ session.revertSuccessCount = revertSuccessCount;
420
+ session.revertFailCount = revertFailCount;
421
+
422
+ history[index] = session;
423
+ saveHistory(history);
424
+
425
+ return session;
426
+ }
@@ -20,11 +20,13 @@ export function countErrorBlocks(text, isStderr = false) {
20
20
  const lines = text.split(/\r?\n/);
21
21
  let count = 0;
22
22
  let inErrorBlock = false;
23
+ let lastWasNpmErr = false;
23
24
 
24
25
  for (const line of lines) {
25
26
  const trimmed = line.trim();
26
27
  if (!trimmed) {
27
28
  inErrorBlock = false;
29
+ lastWasNpmErr = false;
28
30
  continue;
29
31
  }
30
32
 
@@ -39,30 +41,35 @@ export function countErrorBlocks(text, isStderr = false) {
39
41
  /^(?:npm WARN|yarn warning|pnpm warning)/i.test(cleanLine) ||
40
42
  /^\s*\((?:node:\d+|node:|Use\b)/i.test(cleanLine) ||
41
43
  /^[ℹ✔]/u.test(cleanLine) ||
42
- /\b(?:compiled successfully|build completed|done in)\b/i.test(cleanLine);
44
+ /\b(?:compiled successfully|build completed|done in)\b/i.test(cleanLine) ||
45
+ /\[(?:built|emitted|code generated|rendered|orphan|hidden)\]/i.test(cleanLine) ||
46
+ /\bnamespace object\b/i.test(cleanLine);
43
47
 
44
48
  if (isNonErrorLine) {
45
49
  inErrorBlock = false;
50
+ lastWasNpmErr = false;
46
51
  continue;
47
52
  }
48
53
 
54
+ const isNpmErrLine = /^npm ERR!/i.test(cleanLine);
55
+
49
56
  const isNewHeader =
50
57
  /^(?:\[?(?:error|fatal|fail|failed)\]?|error:|uncaught|fatal:)/i.test(cleanLine) ||
51
58
  /^(?:TypeError|ReferenceError|SyntaxError|RangeError|EvalError|URIError|Error|UnhandledPromiseRejectionWarning|UnhandledPromiseRejection):/i.test(cleanLine) ||
52
- /^npm ERR! (?:code|syscall|path|errno)/i.test(cleanLine) ||
59
+ (isNpmErrLine && !lastWasNpmErr) ||
53
60
  /^\[vite\] Internal server error/i.test(cleanLine) ||
54
61
  /\bTS\d{4,5}\b/i.test(cleanLine) ||
55
62
  /\berror TS\d+/i.test(cleanLine) ||
56
63
  /^(?:\[TypeScript\]|TS ERROR)/i.test(cleanLine);
57
64
 
58
- if (isNewHeader) {
65
+ if (isNewHeader && !isNpmErrLine) {
59
66
  inErrorBlock = false;
60
67
  }
61
68
 
62
69
  const isErrorLine =
63
70
  isNewHeader ||
64
- /\b(error|failed|fatal|exception)\b/i.test(cleanLine) ||
65
- /^npm ERR!/i.test(cleanLine) ||
71
+ isNpmErrLine ||
72
+ /(?<![/\\.-])\b(error|failed|fatal|exception)\b(?![/\\.-])/i.test(cleanLine) ||
66
73
  /\bTS\d{4,5}\b/i.test(cleanLine) ||
67
74
  /^\s*at\s+[\w\d_$.<>]+\s+\(/i.test(line);
68
75
 
@@ -71,8 +78,10 @@ export function countErrorBlocks(text, isStderr = false) {
71
78
  count++;
72
79
  inErrorBlock = true;
73
80
  }
81
+ if (isNpmErrLine) lastWasNpmErr = true;
74
82
  } else {
75
83
  inErrorBlock = false;
84
+ lastWasNpmErr = false;
76
85
  }
77
86
  }
78
87
 
package/server.js CHANGED
@@ -19,6 +19,7 @@ import staticPagesRoutes from './server/routes/static-pages.js';
19
19
  import errorRoutes from './server/routes/errors.js';
20
20
  import postmanRoutes from './server/routes/postman.js';
21
21
  import bookmarkSyncRoutes from './server/routes/bookmark-sync.js';
22
+ import fileOrganizerRoutes from './server/routes/file-organizer.js';
22
23
  import { addErrorRecord } from './server/repositories/errors.js';
23
24
  import { startClipboardCapture } from './server/services/clipboard-history.js';
24
25
 
@@ -73,6 +74,7 @@ app.use('/api/static-pages', staticPagesRoutes);
73
74
  app.use('/api/errors', errorRoutes);
74
75
  app.use('/api/postman', postmanRoutes);
75
76
  app.use('/api/bookmark-sync', bookmarkSyncRoutes);
77
+ app.use('/api/file-organizer', fileOrganizerRoutes);
76
78
 
77
79
  app.use((err, req, res, _next) => {
78
80
  addErrorRecord({