buddy-workbench 0.1.16 → 0.1.18
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 +1 -1
- package/server/routes/file-organizer.js +104 -0
- package/server/routes/jira-filters.js +72 -0
- package/server/services/file-organizer.js +426 -0
- package/server.js +2 -0
- package/ui/dist/assets/index-iN37nEIi.js +524 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-C8IWgbWG.js +0 -524
package/package.json
CHANGED
|
@@ -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;
|
|
@@ -158,4 +158,76 @@ router.get('/:id/issues', async (req, res) => {
|
|
|
158
158
|
}
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
// Clone an issue
|
|
162
|
+
router.post('/issues/clone', async (req, res) => {
|
|
163
|
+
const { issueKey, summary } = req.body || {};
|
|
164
|
+
if (!summary || typeof summary !== 'string' || !summary.trim()) {
|
|
165
|
+
return res.status(400).json({ error: 'Summary is required for clone.' });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const jiraHost = getJiraHost();
|
|
169
|
+
if (!jiraHost) {
|
|
170
|
+
return res.status(400).json({ error: 'Jira domain is not configured. Please set Domain in Settings.' });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const settings = readSettings();
|
|
174
|
+
const token = settings.jiraAccessToken;
|
|
175
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
176
|
+
if (token) {
|
|
177
|
+
headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
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
|
+
|
|
197
|
+
const projectKey = issueKey ? issueKey.split('-')[0] : '';
|
|
198
|
+
const url = `https://${jiraHost}/rest/api/2/issue`;
|
|
199
|
+
const payload = {
|
|
200
|
+
fields: {
|
|
201
|
+
summary: summary.trim(),
|
|
202
|
+
...(projectKey ? { project: { key: projectKey } } : {}),
|
|
203
|
+
issuetype: { name: 'Task' },
|
|
204
|
+
...(assigneeField ? { assignee: assigneeField } : {})
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const response = await httpClient.post(url, payload, { headers });
|
|
209
|
+
if (response.status !== 201 && response.status !== 200) {
|
|
210
|
+
const errMsg = response.data?.errorMessages?.[0] || response.data?.message || `Jira API returned status ${response.status}`;
|
|
211
|
+
return res.status(response.status).json({ error: errMsg });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const created = response.data || {};
|
|
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 } });
|
|
228
|
+
} catch (error) {
|
|
229
|
+
res.status(500).json({ error: error.message || 'Failed to clone Jira issue.' });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
161
233
|
export default router;
|
|
@@ -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
|
+
}
|
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({
|