zotero-bridge 1.0.3 → 1.1.1

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/src/database.ts CHANGED
@@ -10,9 +10,10 @@
10
10
  */
11
11
 
12
12
  import initSqlJs, { Database as SqlJsDatabase } from 'sql.js';
13
- import { existsSync, readFileSync, writeFileSync } from 'fs';
13
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, statSync } from 'fs';
14
14
  import { homedir } from 'os';
15
- import { join } from 'path';
15
+ import { join, dirname } from 'path';
16
+ import { execSync } from 'child_process';
16
17
 
17
18
  export interface ZoteroItem {
18
19
  itemID: number;
@@ -48,6 +49,8 @@ export class ZoteroDatabase {
48
49
  private dbPath: string;
49
50
  private readonly: boolean;
50
51
  private SQL: any = null;
52
+ private backupPath: string | null = null;
53
+ private hasUnsavedChanges: boolean = false;
51
54
 
52
55
  constructor(dbPath?: string, readonly: boolean = false) {
53
56
  this.dbPath = dbPath || this.findDefaultZoteroDB();
@@ -94,6 +97,58 @@ export class ZoteroDatabase {
94
97
  return join(home, 'Zotero', 'zotero.sqlite');
95
98
  }
96
99
 
100
+ /**
101
+ * Check if Zotero is currently running
102
+ */
103
+ private isZoteroRunning(): boolean {
104
+ try {
105
+ if (process.platform === 'win32') {
106
+ const result = execSync('tasklist /FI "IMAGENAME eq zotero.exe" /NH', { encoding: 'utf8' });
107
+ return result.toLowerCase().includes('zotero.exe');
108
+ } else if (process.platform === 'darwin') {
109
+ const result = execSync('pgrep -x Zotero', { encoding: 'utf8' });
110
+ return result.trim().length > 0;
111
+ } else {
112
+ const result = execSync('pgrep -x zotero', { encoding: 'utf8' });
113
+ return result.trim().length > 0;
114
+ }
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Check if WAL files exist (indicates active Zotero session)
122
+ */
123
+ private hasWALFiles(): boolean {
124
+ const walPath = this.dbPath + '-wal';
125
+ const shmPath = this.dbPath + '-shm';
126
+ return existsSync(walPath) || existsSync(shmPath);
127
+ }
128
+
129
+ /**
130
+ * Create a backup of the database before modification
131
+ */
132
+ private createBackup(): string {
133
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
134
+ const backupPath = this.dbPath.replace('.sqlite', `.backup-${timestamp}.sqlite`);
135
+ copyFileSync(this.dbPath, backupPath);
136
+ return backupPath;
137
+ }
138
+
139
+ /**
140
+ * Verify database integrity
141
+ */
142
+ private verifyIntegrity(): boolean {
143
+ if (!this.db) return false;
144
+ try {
145
+ const result = this.queryOne('PRAGMA integrity_check');
146
+ return result && result.integrity_check === 'ok';
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
97
152
  /**
98
153
  * Connect to the Zotero database
99
154
  */
@@ -106,6 +161,18 @@ export class ZoteroDatabase {
106
161
  throw new Error(`Zotero database not found at: ${this.dbPath}`);
107
162
  }
108
163
 
164
+ // WARNING: Check if Zotero is running (write operations are dangerous)
165
+ if (!this.readonly && this.isZoteroRunning()) {
166
+ console.warn('⚠️ WARNING: Zotero is currently running. Write operations may corrupt the database!');
167
+ console.warn(' Please close Zotero before making changes, or use readonly mode.');
168
+ }
169
+
170
+ // WARNING: Check for WAL files
171
+ if (!this.readonly && this.hasWALFiles()) {
172
+ console.warn('⚠️ WARNING: WAL files detected. Zotero may be running or was not closed properly.');
173
+ console.warn(' Writing to the database may cause corruption!');
174
+ }
175
+
109
176
  // Initialize sql.js
110
177
  this.SQL = await initSqlJs();
111
178
 
@@ -116,19 +183,57 @@ export class ZoteroDatabase {
116
183
 
117
184
  // Enable foreign keys
118
185
  db.run('PRAGMA foreign_keys = ON');
186
+
187
+ // Verify database integrity on connect
188
+ if (!this.verifyIntegrity()) {
189
+ console.error('❌ ERROR: Database integrity check failed! The database may already be corrupted.');
190
+ }
119
191
  }
120
192
 
121
193
  /**
122
194
  * Save changes to the database file
195
+ * WARNING: This can corrupt the database if Zotero is running!
123
196
  */
124
197
  save(): void {
125
- if (!this.db || this.readonly) {
198
+ if (!this.db || this.readonly || !this.hasUnsavedChanges) {
126
199
  return;
127
200
  }
201
+
202
+ // Safety check before save
203
+ if (this.isZoteroRunning()) {
204
+ throw new Error('Cannot save: Zotero is currently running. Please close Zotero first to avoid database corruption.');
205
+ }
206
+
207
+ if (this.hasWALFiles()) {
208
+ throw new Error('Cannot save: WAL files detected. Please close Zotero and wait for WAL files to be cleaned up.');
209
+ }
210
+
211
+ // Create backup before saving
212
+ if (!this.backupPath) {
213
+ this.backupPath = this.createBackup();
214
+ console.log(`📁 Backup created at: ${this.backupPath}`);
215
+ }
216
+
217
+ // Verify integrity before save (only check a few important tables to speed up)
218
+ try {
219
+ this.queryOne("SELECT 1 FROM items LIMIT 1");
220
+ this.queryOne("SELECT 1 FROM itemData LIMIT 1");
221
+ } catch (e) {
222
+ throw new Error('Cannot save: Basic database check failed. Database may be corrupted.');
223
+ }
128
224
 
129
225
  const data = this.db.export();
130
226
  const buffer = Buffer.from(data);
131
227
  writeFileSync(this.dbPath, buffer);
228
+ this.hasUnsavedChanges = false;
229
+ console.log('✅ Database saved successfully.');
230
+ }
231
+
232
+ /**
233
+ * Mark that changes have been made (for tracking unsaved changes)
234
+ */
235
+ private markDirty(): void {
236
+ this.hasUnsavedChanges = true;
132
237
  }
133
238
 
134
239
  /**
@@ -136,8 +241,12 @@ export class ZoteroDatabase {
136
241
  */
137
242
  disconnect(): void {
138
243
  if (this.db) {
139
- if (!this.readonly) {
140
- this.save();
244
+ if (!this.readonly && this.hasUnsavedChanges) {
245
+ try {
246
+ this.save();
247
+ } catch (e) {
248
+ console.error('Failed to save on disconnect:', e);
249
+ }
141
250
  }
142
251
  this.db.close();
143
252
  this.db = null;
@@ -161,6 +270,34 @@ export class ZoteroDatabase {
161
270
  return this.dbPath;
162
271
  }
163
272
 
273
+ /**
274
+ * Get current timestamp in Zotero SQL format
275
+ */
276
+ private getCurrentTimestamp(): string {
277
+ return new Date().toISOString().replace('T', ' ').replace('Z', '').slice(0, -4);
278
+ }
279
+
280
+ /**
281
+ * Update item metadata after modification (dateModified, version, synced)
282
+ * This is CRITICAL for Zotero compatibility!
283
+ *
284
+ * According to Zotero's official code:
285
+ * - dateModified must be updated on every change
286
+ * - version must be incremented
287
+ * - synced must be set to 0 (false) to indicate local change
288
+ */
289
+ private updateItemMetadata(itemID: number): void {
290
+ const timestamp = this.getCurrentTimestamp();
291
+ this.db!.run(`
292
+ UPDATE items
293
+ SET dateModified = ?,
294
+ clientDateModified = ?,
295
+ version = version + 1,
296
+ synced = 0
297
+ WHERE itemID = ?
298
+ `, [timestamp, timestamp, itemID]);
299
+ }
300
+
164
301
  /**
165
302
  * Execute a query and return all results
166
303
  */
@@ -198,7 +335,13 @@ export class ZoteroDatabase {
198
335
  throw new Error('Database not connected');
199
336
  }
200
337
 
338
+ // Safety check before modifying database
339
+ if (this.isZoteroRunning()) {
340
+ throw new Error('Cannot modify database: Zotero is currently running. Please close Zotero first.');
341
+ }
342
+
201
343
  this.db.run(sql, params);
344
+ this.markDirty(); // Mark that we have unsaved changes
202
345
 
203
346
  const changes = this.db.getRowsModified();
204
347
  const lastId = this.queryOne('SELECT last_insert_rowid() as id');
@@ -373,6 +516,8 @@ export class ZoteroDatabase {
373
516
 
374
517
  /**
375
518
  * Add tag to item
519
+ *
520
+ * Following Zotero's pattern: modifying item tags should update item metadata
376
521
  */
377
522
  addTagToItem(itemID: number, tagName: string, type: number = 0): boolean {
378
523
  // Get or create tag
@@ -389,6 +534,9 @@ export class ZoteroDatabase {
389
534
 
390
535
  this.execute('INSERT INTO itemTags (itemID, tagID) VALUES (?, ?)', [itemID, tagID]);
391
536
 
537
+ // CRITICAL: Update item metadata for Zotero compatibility
538
+ this.updateItemMetadata(itemID);
539
+
392
540
  if (!this.readonly) {
393
541
  this.save();
394
542
  }
@@ -407,6 +555,11 @@ export class ZoteroDatabase {
407
555
 
408
556
  const result = this.execute('DELETE FROM itemTags WHERE itemID = ? AND tagID = ?', [itemID, tag.tagID]);
409
557
 
558
+ if (result.changes > 0) {
559
+ // CRITICAL: Update item metadata for Zotero compatibility
560
+ this.updateItemMetadata(itemID);
561
+ }
562
+
410
563
  if (!this.readonly && result.changes > 0) {
411
564
  this.save();
412
565
  }
@@ -446,6 +599,8 @@ export class ZoteroDatabase {
446
599
 
447
600
  /**
448
601
  * Add item to collection
602
+ *
603
+ * Following Zotero's pattern: collection membership changes update item metadata
449
604
  */
450
605
  addItemToCollection(itemID: number, collectionID: number): boolean {
451
606
  // Check if already in collection
@@ -459,6 +614,9 @@ export class ZoteroDatabase {
459
614
 
460
615
  this.execute('INSERT INTO collectionItems (itemID, collectionID) VALUES (?, ?)', [itemID, collectionID]);
461
616
 
617
+ // CRITICAL: Update item metadata for Zotero compatibility
618
+ this.updateItemMetadata(itemID);
619
+
462
620
  if (!this.readonly) {
463
621
  this.save();
464
622
  }
@@ -468,10 +626,17 @@ export class ZoteroDatabase {
468
626
 
469
627
  /**
470
628
  * Remove item from collection
629
+ *
630
+ * Following Zotero's pattern: collection membership changes update item metadata
471
631
  */
472
632
  removeItemFromCollection(itemID: number, collectionID: number): boolean {
473
633
  const result = this.execute('DELETE FROM collectionItems WHERE itemID = ? AND collectionID = ?', [itemID, collectionID]);
474
634
 
635
+ if (result.changes > 0) {
636
+ // CRITICAL: Update item metadata for Zotero compatibility
637
+ this.updateItemMetadata(itemID);
638
+ }
639
+
475
640
  if (!this.readonly && result.changes > 0) {
476
641
  this.save();
477
642
  }
@@ -587,6 +752,11 @@ export class ZoteroDatabase {
587
752
 
588
753
  /**
589
754
  * Set item abstract
755
+ *
756
+ * Following Zotero's official pattern for modifying item data:
757
+ * 1. Get or create value in itemDataValues
758
+ * 2. Insert or update itemData
759
+ * 3. Update item metadata (dateModified, version, synced)
590
760
  */
591
761
  setItemAbstract(itemID: number, abstract: string): boolean {
592
762
  // Get abstractNote field ID
@@ -595,7 +765,7 @@ export class ZoteroDatabase {
595
765
  return false;
596
766
  }
597
767
 
598
- // Get or create value
768
+ // Get or create value (Zotero stores unique values in itemDataValues)
599
769
  let valueRow = this.queryOne('SELECT valueID FROM itemDataValues WHERE value = ?', [abstract]);
600
770
 
601
771
  if (!valueRow) {
@@ -618,13 +788,15 @@ export class ZoteroDatabase {
618
788
  [itemID, field.fieldID, valueRow.valueID]);
619
789
  }
620
790
 
791
+ // CRITICAL: Update item metadata for Zotero compatibility
792
+ this.updateItemMetadata(itemID);
793
+
621
794
  if (!this.readonly) {
622
795
  this.save();
623
796
  }
624
797
 
625
798
  return true;
626
799
  }
627
-
628
800
  /**
629
801
  * Get item notes
630
802
  */
@@ -638,6 +810,11 @@ export class ZoteroDatabase {
638
810
 
639
811
  /**
640
812
  * Add note to item
813
+ *
814
+ * Following Zotero's official pattern for creating notes:
815
+ * 1. Create item with note type
816
+ * 2. Create itemNotes entry
817
+ * 3. Update parent item's metadata (as adding a child note is a modification)
641
818
  */
642
819
  addItemNote(parentItemID: number, noteContent: string, title: string = ''): number {
643
820
  // Get parent item's library ID
@@ -649,22 +826,32 @@ export class ZoteroDatabase {
649
826
  // Get note item type ID
650
827
  const noteType = this.queryOne("SELECT itemTypeID FROM itemTypes WHERE typeName = 'note'");
651
828
 
652
- // Create item entry
829
+ // Create item entry with proper timestamp format
653
830
  const key = this.generateKey();
654
- const now = new Date().toISOString().replace('T', ' ').replace('Z', '');
831
+ const now = this.getCurrentTimestamp();
655
832
 
833
+ // Note: synced=0 for new local items, clientDateModified is also set
656
834
  const itemResult = this.execute(`
657
- INSERT INTO items (itemTypeID, dateAdded, dateModified, key, libraryID, version)
658
- VALUES (?, ?, ?, ?, ?, 0)
659
- `, [noteType.itemTypeID, now, now, key, parent.libraryID]);
835
+ INSERT INTO items (itemTypeID, dateAdded, dateModified, clientDateModified, key, libraryID, version, synced)
836
+ VALUES (?, ?, ?, ?, ?, ?, 0, 0)
837
+ `, [noteType.itemTypeID, now, now, now, key, parent.libraryID]);
660
838
 
661
839
  const itemID = itemResult.lastInsertRowid;
662
840
 
841
+ // Wrap note content in Zotero's expected format if not already
842
+ let formattedNote = noteContent;
843
+ if (!noteContent.startsWith('<div class="zotero-note')) {
844
+ formattedNote = `<div class="zotero-note znv1">${noteContent}</div>`;
845
+ }
846
+
663
847
  // Create note entry
664
848
  this.execute(`
665
849
  INSERT INTO itemNotes (itemID, parentItemID, note, title)
666
850
  VALUES (?, ?, ?, ?)
667
- `, [itemID, parentItemID, noteContent, title]);
851
+ `, [itemID, parentItemID, formattedNote, title]);
852
+
853
+ // CRITICAL: Update parent item metadata (adding a note is considered a modification)
854
+ this.updateItemMetadata(parentItemID);
668
855
 
669
856
  if (!this.readonly) {
670
857
  this.save();
@@ -1222,4 +1409,339 @@ export class ZoteroDatabase {
1222
1409
  run(sql: string, params: any[] = []): { changes: number; lastInsertRowid: number } {
1223
1410
  return this.execute(sql, params);
1224
1411
  }
1412
+
1413
+ // ============================================
1414
+ // Duplicate Detection & Attachment Validation
1415
+ // ============================================
1416
+
1417
+ /**
1418
+ * Find duplicate items based on title, DOI, or ISBN
1419
+ */
1420
+ findDuplicates(field: 'title' | 'doi' | 'isbn' = 'title', libraryID: number = 1): any[] {
1421
+ if (field === 'title') {
1422
+ // Find items with the same title
1423
+ return this.queryAll(`
1424
+ SELECT
1425
+ iv.value as title,
1426
+ GROUP_CONCAT(i.itemID) as itemIDs,
1427
+ COUNT(*) as count
1428
+ FROM items i
1429
+ JOIN itemData id ON i.itemID = id.itemID
1430
+ JOIN itemDataValues iv ON id.valueID = iv.valueID
1431
+ JOIN fields f ON id.fieldID = f.fieldID
1432
+ WHERE f.fieldName = 'title'
1433
+ AND i.libraryID = ?
1434
+ AND i.itemTypeID NOT IN (1, 14) -- Exclude notes and attachments
1435
+ GROUP BY iv.value
1436
+ HAVING COUNT(*) > 1
1437
+ ORDER BY count DESC
1438
+ `, [libraryID]);
1439
+ } else if (field === 'doi') {
1440
+ return this.queryAll(`
1441
+ SELECT
1442
+ iv.value as doi,
1443
+ GROUP_CONCAT(i.itemID) as itemIDs,
1444
+ COUNT(*) as count
1445
+ FROM items i
1446
+ JOIN itemData id ON i.itemID = id.itemID
1447
+ JOIN itemDataValues iv ON id.valueID = iv.valueID
1448
+ JOIN fields f ON id.fieldID = f.fieldID
1449
+ WHERE f.fieldName = 'DOI'
1450
+ AND i.libraryID = ?
1451
+ AND iv.value != ''
1452
+ GROUP BY LOWER(iv.value)
1453
+ HAVING COUNT(*) > 1
1454
+ ORDER BY count DESC
1455
+ `, [libraryID]);
1456
+ } else {
1457
+ return this.queryAll(`
1458
+ SELECT
1459
+ iv.value as isbn,
1460
+ GROUP_CONCAT(i.itemID) as itemIDs,
1461
+ COUNT(*) as count
1462
+ FROM items i
1463
+ JOIN itemData id ON i.itemID = id.itemID
1464
+ JOIN itemDataValues iv ON id.valueID = iv.valueID
1465
+ JOIN fields f ON id.fieldID = f.fieldID
1466
+ WHERE f.fieldName = 'ISBN'
1467
+ AND i.libraryID = ?
1468
+ AND iv.value != ''
1469
+ GROUP BY REPLACE(REPLACE(iv.value, '-', ''), ' ', '')
1470
+ HAVING COUNT(*) > 1
1471
+ ORDER BY count DESC
1472
+ `, [libraryID]);
1473
+ }
1474
+ }
1475
+
1476
+ /**
1477
+ * Validate attachment files exist on disk
1478
+ */
1479
+ validateAttachments(itemID?: number, checkAll: boolean = false): {
1480
+ valid: any[];
1481
+ missing: any[];
1482
+ total: number;
1483
+ } {
1484
+ let attachments: any[];
1485
+
1486
+ if (itemID) {
1487
+ attachments = this.queryAll(`
1488
+ SELECT ia.itemID, ia.parentItemID, ia.path, ia.contentType, i.key
1489
+ FROM itemAttachments ia
1490
+ JOIN items i ON ia.itemID = i.itemID
1491
+ WHERE ia.parentItemID = ? AND ia.path IS NOT NULL
1492
+ `, [itemID]);
1493
+ } else if (checkAll) {
1494
+ attachments = this.queryAll(`
1495
+ SELECT ia.itemID, ia.parentItemID, ia.path, ia.contentType, i.key
1496
+ FROM itemAttachments ia
1497
+ JOIN items i ON ia.itemID = i.itemID
1498
+ WHERE ia.path IS NOT NULL
1499
+ LIMIT 1000
1500
+ `);
1501
+ } else {
1502
+ return { valid: [], missing: [], total: 0 };
1503
+ }
1504
+
1505
+ const valid: any[] = [];
1506
+ const missing: any[] = [];
1507
+
1508
+ for (const att of attachments) {
1509
+ const fullPath = this.getAttachmentPath(att.itemID);
1510
+ if (fullPath && existsSync(fullPath)) {
1511
+ valid.push({
1512
+ ...att,
1513
+ fullPath,
1514
+ exists: true
1515
+ });
1516
+ } else {
1517
+ missing.push({
1518
+ ...att,
1519
+ fullPath,
1520
+ exists: false
1521
+ });
1522
+ }
1523
+ }
1524
+
1525
+ return {
1526
+ valid,
1527
+ missing,
1528
+ total: attachments.length
1529
+ };
1530
+ }
1531
+
1532
+ /**
1533
+ * Get a valid (existing) attachment for an item
1534
+ * Useful when multiple attachment records exist but only one file is present
1535
+ */
1536
+ getValidAttachment(parentItemID: number, contentType: string = 'application/pdf'): any | null {
1537
+ const attachments = this.queryAll(`
1538
+ SELECT ia.itemID, ia.path, ia.contentType, i.key
1539
+ FROM itemAttachments ia
1540
+ JOIN items i ON ia.itemID = i.itemID
1541
+ WHERE ia.parentItemID = ?
1542
+ AND ia.contentType = ?
1543
+ AND ia.path IS NOT NULL
1544
+ `, [parentItemID, contentType]);
1545
+
1546
+ // Return the first attachment that actually exists
1547
+ for (const att of attachments) {
1548
+ const fullPath = this.getAttachmentPath(att.itemID);
1549
+ if (fullPath && existsSync(fullPath)) {
1550
+ return {
1551
+ ...att,
1552
+ fullPath,
1553
+ exists: true
1554
+ };
1555
+ }
1556
+ }
1557
+
1558
+ return null;
1559
+ }
1560
+
1561
+ /**
1562
+ * Find items with valid (existing) PDF files
1563
+ */
1564
+ findItemsWithValidPDF(options: {
1565
+ title?: string;
1566
+ doi?: string;
1567
+ requireValidPDF?: boolean;
1568
+ }): any[] {
1569
+ let items: any[];
1570
+
1571
+ if (options.doi) {
1572
+ // Search by DOI
1573
+ const normalizedDOI = options.doi.replace(/^https?:\/\/doi\.org\//i, '').replace(/^doi:/i, '').trim();
1574
+ items = this.queryAll(`
1575
+ SELECT DISTINCT i.itemID, i.key, i.dateAdded, iv.value as doi
1576
+ FROM items i
1577
+ JOIN itemData id ON i.itemID = id.itemID
1578
+ JOIN itemDataValues iv ON id.valueID = iv.valueID
1579
+ JOIN fields f ON id.fieldID = f.fieldID
1580
+ WHERE f.fieldName = 'DOI' AND LOWER(iv.value) = LOWER(?)
1581
+ `, [normalizedDOI]);
1582
+ } else if (options.title) {
1583
+ // Search by title
1584
+ items = this.queryAll(`
1585
+ SELECT DISTINCT i.itemID, i.key, i.dateAdded, iv.value as title
1586
+ FROM items i
1587
+ JOIN itemData id ON i.itemID = id.itemID
1588
+ JOIN itemDataValues iv ON id.valueID = iv.valueID
1589
+ JOIN fields f ON id.fieldID = f.fieldID
1590
+ WHERE f.fieldName = 'title' AND iv.value LIKE ?
1591
+ LIMIT 50
1592
+ `, [`%${options.title}%`]);
1593
+ } else {
1594
+ return [];
1595
+ }
1596
+
1597
+ if (!options.requireValidPDF) {
1598
+ return items.map(item => ({
1599
+ ...this.getItemDetails(item.itemID),
1600
+ hasValidPDF: this.getValidAttachment(item.itemID) !== null
1601
+ }));
1602
+ }
1603
+
1604
+ // Filter to only items with valid PDF
1605
+ const results: any[] = [];
1606
+ for (const item of items) {
1607
+ const validAttachment = this.getValidAttachment(item.itemID);
1608
+ if (validAttachment) {
1609
+ results.push({
1610
+ ...this.getItemDetails(item.itemID),
1611
+ validAttachment
1612
+ });
1613
+ }
1614
+ }
1615
+ return results;
1616
+ }
1617
+
1618
+ /**
1619
+ * Find orphan attachments (records without files)
1620
+ */
1621
+ findOrphanAttachments(limit: number = 100): any[] {
1622
+ const attachments = this.queryAll(`
1623
+ SELECT ia.itemID, ia.parentItemID, ia.path, ia.contentType, i.key,
1624
+ parent.itemID as parentExists
1625
+ FROM itemAttachments ia
1626
+ JOIN items i ON ia.itemID = i.itemID
1627
+ LEFT JOIN items parent ON ia.parentItemID = parent.itemID
1628
+ WHERE ia.path LIKE 'storage:%'
1629
+ LIMIT ?
1630
+ `, [limit]);
1631
+
1632
+ const orphans: any[] = [];
1633
+
1634
+ for (const att of attachments) {
1635
+ const fullPath = this.getAttachmentPath(att.itemID);
1636
+ if (!fullPath || !existsSync(fullPath)) {
1637
+ orphans.push({
1638
+ itemID: att.itemID,
1639
+ parentItemID: att.parentItemID,
1640
+ key: att.key,
1641
+ path: att.path,
1642
+ expectedPath: fullPath,
1643
+ reason: !fullPath ? 'invalid_path' : 'file_not_found'
1644
+ });
1645
+ }
1646
+ }
1647
+
1648
+ return orphans;
1649
+ }
1650
+
1651
+ /**
1652
+ * Delete orphan attachment records (use with caution!)
1653
+ */
1654
+ deleteOrphanAttachments(dryRun: boolean = true): {
1655
+ orphans: any[];
1656
+ deleted: number;
1657
+ dryRun: boolean;
1658
+ } {
1659
+ const orphans = this.findOrphanAttachments(500);
1660
+
1661
+ if (dryRun || this.readonly) {
1662
+ return {
1663
+ orphans,
1664
+ deleted: 0,
1665
+ dryRun: true
1666
+ };
1667
+ }
1668
+
1669
+ let deleted = 0;
1670
+ for (const orphan of orphans) {
1671
+ try {
1672
+ this.execute('DELETE FROM itemAttachments WHERE itemID = ?', [orphan.itemID]);
1673
+ this.execute('DELETE FROM items WHERE itemID = ?', [orphan.itemID]);
1674
+ deleted++;
1675
+ } catch (error) {
1676
+ console.error(`Failed to delete orphan ${orphan.itemID}:`, error);
1677
+ }
1678
+ }
1679
+
1680
+ return {
1681
+ orphans,
1682
+ deleted,
1683
+ dryRun: false
1684
+ };
1685
+ }
1686
+
1687
+ /**
1688
+ * Merge items by transferring notes and tags from source items to target
1689
+ */
1690
+ mergeItems(targetItemID: number, sourceItemIDs: number[]): {
1691
+ success: boolean;
1692
+ transferred: {
1693
+ notes: number;
1694
+ tags: number;
1695
+ };
1696
+ errors: string[];
1697
+ } {
1698
+ const errors: string[] = [];
1699
+ let notesTransferred = 0;
1700
+ let tagsTransferred = 0;
1701
+
1702
+ // Verify target exists
1703
+ const target = this.getItemDetails(targetItemID);
1704
+ if (!target) {
1705
+ return {
1706
+ success: false,
1707
+ transferred: { notes: 0, tags: 0 },
1708
+ errors: ['Target item not found']
1709
+ };
1710
+ }
1711
+
1712
+ for (const sourceID of sourceItemIDs) {
1713
+ if (sourceID === targetItemID) continue;
1714
+
1715
+ // Transfer notes
1716
+ try {
1717
+ const notes = this.getItemNotes(sourceID);
1718
+ for (const note of notes) {
1719
+ this.addItemNote(targetItemID, note.note, `[Merged] ${note.title || ''}`);
1720
+ notesTransferred++;
1721
+ }
1722
+ } catch (error) {
1723
+ errors.push(`Failed to transfer notes from ${sourceID}: ${error}`);
1724
+ }
1725
+
1726
+ // Transfer tags
1727
+ try {
1728
+ const tags = this.getItemTags(sourceID);
1729
+ for (const tag of tags) {
1730
+ this.addTagToItem(targetItemID, tag.name, tag.type);
1731
+ tagsTransferred++;
1732
+ }
1733
+ } catch (error) {
1734
+ errors.push(`Failed to transfer tags from ${sourceID}: ${error}`);
1735
+ }
1736
+ }
1737
+
1738
+ return {
1739
+ success: errors.length === 0,
1740
+ transferred: {
1741
+ notes: notesTransferred,
1742
+ tags: tagsTransferred
1743
+ },
1744
+ errors
1745
+ };
1746
+ }
1225
1747
  }