buddy-workbench 0.1.18 → 0.1.20

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.18",
3
+ "version": "0.1.20",
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
  }
package/server/config.js CHANGED
@@ -12,6 +12,7 @@ export const paths = {
12
12
  staticPages: join(root, 'data', 'static-pages.json'),
13
13
  errors: join(root, 'data', 'errors.json'),
14
14
  postman: join(root, 'data', 'postman.json'),
15
+ branchSync: join(root, 'data', 'branch-sync.json'),
15
16
  shutdownLog: join(root, 'data', 'shutdown.log'),
16
17
  clipboardDir: join(root, 'data', 'clipboard'),
17
18
  plugins: join(root, 'plugins'),
@@ -0,0 +1,56 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { paths } from '../config.js';
4
+
5
+ export function listBranchSyncApps() {
6
+ try {
7
+ return existsSync(paths.branchSync) ? JSON.parse(readFileSync(paths.branchSync, 'utf8')) : [];
8
+ } catch {
9
+ return [];
10
+ }
11
+ }
12
+
13
+ export function saveBranchSyncApps(apps) {
14
+ mkdirSync(dirname(paths.branchSync), { recursive: true });
15
+ writeFileSync(paths.branchSync, JSON.stringify(apps, null, 2));
16
+ }
17
+
18
+ export function createBranchSyncApp(data) {
19
+ const apps = listBranchSyncApps();
20
+ const newApp = {
21
+ id: `app-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
22
+ name: data.name || '',
23
+ repo: data.repo || '',
24
+ activeBranch: data.activeBranch || '',
25
+ lastReleaseBranch: data.lastReleaseBranch || '',
26
+ subApps: Array.isArray(data.subApps) ? data.subApps : [],
27
+ createdAt: new Date().toISOString()
28
+ };
29
+ apps.push(newApp);
30
+ saveBranchSyncApps(apps);
31
+ return newApp;
32
+ }
33
+
34
+ export function updateBranchSyncApp(id, data) {
35
+ const apps = listBranchSyncApps();
36
+ const index = apps.findIndex((a) => a.id === id);
37
+ if (index === -1) return null;
38
+ apps[index] = {
39
+ ...apps[index],
40
+ name: data.name !== undefined ? data.name : apps[index].name,
41
+ repo: data.repo !== undefined ? data.repo : apps[index].repo,
42
+ activeBranch: data.activeBranch !== undefined ? data.activeBranch : apps[index].activeBranch,
43
+ lastReleaseBranch: data.lastReleaseBranch !== undefined ? data.lastReleaseBranch : apps[index].lastReleaseBranch,
44
+ subApps: Array.isArray(data.subApps) ? data.subApps : apps[index].subApps,
45
+ updatedAt: new Date().toISOString()
46
+ };
47
+ saveBranchSyncApps(apps);
48
+ return apps[index];
49
+ }
50
+
51
+ export function removeBranchSyncApp(id) {
52
+ const apps = listBranchSyncApps();
53
+ const filtered = apps.filter((a) => a.id !== id);
54
+ saveBranchSyncApps(filtered);
55
+ return true;
56
+ }
@@ -0,0 +1,464 @@
1
+ import https from 'node:https';
2
+ import axios from 'axios';
3
+ import { Router } from 'express';
4
+ import { readSettings } from '../repositories/settings.js';
5
+ import {
6
+ createBranchSyncApp,
7
+ listBranchSyncApps,
8
+ removeBranchSyncApp,
9
+ updateBranchSyncApp
10
+ } from '../repositories/branch-sync.js';
11
+
12
+ const router = Router();
13
+
14
+ const httpsAgent = new https.Agent({ rejectUnauthorized: false });
15
+ const httpClient = axios.create({
16
+ httpsAgent,
17
+ validateStatus: () => true
18
+ });
19
+
20
+ function parseRepo(repoStr) {
21
+ if (!repoStr || typeof repoStr !== 'string') return null;
22
+ const settings = readSettings();
23
+ let raw = repoStr.trim().replace(/\.git$/i, '');
24
+
25
+ let protocol = 'https';
26
+ if (raw.startsWith('http://')) {
27
+ protocol = 'http';
28
+ }
29
+
30
+ let host = '';
31
+ let projectKey = '';
32
+ let repositorySlug = '';
33
+
34
+ // 1. Handle HTTP / HTTPS / SSH URLs
35
+ if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('ssh://')) {
36
+ try {
37
+ let urlStr = raw;
38
+ if (urlStr.startsWith('ssh://')) {
39
+ urlStr = urlStr.replace(/^ssh:\/\//i, 'https://');
40
+ }
41
+ // Strip basic auth (e.g. http://username@host/...)
42
+ urlStr = urlStr.replace(/^(https?:\/\/)(([^/@]+)@)/i, '$1');
43
+
44
+ const urlObj = new URL(urlStr);
45
+ host = urlObj.host; // Includes hostname and port
46
+ let pathname = urlObj.pathname.replace(/^\/+/, '');
47
+
48
+ // Case A: /projects/PROJ/repos/REPO or /users/USER/repos/REPO
49
+ const webMatch = pathname.match(/^(?:projects\/([^/]+)|users\/([^/]+))\/repos\/([^/]+)/i);
50
+ if (webMatch) {
51
+ projectKey = webMatch[1] ? webMatch[1] : `~${webMatch[2]}`;
52
+ repositorySlug = webMatch[3];
53
+ return { protocol, host, projectKey, repositorySlug };
54
+ }
55
+
56
+ // Case B: /scm/PROJ/REPO or /scm/~USER/REPO
57
+ const scmMatch = pathname.match(/^scm\/([^/]+)\/([^/]+)/i);
58
+ if (scmMatch) {
59
+ projectKey = scmMatch[1];
60
+ repositorySlug = scmMatch[2];
61
+ return { protocol, host, projectKey, repositorySlug };
62
+ }
63
+
64
+ // Case C: /PROJ/REPO
65
+ const parts = pathname.split('/').filter(Boolean);
66
+ if (parts.length >= 2) {
67
+ projectKey = parts[parts.length - 2];
68
+ repositorySlug = parts[parts.length - 1];
69
+ return { protocol, host, projectKey, repositorySlug };
70
+ }
71
+ } catch {}
72
+ }
73
+
74
+ // 2. SSH / SCP pattern: bitbucket@host/PROJECT/REPO or git@host:PROJECT/REPO
75
+ const scpMatch = raw.match(/^(?:[a-zA-Z0-9_.-]+@)?([^:/]+)[:/](.+)$/);
76
+ if (scpMatch) {
77
+ host = scpMatch[1];
78
+ let path = scpMatch[2].replace(/^\/+/, '').replace(/^scm\//i, '');
79
+ const pathParts = path.split('/').filter(Boolean);
80
+ if (pathParts.length >= 2) {
81
+ projectKey = pathParts[pathParts.length - 2];
82
+ repositorySlug = pathParts[pathParts.length - 1];
83
+ return { protocol, host, projectKey, repositorySlug };
84
+ }
85
+ }
86
+
87
+ // 3. Fallback: PROJ/REPO
88
+ const parts = raw.split('/').filter(Boolean);
89
+ if (parts.length === 2) {
90
+ projectKey = parts[0];
91
+ repositorySlug = parts[1];
92
+ const domain = settings.domain || '';
93
+ host = domain ? (domain.includes('bitbucket') ? domain : `bitbucket.${domain}`) : '';
94
+ return { protocol, host, projectKey, repositorySlug };
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ // Check branch merge status & existing PR for a single repo and branch pair
101
+ async function checkBranchPair(parsedRepo, sourceBranch, targetBranch, headers) {
102
+ if (!sourceBranch || !targetBranch) {
103
+ return { error: 'Source and target branches must be specified.' };
104
+ }
105
+ const { protocol = 'https', host, projectKey, repositorySlug } = parsedRepo;
106
+ if (!host) {
107
+ return { error: 'Could not determine Bitbucket host for repository.' };
108
+ }
109
+
110
+ const result = {
111
+ sourceBranch,
112
+ targetBranch,
113
+ merged: false,
114
+ unmergedCommitCount: 0,
115
+ openPr: null,
116
+ error: null
117
+ };
118
+
119
+ try {
120
+ // 1. Check commits in sourceBranch not in targetBranch
121
+ const commitsUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/commits?until=${encodeURIComponent(targetBranch)}&since=${encodeURIComponent(sourceBranch)}&limit=1`;
122
+ const commitsRes = await httpClient.get(commitsUrl, { headers });
123
+
124
+ if (commitsRes.status === 401) {
125
+ result.error = 'Unauthorized. Please check your Bitbucket Access Token in Settings.';
126
+ return result;
127
+ }
128
+
129
+ if (commitsRes.status < 200 || commitsRes.status >= 300) {
130
+ const errDetail = commitsRes.data?.errors?.[0]?.message || commitsRes.data?.message || commitsRes.statusText;
131
+ result.error = `Failed to check commits (${commitsRes.status}): ${errDetail}`;
132
+ return result;
133
+ }
134
+
135
+ const commits = commitsRes.data?.values || [];
136
+ if (commits.length === 0) {
137
+ result.merged = true;
138
+ result.unmergedCommitCount = 0;
139
+ } else {
140
+ result.merged = false;
141
+ result.unmergedCommitCount = commitsRes.data?.size || commits.length;
142
+
143
+ // 2. Check if an open PR already exists
144
+ try {
145
+ const prUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/pull-requests?at=refs/heads/${encodeURIComponent(sourceBranch)}&direction=OUTGOING&state=OPEN`;
146
+ const prRes = await httpClient.get(prUrl, { headers });
147
+ if (prRes.status >= 200 && prRes.status < 300) {
148
+ const prs = prRes.data?.values || [];
149
+ const existingPr = prs.find((pr) => {
150
+ const toId = pr.toRef?.id || '';
151
+ const toDisplay = pr.toRef?.displayId || '';
152
+ return toId === `refs/heads/${targetBranch}` || toDisplay === targetBranch;
153
+ });
154
+ if (existingPr) {
155
+ result.openPr = {
156
+ id: existingPr.id,
157
+ title: existingPr.title,
158
+ url: existingPr.links?.self?.[0]?.href || `${protocol}://${host}/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${existingPr.id}`
159
+ };
160
+ }
161
+ }
162
+ } catch {}
163
+ }
164
+ } catch (err) {
165
+ result.error = err.message || 'Network error checking branch status.';
166
+ }
167
+
168
+ return result;
169
+ }
170
+
171
+ // App CRUD Routes
172
+ router.get('/apps', (_req, res) => {
173
+ res.json(listBranchSyncApps());
174
+ });
175
+
176
+ // Fetch Remote Branches List for a Repository from Bitbucket
177
+ router.get('/branches', async (req, res) => {
178
+ const repoStr = req.query.repo;
179
+ if (!repoStr) {
180
+ return res.status(400).json({ error: 'Repository URL parameter "repo" is required.', branches: [] });
181
+ }
182
+
183
+ const parsed = parseRepo(String(repoStr));
184
+ if (!parsed) {
185
+ return res.status(400).json({ error: `Invalid repository URL format: ${repoStr}`, branches: [] });
186
+ }
187
+
188
+ const settings = readSettings();
189
+ const token = settings.bitbucketAccessToken;
190
+ const headers = { Accept: 'application/json' };
191
+ if (token) headers['Authorization'] = `Bearer ${token}`;
192
+
193
+ const { protocol = 'https', host, projectKey, repositorySlug } = parsed;
194
+ if (!host) {
195
+ return res.status(400).json({ error: 'Could not determine Bitbucket host for repository.', branches: [] });
196
+ }
197
+
198
+ try {
199
+ const branchesUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/branches?limit=200`;
200
+ const response = await httpClient.get(branchesUrl, { headers });
201
+
202
+ if (response.status >= 200 && response.status < 300) {
203
+ const values = response.data?.values || [];
204
+ const branchNames = values
205
+ .map((b) => b.displayId || b.id?.replace(/^refs\/heads\//, ''))
206
+ .filter(Boolean);
207
+ return res.json({ branches: branchNames });
208
+ }
209
+
210
+ const errDetail = response.data?.errors?.[0]?.message || response.data?.message || response.statusText;
211
+ return res.status(response.status).json({ error: `Bitbucket error: ${errDetail}`, branches: [] });
212
+ } catch (err) {
213
+ return res.status(500).json({ error: err.message || 'Failed to fetch remote branches.', branches: [] });
214
+ }
215
+ });
216
+
217
+ router.post('/apps', (req, res) => {
218
+ const { name, repo, activeBranch, lastReleaseBranch, subApps } = req.body || {};
219
+ if (!name || !repo) {
220
+ return res.status(400).json({ error: 'App name and repository URL are required.' });
221
+ }
222
+ const app = createBranchSyncApp({ name, repo, activeBranch, lastReleaseBranch, subApps });
223
+ res.status(201).json(app);
224
+ });
225
+
226
+ router.put('/apps/:id', (req, res) => {
227
+ const updated = updateBranchSyncApp(req.params.id, req.body || {});
228
+ if (!updated) {
229
+ return res.status(404).json({ error: 'App not found.' });
230
+ }
231
+ res.json(updated);
232
+ });
233
+
234
+ router.delete('/apps/:id', (req, res) => {
235
+ removeBranchSyncApp(req.params.id);
236
+ res.status(204).end();
237
+ });
238
+
239
+ // Check Branch Sync Status for an App (and all its nested subApps)
240
+ router.post('/check', async (req, res) => {
241
+ const { app } = req.body || {};
242
+ if (!app || !app.repo) {
243
+ return res.status(400).json({ error: 'Valid App configuration is required.' });
244
+ }
245
+
246
+ const settings = readSettings();
247
+ const token = settings.bitbucketAccessToken;
248
+ const headers = { Accept: 'application/json' };
249
+ if (token) headers['Authorization'] = `Bearer ${token}`;
250
+
251
+ const activeBranch = app.activeBranch || 'active';
252
+ const lastReleaseBranch = app.lastReleaseBranch || 'release';
253
+ const targetMasterBranch = 'master';
254
+
255
+ // 1. Check parent app
256
+ const parentParsed = parseRepo(app.repo);
257
+ let parentCheck = null;
258
+ if (!parentParsed) {
259
+ parentCheck = {
260
+ error: `Invalid repository URL format: ${app.repo}`
261
+ };
262
+ } else {
263
+ const releaseToMaster = await checkBranchPair(parentParsed, lastReleaseBranch, targetMasterBranch, headers);
264
+ const masterToActive = await checkBranchPair(parentParsed, targetMasterBranch, activeBranch, headers);
265
+ parentCheck = {
266
+ releaseToMaster,
267
+ masterToActive
268
+ };
269
+ }
270
+
271
+ // 2. Check sub-apps (using sub.branch if configured, otherwise parent's activeBranch)
272
+ const subAppResults = [];
273
+ for (const sub of app.subApps || []) {
274
+ const subParsed = parseRepo(sub.repo);
275
+ const subActiveBranch = (sub.branch && typeof sub.branch === 'string' && sub.branch.trim()) ? sub.branch.trim() : activeBranch;
276
+
277
+ if (!subParsed) {
278
+ subAppResults.push({
279
+ name: sub.name,
280
+ repo: sub.repo,
281
+ activeBranch: subActiveBranch,
282
+ error: `Invalid sub-app repository URL format: ${sub.repo}`
283
+ });
284
+ } else {
285
+ const releaseToMaster = await checkBranchPair(subParsed, lastReleaseBranch, targetMasterBranch, headers);
286
+ const masterToActive = await checkBranchPair(subParsed, targetMasterBranch, subActiveBranch, headers);
287
+ subAppResults.push({
288
+ name: sub.name,
289
+ repo: sub.repo,
290
+ activeBranch: subActiveBranch,
291
+ releaseToMaster,
292
+ masterToActive
293
+ });
294
+ }
295
+ }
296
+
297
+ res.json({
298
+ appId: app.id,
299
+ appName: app.name,
300
+ parentCheck,
301
+ subAppResults,
302
+ checkedAt: new Date().toISOString()
303
+ });
304
+ });
305
+
306
+ // Create Pull Request
307
+ router.post('/create-pr', async (req, res) => {
308
+ const { repo, sourceBranch, targetBranch, title } = req.body || {};
309
+ if (!repo || !sourceBranch || !targetBranch) {
310
+ return res.status(400).json({ error: 'Repository, sourceBranch, and targetBranch are required.' });
311
+ }
312
+
313
+ const parsed = parseRepo(repo);
314
+ if (!parsed) {
315
+ return res.status(400).json({ error: `Invalid repository URL format: ${repo}` });
316
+ }
317
+
318
+ const settings = readSettings();
319
+ const token = settings.bitbucketAccessToken;
320
+ if (!token) {
321
+ return res.status(400).json({ error: 'Bitbucket Access Token is not configured. Please set your token in Settings.' });
322
+ }
323
+
324
+ const { protocol = 'https', host, projectKey, repositorySlug } = parsed;
325
+ const headers = {
326
+ Accept: 'application/json',
327
+ 'Content-Type': 'application/json',
328
+ Authorization: `Bearer ${token}`
329
+ };
330
+
331
+ const prPayload = {
332
+ title: title || `Merge ${sourceBranch} to ${targetBranch}`,
333
+ description: `Automated Pull Request created via DevBuddy Branch Sync Status Check.\n\nMerging \`${sourceBranch}\` into \`${targetBranch}\`.`,
334
+ fromRef: {
335
+ id: `refs/heads/${sourceBranch}`,
336
+ repository: {
337
+ slug: repositorySlug,
338
+ project: { key: projectKey }
339
+ }
340
+ },
341
+ toRef: {
342
+ id: `refs/heads/${targetBranch}`,
343
+ repository: {
344
+ slug: repositorySlug,
345
+ project: { key: projectKey }
346
+ }
347
+ }
348
+ };
349
+
350
+ try {
351
+ const prUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/pull-requests`;
352
+ const prRes = await httpClient.post(prUrl, prPayload, { headers });
353
+
354
+ if (prRes.status >= 200 && prRes.status < 300) {
355
+ const data = prRes.data;
356
+ const webUrl = data?.links?.self?.[0]?.href || `${protocol}://${host}/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${data?.id}`;
357
+ return res.json({
358
+ success: true,
359
+ prId: data?.id,
360
+ prUrl: webUrl,
361
+ title: data?.title
362
+ });
363
+ }
364
+
365
+ // Handle error response from Bitbucket
366
+ const errMessage =
367
+ prRes.data?.errors?.[0]?.message ||
368
+ prRes.data?.message ||
369
+ `Bitbucket returned status ${prRes.status}: ${prRes.statusText || 'Failed to create PR'}`;
370
+
371
+ return res.status(400).json({ error: errMessage });
372
+ } catch (err) {
373
+ return res.status(500).json({ error: err.message || 'Failed to create pull request.' });
374
+ }
375
+ });
376
+
377
+ // Helper to create a single branch on Bitbucket
378
+ async function createBranchForRepo(repoStr, sourceBranch, targetBranch, headers) {
379
+ const parsed = parseRepo(repoStr);
380
+ if (!parsed) {
381
+ return { success: false, error: `Invalid repository URL format: ${repoStr}` };
382
+ }
383
+ const { protocol = 'https', host, projectKey, repositorySlug } = parsed;
384
+ if (!host) {
385
+ return { success: false, error: 'Could not determine Bitbucket host for repository.' };
386
+ }
387
+
388
+ const startPoint = sourceBranch.startsWith('refs/') ? sourceBranch : `refs/heads/${sourceBranch}`;
389
+ const branchUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/branches`;
390
+ const payload = {
391
+ name: targetBranch,
392
+ startPoint
393
+ };
394
+
395
+ try {
396
+ const res = await httpClient.post(branchUrl, payload, { headers });
397
+ if (res.status >= 200 && res.status < 300) {
398
+ return { success: true, branch: targetBranch, id: res.data?.id };
399
+ }
400
+ const errDetail = res.data?.errors?.[0]?.message || res.data?.message || `Bitbucket returned status ${res.status}: ${res.statusText}`;
401
+ return { success: false, error: errDetail };
402
+ } catch (err) {
403
+ return { success: false, error: err.message || 'Network error creating branch.' };
404
+ }
405
+ }
406
+
407
+ // Create Branch endpoint
408
+ router.post('/create-branch', async (req, res) => {
409
+ const { app, repo, sourceBranch, targetBranch } = req.body || {};
410
+ if (!sourceBranch || !targetBranch) {
411
+ return res.status(400).json({ error: 'Source branch and target branch are required.' });
412
+ }
413
+
414
+ const settings = readSettings();
415
+ const token = settings.bitbucketAccessToken;
416
+ if (!token) {
417
+ return res.status(400).json({ error: 'Bitbucket Access Token is not configured. Please set your token in Settings.' });
418
+ }
419
+
420
+ const headers = {
421
+ Accept: 'application/json',
422
+ 'Content-Type': 'application/json',
423
+ Authorization: `Bearer ${token}`
424
+ };
425
+
426
+ if (app && app.repo) {
427
+ const results = [];
428
+ // 1. Parent App
429
+ const parentRes = await createBranchForRepo(app.repo, sourceBranch, targetBranch, headers);
430
+ results.push({
431
+ name: app.name,
432
+ repo: app.repo,
433
+ isParent: true,
434
+ ...parentRes
435
+ });
436
+
437
+ // 2. Sub Apps
438
+ for (const sub of app.subApps || []) {
439
+ const subSourceBranch = (sourceBranch === app.activeBranch && sub.branch && typeof sub.branch === 'string' && sub.branch.trim())
440
+ ? sub.branch.trim()
441
+ : sourceBranch;
442
+ const subRes = await createBranchForRepo(sub.repo, subSourceBranch, targetBranch, headers);
443
+ results.push({
444
+ name: sub.name,
445
+ repo: sub.repo,
446
+ isParent: false,
447
+ sourceBranch: subSourceBranch,
448
+ ...subRes
449
+ });
450
+ }
451
+
452
+ return res.json({ results });
453
+ } else if (repo) {
454
+ const singleRes = await createBranchForRepo(repo, sourceBranch, targetBranch, headers);
455
+ if (!singleRes.success) {
456
+ return res.status(400).json({ error: singleRes.error });
457
+ }
458
+ return res.json(singleRes);
459
+ }
460
+
461
+ return res.status(400).json({ error: 'Repository or App object is required.' });
462
+ });
463
+
464
+ export default router;
@@ -29,7 +29,7 @@ router.post('/scan', async (req, res, next) => {
29
29
  // POST /api/file-organizer/execute
30
30
  router.post('/execute', async (req, res, next) => {
31
31
  try {
32
- const { sourcePath, targetPath, files, operation = 'move' } = req.body;
32
+ const { sourcePath, targetPath, files, operation = 'move', deleteEmptyFolders = false } = req.body;
33
33
  if (!sourcePath || !targetPath) {
34
34
  return res.status(400).json({ error: '必须同时提供源文件夹和目标文件夹路径' });
35
35
  }
@@ -41,7 +41,8 @@ router.post('/execute', async (req, res, next) => {
41
41
  sourcePath,
42
42
  targetPath,
43
43
  files,
44
- operation
44
+ operation,
45
+ deleteEmptyFolders: Boolean(deleteEmptyFolders)
45
46
  });
46
47
 
47
48
  res.json(session);
@@ -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
  }
@@ -291,11 +291,58 @@ function saveHistory(history) {
291
291
  }
292
292
  }
293
293
 
294
+ async function cleanEmptyDirectories(dirPath, excludeTargetDir, deletedDirs = []) {
295
+ if (!dirPath || !fs.existsSync(dirPath)) return deletedDirs;
296
+
297
+ try {
298
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
299
+
300
+ for (const entry of entries) {
301
+ if (entry.isDirectory()) {
302
+ const fullPath = path.join(dirPath, entry.name);
303
+
304
+ // Do NOT delete or traverse into the target directory!
305
+ if (excludeTargetDir && (fullPath === excludeTargetDir || fullPath.startsWith(excludeTargetDir + path.sep))) {
306
+ continue;
307
+ }
308
+
309
+ // Recursively clean child directories first
310
+ await cleanEmptyDirectories(fullPath, excludeTargetDir, deletedDirs);
311
+
312
+ // Check if current subdirectory is empty
313
+ try {
314
+ const subEntries = await fs.promises.readdir(fullPath);
315
+ const remaining = subEntries.filter(n => !['.DS_Store', '.git', '.localized', 'Thumbs.db'].includes(n));
316
+
317
+ if (remaining.length === 0) {
318
+ // Remove OS metadata files inside if any
319
+ for (const metaFile of subEntries) {
320
+ try {
321
+ await fs.promises.unlink(path.join(fullPath, metaFile));
322
+ } catch {}
323
+ }
324
+
325
+ await fs.promises.rmdir(fullPath);
326
+ deletedDirs.push(fullPath);
327
+ }
328
+ } catch (err) {
329
+ console.error(`Failed to clean empty directory ${fullPath}:`, err);
330
+ }
331
+ }
332
+ }
333
+ } catch (err) {
334
+ console.error(`Failed to read directory ${dirPath} for empty cleanup:`, err);
335
+ }
336
+
337
+ return deletedDirs;
338
+ }
339
+
294
340
  export async function executeOrganize({
295
341
  sourcePath,
296
342
  targetPath,
297
343
  files,
298
- operation = 'move'
344
+ operation = 'move',
345
+ deleteEmptyFolders = false
299
346
  }) {
300
347
  const resolvedSource = expandPath(sourcePath);
301
348
  const resolvedTarget = resolveTargetPath(targetPath, resolvedSource);
@@ -350,6 +397,11 @@ export async function executeOrganize({
350
397
  }
351
398
  }
352
399
 
400
+ let deletedDirs = [];
401
+ if (deleteEmptyFolders) {
402
+ deletedDirs = await cleanEmptyDirectories(resolvedSource, resolvedTarget);
403
+ }
404
+
353
405
  // Save history session
354
406
  const history = loadHistory();
355
407
  const session = {
@@ -358,6 +410,9 @@ export async function executeOrganize({
358
410
  sourcePath: resolvedSource,
359
411
  targetPath: resolvedTarget,
360
412
  operation,
413
+ deleteEmptyFolders: Boolean(deleteEmptyFolders),
414
+ deletedDirsCount: deletedDirs.length,
415
+ deletedDirs,
361
416
  totalFiles: files.length,
362
417
  successCount,
363
418
  failCount,
@@ -414,6 +469,17 @@ export async function undoSession(sessionId) {
414
469
  }
415
470
  }
416
471
 
472
+ // Re-create deleted empty directories if any
473
+ if (Array.isArray(session.deletedDirs)) {
474
+ for (const dir of session.deletedDirs) {
475
+ try {
476
+ await fs.promises.mkdir(dir, { recursive: true });
477
+ } catch (err) {
478
+ console.error(`Failed to recreate directory ${dir}:`, err);
479
+ }
480
+ }
481
+ }
482
+
417
483
  session.status = 'reverted';
418
484
  session.revertedAt = new Date().toISOString();
419
485
  session.revertSuccessCount = revertSuccessCount;