buddy-workbench 0.1.13 → 0.1.14

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.13",
3
+ "version": "0.1.14",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,42 @@
1
+ import { Router } from 'express';
2
+ import {
3
+ getBookmarkSyncStatus,
4
+ previewSync,
5
+ performSync
6
+ } from '../services/bookmark-sync.js';
7
+
8
+ const router = Router();
9
+
10
+ // Get status of Chrome & Edge bookmark files & processes
11
+ router.get('/status', (req, res, next) => {
12
+ try {
13
+ const status = getBookmarkSyncStatus();
14
+ res.json(status);
15
+ } catch (error) {
16
+ next(error);
17
+ }
18
+ });
19
+
20
+ // Preview sync changes
21
+ router.get('/preview', (req, res, next) => {
22
+ try {
23
+ const mode = req.query.mode || 'two-way';
24
+ const preview = previewSync(mode);
25
+ res.json(preview);
26
+ } catch (error) {
27
+ next(error);
28
+ }
29
+ });
30
+
31
+ // Perform actual sync
32
+ router.post('/sync', (req, res, next) => {
33
+ try {
34
+ const mode = req.body?.mode || 'two-way';
35
+ const result = performSync(mode);
36
+ res.json(result);
37
+ } catch (error) {
38
+ next(error);
39
+ }
40
+ });
41
+
42
+ export default router;
@@ -0,0 +1,435 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+ import { execSync } from 'node:child_process';
6
+
7
+ /**
8
+ * Resolves standard bookmark file paths for Chrome and Edge based on OS.
9
+ */
10
+ export function getBrowserBookmarkPaths() {
11
+ const home = os.homedir();
12
+ const platform = os.platform();
13
+
14
+ let chromePath = '';
15
+ let edgePath = '';
16
+
17
+ if (platform === 'darwin') {
18
+ chromePath = path.join(home, 'Library', 'Application Support', 'Google', 'Chrome', 'Default', 'Bookmarks');
19
+ edgePath = path.join(home, 'Library', 'Application Support', 'Microsoft Edge', 'Default', 'Bookmarks');
20
+ } else if (platform === 'win32') {
21
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
22
+ chromePath = path.join(localAppData, 'Google', 'Chrome', 'User Data', 'Default', 'Bookmarks');
23
+ edgePath = path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'Default', 'Bookmarks');
24
+ } else {
25
+ chromePath = path.join(home, '.config', 'google-chrome', 'Default', 'Bookmarks');
26
+ edgePath = path.join(home, '.config', 'microsoft-edge', 'Default', 'Bookmarks');
27
+ }
28
+
29
+ return { chromePath, edgePath };
30
+ }
31
+
32
+ /**
33
+ * Checks if a browser process is currently running.
34
+ */
35
+ export function checkBrowserRunning(browserName) {
36
+ try {
37
+ const processPattern = browserName === 'chrome' ? 'Google Chrome' : 'Microsoft Edge';
38
+ const output = execSync(`pgrep -f "${processPattern}"`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
39
+ return output.trim().length > 0;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Reads and parses a Chromium bookmark file.
47
+ */
48
+ export function readBookmarkFile(filePath) {
49
+ if (!fs.existsSync(filePath)) {
50
+ return null;
51
+ }
52
+ try {
53
+ const content = fs.readFileSync(filePath, 'utf8');
54
+ return JSON.parse(content);
55
+ } catch (error) {
56
+ throw new Error(`Failed to parse bookmark file at ${filePath}: ${error.message}`);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Extracts all URLs from a bookmark tree or roots object into a Map keyed by normalized URL.
62
+ */
63
+ export function getAllUrls(node, urlMap = new Map()) {
64
+ if (!node) return urlMap;
65
+ if (node.type === 'url' && node.url) {
66
+ const normalizedUrl = normalizeUrl(node.url);
67
+ if (!urlMap.has(normalizedUrl)) {
68
+ urlMap.set(normalizedUrl, node);
69
+ }
70
+ }
71
+ if (Array.isArray(node.children)) {
72
+ for (const child of node.children) {
73
+ getAllUrls(child, urlMap);
74
+ }
75
+ } else if (typeof node === 'object') {
76
+ for (const key of Object.keys(node)) {
77
+ if (typeof node[key] === 'object' && node[key] !== null) {
78
+ getAllUrls(node[key], urlMap);
79
+ }
80
+ }
81
+ }
82
+ return urlMap;
83
+ }
84
+
85
+ /**
86
+ * Standardizes URL for comparison (removes trailing slash, case-insensitive protocol/domain).
87
+ */
88
+ function normalizeUrl(rawUrl) {
89
+ try {
90
+ const parsed = new URL(rawUrl);
91
+ parsed.hash = '';
92
+ let href = parsed.href;
93
+ if (href.endsWith('/')) href = href.slice(0, -1);
94
+ return href.toLowerCase();
95
+ } catch {
96
+ return (rawUrl || '').trim().toLowerCase().replace(/\/$/, '');
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Finds maximum numeric ID used in a bookmark tree.
102
+ */
103
+ function getMaxId(node) {
104
+ let max = 0;
105
+ if (!node) return max;
106
+ const numericId = parseInt(node.id, 10);
107
+ if (!isNaN(numericId) && numericId > max) {
108
+ max = numericId;
109
+ }
110
+ if (Array.isArray(node.children)) {
111
+ for (const child of node.children) {
112
+ const childMax = getMaxId(child);
113
+ if (childMax > max) max = childMax;
114
+ }
115
+ } else if (typeof node === 'object') {
116
+ for (const key of Object.keys(node)) {
117
+ if (typeof node[key] === 'object' && node[key] !== null) {
118
+ const keyMax = getMaxId(node[key]);
119
+ if (keyMax > max) max = keyMax;
120
+ }
121
+ }
122
+ }
123
+ return max;
124
+ }
125
+
126
+ /**
127
+ * Collects list of all bookmark items from a node or roots object with their folder hierarchy path.
128
+ */
129
+ export function extractBookmarksList(rootContainer, folderPath = []) {
130
+ let list = [];
131
+ if (!rootContainer) return list;
132
+
133
+ if (Array.isArray(rootContainer.children)) {
134
+ for (const child of rootContainer.children) {
135
+ if (child.type === 'folder') {
136
+ const currentPath = [...folderPath, child.name];
137
+ list = list.concat(extractBookmarksList(child, currentPath));
138
+ } else if (child.type === 'url' && child.url) {
139
+ list.push({
140
+ name: child.name || 'Untitled',
141
+ url: child.url,
142
+ folderPath: [...folderPath]
143
+ });
144
+ }
145
+ }
146
+ } else if (typeof rootContainer === 'object') {
147
+ for (const key of Object.keys(rootContainer)) {
148
+ if (typeof rootContainer[key] === 'object' && rootContainer[key] !== null) {
149
+ const rootFolder = rootContainer[key];
150
+ const initialPath = rootFolder.name ? [...folderPath] : folderPath;
151
+ list = list.concat(extractBookmarksList(rootFolder, initialPath));
152
+ }
153
+ }
154
+ }
155
+
156
+ return list;
157
+ }
158
+
159
+ /**
160
+ * Ensures target folder hierarchy exists in rootNode, returning the destination folder node.
161
+ */
162
+ function ensureFolderPath(rootNode, folderPath, maxIdRef) {
163
+ let current = rootNode;
164
+ if (!current.children) current.children = [];
165
+
166
+ for (const folderName of folderPath) {
167
+ let childFolder = current.children.find(c => c.type === 'folder' && c.name === folderName);
168
+ if (!childFolder) {
169
+ maxIdRef.current += 1;
170
+ childFolder = {
171
+ children: [],
172
+ date_added: String(Date.now() * 1000),
173
+ date_modified: String(Date.now() * 1000),
174
+ guid: crypto.randomUUID(),
175
+ id: String(maxIdRef.current),
176
+ name: folderName,
177
+ type: 'folder'
178
+ };
179
+ current.children.push(childFolder);
180
+ }
181
+ current = childFolder;
182
+ if (!current.children) current.children = [];
183
+ }
184
+ return current;
185
+ }
186
+
187
+ /**
188
+ * Merges source items into target Chromium bookmark JSON structure.
189
+ * Returns { updatedJson, addedItems }
190
+ */
191
+ export function mergeBookmarksIntoTree(targetJson, sourceItems) {
192
+ if (!targetJson || !targetJson.roots) {
193
+ // If target JSON doesn't exist or is invalid, build a fresh Chromium roots structure
194
+ targetJson = {
195
+ checksum: '',
196
+ roots: {
197
+ bookmark_bar: {
198
+ children: [],
199
+ date_added: String(Date.now() * 1000),
200
+ date_modified: String(Date.now() * 1000),
201
+ guid: crypto.randomUUID(),
202
+ id: '1',
203
+ name: '书签栏',
204
+ type: 'folder'
205
+ },
206
+ other: {
207
+ children: [],
208
+ date_added: String(Date.now() * 1000),
209
+ date_modified: String(Date.now() * 1000),
210
+ guid: crypto.randomUUID(),
211
+ id: '2',
212
+ name: '其他书签',
213
+ type: 'folder'
214
+ },
215
+ synced: {
216
+ children: [],
217
+ date_added: String(Date.now() * 1000),
218
+ date_modified: String(Date.now() * 1000),
219
+ guid: crypto.randomUUID(),
220
+ id: '3',
221
+ name: '移动设备书签',
222
+ type: 'folder'
223
+ }
224
+ },
225
+ version: 1
226
+ };
227
+ }
228
+
229
+ if (!targetJson.roots.bookmark_bar) {
230
+ targetJson.roots.bookmark_bar = {
231
+ children: [],
232
+ date_added: String(Date.now() * 1000),
233
+ date_modified: String(Date.now() * 1000),
234
+ guid: crypto.randomUUID(),
235
+ id: '1',
236
+ name: '书签栏',
237
+ type: 'folder'
238
+ };
239
+ }
240
+
241
+ // Get existing URLs across the entire target tree
242
+ const existingUrlMap = getAllUrls(targetJson.roots);
243
+
244
+ const maxIdRef = { current: getMaxId(targetJson.roots) };
245
+ const addedItems = [];
246
+
247
+ for (const item of sourceItems) {
248
+ const normUrl = normalizeUrl(item.url);
249
+ if (existingUrlMap.has(normUrl)) {
250
+ continue; // Skip existing bookmark
251
+ }
252
+
253
+ // Destination folder under bookmark_bar
254
+ const destFolder = ensureFolderPath(targetJson.roots.bookmark_bar, item.folderPath, maxIdRef);
255
+
256
+ maxIdRef.current += 1;
257
+ const newBookmarkNode = {
258
+ date_added: String(Date.now() * 1000),
259
+ guid: crypto.randomUUID(),
260
+ id: String(maxIdRef.current),
261
+ name: item.name,
262
+ type: 'url',
263
+ url: item.url
264
+ };
265
+
266
+ destFolder.children.push(newBookmarkNode);
267
+ existingUrlMap.set(normUrl, newBookmarkNode);
268
+ addedItems.push({
269
+ name: item.name,
270
+ url: item.url,
271
+ folderPath: item.folderPath.join(' / ') || '书签栏'
272
+ });
273
+ }
274
+
275
+ return { updatedJson: targetJson, addedItems };
276
+ }
277
+
278
+ /**
279
+ * Gets overview status of Chrome & Edge bookmarks.
280
+ */
281
+ export function getBookmarkSyncStatus() {
282
+ const { chromePath, edgePath } = getBrowserBookmarkPaths();
283
+
284
+ const chromeExists = fs.existsSync(chromePath);
285
+ const edgeExists = fs.existsSync(edgePath);
286
+
287
+ const chromeRunning = checkBrowserRunning('chrome');
288
+ const edgeRunning = checkBrowserRunning('edge');
289
+
290
+ let chromeCount = 0;
291
+ let edgeCount = 0;
292
+
293
+ if (chromeExists) {
294
+ const chromeJson = readBookmarkFile(chromePath);
295
+ if (chromeJson && chromeJson.roots) {
296
+ const urls = getAllUrls(chromeJson.roots);
297
+ chromeCount = urls.size;
298
+ }
299
+ }
300
+
301
+ if (edgeExists) {
302
+ const edgeJson = readBookmarkFile(edgePath);
303
+ if (edgeJson && edgeJson.roots) {
304
+ const urls = getAllUrls(edgeJson.roots);
305
+ edgeCount = urls.size;
306
+ }
307
+ }
308
+
309
+ return {
310
+ chrome: {
311
+ path: chromePath,
312
+ exists: chromeExists,
313
+ running: chromeRunning,
314
+ count: chromeCount
315
+ },
316
+ edge: {
317
+ path: edgePath,
318
+ exists: edgeExists,
319
+ running: edgeRunning,
320
+ count: edgeCount
321
+ }
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Previews what items would be added in sync.
327
+ */
328
+ export function previewSync(mode = 'two-way') {
329
+ const { chromePath, edgePath } = getBrowserBookmarkPaths();
330
+ const chromeJson = readBookmarkFile(chromePath);
331
+ const edgeJson = readBookmarkFile(edgePath);
332
+
333
+ if (!chromeJson && !edgeJson) {
334
+ throw new Error('Neither Chrome nor Edge bookmark files were found.');
335
+ }
336
+
337
+ const chromeItems = chromeJson && chromeJson.roots ? extractBookmarksList(chromeJson.roots) : [];
338
+ const edgeItems = edgeJson && edgeJson.roots ? extractBookmarksList(edgeJson.roots) : [];
339
+
340
+ let toAddEdge = [];
341
+ let toAddChrome = [];
342
+
343
+ if (mode === 'two-way' || mode === 'chrome-to-edge') {
344
+ const baseEdgeJson = edgeJson || null;
345
+ const { addedItems } = mergeBookmarksIntoTree(baseEdgeJson ? structuredClone(baseEdgeJson) : null, chromeItems);
346
+ toAddEdge = addedItems;
347
+ }
348
+
349
+ if (mode === 'two-way' || mode === 'edge-to-chrome') {
350
+ const baseChromeJson = chromeJson || null;
351
+ const { addedItems } = mergeBookmarksIntoTree(baseChromeJson ? structuredClone(baseChromeJson) : null, edgeItems);
352
+ toAddChrome = addedItems;
353
+ }
354
+
355
+ return {
356
+ mode,
357
+ toAddEdge,
358
+ toAddChrome
359
+ };
360
+ }
361
+
362
+ /**
363
+ * Performs actual sync and creates backup files.
364
+ */
365
+ export function performSync(mode = 'two-way') {
366
+ const { chromePath, edgePath } = getBrowserBookmarkPaths();
367
+ const chromeJson = readBookmarkFile(chromePath);
368
+ const edgeJson = readBookmarkFile(edgePath);
369
+
370
+ if (!chromeJson && !edgeJson) {
371
+ throw new Error('Neither Chrome nor Edge bookmark files were found.');
372
+ }
373
+
374
+ const chromeItems = chromeJson && chromeJson.roots ? extractBookmarksList(chromeJson.roots) : [];
375
+ const edgeItems = edgeJson && edgeJson.roots ? extractBookmarksList(edgeJson.roots) : [];
376
+
377
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
378
+ const backupsCreated = [];
379
+
380
+ let addedToEdge = [];
381
+ let addedToChrome = [];
382
+
383
+ // Sync Chrome -> Edge
384
+ if (mode === 'two-way' || mode === 'chrome-to-edge') {
385
+ const { updatedJson, addedItems } = mergeBookmarksIntoTree(edgeJson ? structuredClone(edgeJson) : null, chromeItems);
386
+ addedToEdge = addedItems;
387
+
388
+ if (addedItems.length > 0) {
389
+ if (fs.existsSync(edgePath)) {
390
+ const edgeBak = `${edgePath}.bak_${timestamp}`;
391
+ fs.copyFileSync(edgePath, edgeBak);
392
+ backupsCreated.push(edgeBak);
393
+ } else {
394
+ // Ensure directory exists if creating new edge file
395
+ const edgeDir = path.dirname(edgePath);
396
+ if (!fs.existsSync(edgeDir)) {
397
+ fs.mkdirSync(edgeDir, { recursive: true });
398
+ }
399
+ }
400
+
401
+ fs.writeFileSync(edgePath, JSON.stringify(updatedJson, null, 2), 'utf8');
402
+ }
403
+ }
404
+
405
+ // Sync Edge -> Chrome
406
+ if (mode === 'two-way' || mode === 'edge-to-chrome') {
407
+ const { updatedJson, addedItems } = mergeBookmarksIntoTree(chromeJson ? structuredClone(chromeJson) : null, edgeItems);
408
+ addedToChrome = addedItems;
409
+
410
+ if (addedItems.length > 0) {
411
+ if (fs.existsSync(chromePath)) {
412
+ const chromeBak = `${chromePath}.bak_${timestamp}`;
413
+ fs.copyFileSync(chromePath, chromeBak);
414
+ backupsCreated.push(chromeBak);
415
+ } else {
416
+ const chromeDir = path.dirname(chromePath);
417
+ if (!fs.existsSync(chromeDir)) {
418
+ fs.mkdirSync(chromeDir, { recursive: true });
419
+ }
420
+ }
421
+
422
+ fs.writeFileSync(chromePath, JSON.stringify(updatedJson, null, 2), 'utf8');
423
+ }
424
+ }
425
+
426
+ return {
427
+ success: true,
428
+ timestamp,
429
+ backupsCreated,
430
+ addedToEdgeCount: addedToEdge.length,
431
+ addedToChromeCount: addedToChrome.length,
432
+ addedToEdge,
433
+ addedToChrome
434
+ };
435
+ }
package/server.js CHANGED
@@ -18,6 +18,7 @@ import todoRoutes from './server/routes/todos.js';
18
18
  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
+ import bookmarkSyncRoutes from './server/routes/bookmark-sync.js';
21
22
  import { addErrorRecord } from './server/repositories/errors.js';
22
23
  import { startClipboardCapture } from './server/services/clipboard-history.js';
23
24
 
@@ -71,6 +72,7 @@ app.use('/api/todos', todoRoutes);
71
72
  app.use('/api/static-pages', staticPagesRoutes);
72
73
  app.use('/api/errors', errorRoutes);
73
74
  app.use('/api/postman', postmanRoutes);
75
+ app.use('/api/bookmark-sync', bookmarkSyncRoutes);
74
76
 
75
77
  app.use((err, req, res, _next) => {
76
78
  addErrorRecord({