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 +2 -1
- package/server/routes/file-organizer.js +104 -0
- package/server/routes/jira-filters.js +31 -2
- package/server/services/clipboard-history.js +2 -2
- package/server/services/file-organizer.js +426 -0
- package/server/services/process-manager.js +14 -5
- package/server.js +2 -0
- package/ui/dist/assets/index-jQB3dI9Z.js +524 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-BUTURQt4.js +0 -524
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buddy-workbench",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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 '
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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({
|