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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-workbench",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -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({