zotero-bridge 1.1.3 → 1.1.5

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
@@ -352,6 +352,94 @@ export class ZoteroDatabase {
352
352
  };
353
353
  }
354
354
 
355
+ /**
356
+ * Begin a transaction for batch operations
357
+ * IMPORTANT: Always use try-finally to ensure commit/rollback
358
+ */
359
+ beginTransaction(): void {
360
+ if (!this.db) {
361
+ throw new Error('Database not connected');
362
+ }
363
+ this.db.run('BEGIN TRANSACTION');
364
+ }
365
+
366
+ /**
367
+ * Commit the current transaction
368
+ */
369
+ commitTransaction(): void {
370
+ if (!this.db) {
371
+ throw new Error('Database not connected');
372
+ }
373
+ this.db.run('COMMIT');
374
+ }
375
+
376
+ /**
377
+ * Rollback the current transaction
378
+ */
379
+ rollbackTransaction(): void {
380
+ if (!this.db) {
381
+ throw new Error('Database not connected');
382
+ }
383
+ try {
384
+ this.db.run('ROLLBACK');
385
+ } catch (e) {
386
+ // Ignore if no transaction is active
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Execute a function within a transaction
392
+ * Automatically commits on success, rolls back on error
393
+ */
394
+ withTransaction<T>(fn: () => T): T {
395
+ this.beginTransaction();
396
+ try {
397
+ const result = fn();
398
+ this.commitTransaction();
399
+ return result;
400
+ } catch (error) {
401
+ this.rollbackTransaction();
402
+ throw error;
403
+ }
404
+ }
405
+
406
+ // ============================================
407
+ // Trash / Deleted Items Operations
408
+ // ============================================
409
+
410
+ /**
411
+ * Check if an item is in the trash (deletedItems table)
412
+ */
413
+ isItemDeleted(itemID: number): boolean {
414
+ const result = this.queryOne('SELECT 1 FROM deletedItems WHERE itemID = ?', [itemID]);
415
+ return !!result;
416
+ }
417
+
418
+ /**
419
+ * Get all items in trash with details
420
+ */
421
+ getDeletedItems(): any[] {
422
+ return this.queryAll(`
423
+ SELECT d.itemID, d.dateDeleted, i.key, i.itemTypeID, it.typeName,
424
+ (SELECT iv.value FROM itemData id
425
+ JOIN itemDataValues iv ON id.valueID = iv.valueID
426
+ JOIN fields f ON id.fieldID = f.fieldID
427
+ WHERE id.itemID = i.itemID AND f.fieldName = 'title') as title
428
+ FROM deletedItems d
429
+ JOIN items i ON d.itemID = i.itemID
430
+ JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
431
+ ORDER BY d.dateDeleted DESC
432
+ `);
433
+ }
434
+
435
+ /**
436
+ * Get count of items in trash
437
+ */
438
+ getDeletedItemsCount(): number {
439
+ const result = this.queryOne('SELECT COUNT(*) as count FROM deletedItems');
440
+ return result?.count || 0;
441
+ }
442
+
355
443
  // ============================================
356
444
  // Collection (Directory) Operations
357
445
  // ============================================
@@ -518,6 +606,7 @@ export class ZoteroDatabase {
518
606
  * Add tag to item
519
607
  *
520
608
  * Following Zotero's pattern: modifying item tags should update item metadata
609
+ * Note: itemTags.type is required (NOT NULL) - 0=user tag, 1=automatic
521
610
  */
522
611
  addTagToItem(itemID: number, tagName: string, type: number = 0): boolean {
523
612
  // Get or create tag
@@ -532,7 +621,8 @@ export class ZoteroDatabase {
532
621
  return false;
533
622
  }
534
623
 
535
- this.execute('INSERT INTO itemTags (itemID, tagID) VALUES (?, ?)', [itemID, tagID]);
624
+ // itemTags.type is NOT NULL, must provide value
625
+ this.execute('INSERT INTO itemTags (itemID, tagID, type) VALUES (?, ?, ?)', [itemID, tagID, type]);
536
626
 
537
627
  // CRITICAL: Update item metadata for Zotero compatibility
538
628
  this.updateItemMetadata(itemID);
@@ -646,19 +736,27 @@ export class ZoteroDatabase {
646
736
 
647
737
  /**
648
738
  * Get item by key
739
+ * Optionally include deleted items
649
740
  */
650
- getItemByKey(key: string): ZoteroItem | null {
651
- return this.queryOne(`
652
- SELECT itemID, key, itemTypeID, dateAdded, dateModified, libraryID
653
- FROM items
654
- WHERE key = ?
655
- `, [key]);
741
+ getItemByKey(key: string, includeDeleted: boolean = false): ZoteroItem | null {
742
+ const sql = includeDeleted
743
+ ? `SELECT itemID, key, itemTypeID, dateAdded, dateModified, libraryID
744
+ FROM items WHERE key = ?`
745
+ : `SELECT itemID, key, itemTypeID, dateAdded, dateModified, libraryID
746
+ FROM items WHERE key = ? AND itemID NOT IN (SELECT itemID FROM deletedItems)`;
747
+ return this.queryOne(sql, [key]);
656
748
  }
657
749
 
658
750
  /**
659
751
  * Search items by title
752
+ * Excludes deleted items and non-searchable types (attachments, notes)
660
753
  */
661
754
  searchItems(query: string, limit: number = 50, libraryID: number = 1): any[] {
755
+ // Get note and attachment type IDs dynamically
756
+ const noteType = this.queryOne("SELECT itemTypeID FROM itemTypes WHERE typeName = 'note'");
757
+ const attachType = this.queryOne("SELECT itemTypeID FROM itemTypes WHERE typeName = 'attachment'");
758
+ const excludeTypes = [noteType?.itemTypeID || 28, attachType?.itemTypeID || 3];
759
+
662
760
  return this.queryAll(`
663
761
  SELECT DISTINCT i.itemID, i.key, i.itemTypeID, i.dateAdded, i.dateModified,
664
762
  iv.value as title
@@ -669,13 +767,16 @@ export class ZoteroDatabase {
669
767
  WHERE f.fieldName = 'title'
670
768
  AND iv.value LIKE ?
671
769
  AND i.libraryID = ?
770
+ AND i.itemTypeID NOT IN (?, ?)
771
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
672
772
  ORDER BY i.dateModified DESC
673
773
  LIMIT ?
674
- `, [`%${query}%`, libraryID, limit]);
774
+ `, [`%${query}%`, libraryID, excludeTypes[0], excludeTypes[1], limit]);
675
775
  }
676
776
 
677
777
  /**
678
778
  * Get item details with all fields
779
+ * Includes isDeleted flag to indicate if item is in trash
679
780
  */
680
781
  getItemDetails(itemID: number): Record<string, any> {
681
782
  // Get item basic info
@@ -688,6 +789,11 @@ export class ZoteroDatabase {
688
789
  return {};
689
790
  }
690
791
 
792
+ // Check if item is in trash
793
+ const deletedInfo = this.queryOne(`
794
+ SELECT dateDeleted FROM deletedItems WHERE itemID = ?
795
+ `, [itemID]);
796
+
691
797
  // Get all item data fields
692
798
  const fields = this.queryAll(`
693
799
  SELECT f.fieldName, iv.value
@@ -710,15 +816,18 @@ export class ZoteroDatabase {
710
816
  // Get tags
711
817
  const tags = this.getItemTags(itemID);
712
818
 
713
- // Get attachments
819
+ // Get attachments (exclude deleted attachments)
714
820
  const attachments = this.queryAll(`
715
821
  SELECT ia.itemID, ia.path, ia.contentType
716
822
  FROM itemAttachments ia
717
823
  WHERE ia.parentItemID = ?
824
+ AND ia.itemID NOT IN (SELECT itemID FROM deletedItems)
718
825
  `, [itemID]);
719
826
 
720
827
  const result: Record<string, any> = {
721
828
  ...item,
829
+ isDeleted: !!deletedInfo,
830
+ dateDeleted: deletedInfo?.dateDeleted || null,
722
831
  creators,
723
832
  tags,
724
833
  attachments
@@ -914,52 +1023,90 @@ export class ZoteroDatabase {
914
1023
 
915
1024
  /**
916
1025
  * Find item by DOI
1026
+ * Returns the most recently modified item if duplicates exist
1027
+ * Excludes items in trash
917
1028
  */
918
1029
  findItemByDOI(doi: string): any | null {
919
1030
  // Normalize DOI (remove common prefixes)
920
1031
  const normalizedDOI = doi.replace(/^https?:\/\/doi\.org\//i, '').replace(/^doi:/i, '').trim();
921
1032
 
922
- const result = this.queryOne(`
1033
+ // Find all matches, excluding deleted items
1034
+ const allMatches = this.queryAll(`
923
1035
  SELECT DISTINCT i.itemID, i.key, i.itemTypeID, i.dateAdded, i.dateModified,
924
1036
  iv.value as doi
925
1037
  FROM items i
926
1038
  JOIN itemData id ON i.itemID = id.itemID
927
1039
  JOIN itemDataValues iv ON id.valueID = iv.valueID
928
1040
  JOIN fields f ON id.fieldID = f.fieldID
929
- WHERE f.fieldName = 'DOI' AND LOWER(iv.value) = LOWER(?)
1041
+ WHERE f.fieldName = 'DOI'
1042
+ AND LOWER(iv.value) = LOWER(?)
1043
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
1044
+ ORDER BY i.dateModified DESC
930
1045
  `, [normalizedDOI]);
931
1046
 
932
- if (result) {
933
- return this.getItemDetails(result.itemID);
1047
+ if (allMatches.length === 0) {
1048
+ return null;
934
1049
  }
935
- return null;
1050
+
1051
+ const result = this.getItemDetails(allMatches[0].itemID);
1052
+
1053
+ // Warn about duplicates
1054
+ if (allMatches.length > 1) {
1055
+ result._duplicateWarning = {
1056
+ message: `Found ${allMatches.length} items with same DOI`,
1057
+ duplicateItemIDs: allMatches.map((m: any) => m.itemID),
1058
+ usingItemID: allMatches[0].itemID
1059
+ };
1060
+ }
1061
+
1062
+ return result;
936
1063
  }
937
1064
 
938
1065
  /**
939
1066
  * Find item by ISBN
1067
+ * Returns the most recently modified item if duplicates exist
1068
+ * Excludes items in trash
940
1069
  */
941
1070
  findItemByISBN(isbn: string): any | null {
942
1071
  // Normalize ISBN (remove hyphens and spaces)
943
1072
  const normalizedISBN = isbn.replace(/[-\s]/g, '').trim();
944
1073
 
945
- const result = this.queryOne(`
1074
+ // Find all matches, excluding deleted items
1075
+ const allMatches = this.queryAll(`
946
1076
  SELECT DISTINCT i.itemID, i.key, i.itemTypeID, i.dateAdded, i.dateModified,
947
1077
  iv.value as isbn
948
1078
  FROM items i
949
1079
  JOIN itemData id ON i.itemID = id.itemID
950
1080
  JOIN itemDataValues iv ON id.valueID = iv.valueID
951
1081
  JOIN fields f ON id.fieldID = f.fieldID
952
- WHERE f.fieldName = 'ISBN' AND REPLACE(REPLACE(iv.value, '-', ''), ' ', '') = ?
1082
+ WHERE f.fieldName = 'ISBN'
1083
+ AND REPLACE(REPLACE(iv.value, '-', ''), ' ', '') = ?
1084
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
1085
+ ORDER BY i.dateModified DESC
953
1086
  `, [normalizedISBN]);
954
1087
 
955
- if (result) {
956
- return this.getItemDetails(result.itemID);
1088
+ if (allMatches.length === 0) {
1089
+ return null;
957
1090
  }
958
- return null;
1091
+
1092
+ const result = this.getItemDetails(allMatches[0].itemID);
1093
+
1094
+ // Warn about duplicates
1095
+ if (allMatches.length > 1) {
1096
+ result._duplicateWarning = {
1097
+ message: `Found ${allMatches.length} items with same ISBN`,
1098
+ duplicateItemIDs: allMatches.map((m: any) => m.itemID),
1099
+ usingItemID: allMatches[0].itemID
1100
+ };
1101
+ }
1102
+
1103
+ return result;
959
1104
  }
960
1105
 
961
1106
  /**
962
1107
  * Find item by any identifier (DOI, ISBN, PMID, arXiv, etc.)
1108
+ * Returns the most recently modified item if duplicates exist
1109
+ * Excludes items in trash
963
1110
  */
964
1111
  findItemByIdentifier(identifier: string, type?: string): any | null {
965
1112
  const fieldMap: Record<string, string> = {
@@ -979,17 +1126,29 @@ export class ZoteroDatabase {
979
1126
  return this.findItemByISBN(identifier);
980
1127
  }
981
1128
 
982
- const result = this.queryOne(`
1129
+ // For other identifier types, also check for duplicates (excluding deleted items)
1130
+ const allMatches = this.queryAll(`
983
1131
  SELECT DISTINCT i.itemID, i.key
984
1132
  FROM items i
985
1133
  JOIN itemData id ON i.itemID = id.itemID
986
1134
  JOIN itemDataValues iv ON id.valueID = iv.valueID
987
1135
  JOIN fields f ON id.fieldID = f.fieldID
988
- WHERE f.fieldName = ? AND iv.value LIKE ?
1136
+ WHERE f.fieldName = ?
1137
+ AND iv.value LIKE ?
1138
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
1139
+ ORDER BY i.dateModified DESC
989
1140
  `, [fieldName, `%${identifier}%`]);
990
1141
 
991
- if (result) {
992
- return this.getItemDetails(result.itemID);
1142
+ if (allMatches.length > 0) {
1143
+ const result = this.getItemDetails(allMatches[0].itemID);
1144
+ if (allMatches.length > 1) {
1145
+ result._duplicateWarning = {
1146
+ message: `Found ${allMatches.length} items with same ${fieldName}`,
1147
+ duplicateItemIDs: allMatches.map((m: any) => m.itemID),
1148
+ usingItemID: allMatches[0].itemID
1149
+ };
1150
+ }
1151
+ return result;
993
1152
  }
994
1153
  }
995
1154
 
@@ -1416,8 +1575,18 @@ export class ZoteroDatabase {
1416
1575
 
1417
1576
  /**
1418
1577
  * Find duplicate items based on title, DOI, or ISBN
1578
+ *
1579
+ * Note: itemTypeID values vary by Zotero version:
1580
+ * - attachment = 3
1581
+ * - note = 28 (in newer versions) or 1 (in older versions)
1582
+ * We exclude both attachments and notes from duplicate detection
1419
1583
  */
1420
1584
  findDuplicates(field: 'title' | 'doi' | 'isbn' = 'title', libraryID: number = 1): any[] {
1585
+ // Get note and attachment type IDs dynamically
1586
+ const noteType = this.queryOne("SELECT itemTypeID FROM itemTypes WHERE typeName = 'note'");
1587
+ const attachType = this.queryOne("SELECT itemTypeID FROM itemTypes WHERE typeName = 'attachment'");
1588
+ const excludeTypes = [noteType?.itemTypeID || 28, attachType?.itemTypeID || 3].join(',');
1589
+
1421
1590
  if (field === 'title') {
1422
1591
  // Find items with the same title
1423
1592
  return this.queryAll(`
@@ -1431,7 +1600,8 @@ export class ZoteroDatabase {
1431
1600
  JOIN fields f ON id.fieldID = f.fieldID
1432
1601
  WHERE f.fieldName = 'title'
1433
1602
  AND i.libraryID = ?
1434
- AND i.itemTypeID NOT IN (1, 14) -- Exclude notes and attachments
1603
+ AND i.itemTypeID NOT IN (${excludeTypes})
1604
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
1435
1605
  GROUP BY iv.value
1436
1606
  HAVING COUNT(*) > 1
1437
1607
  ORDER BY count DESC
@@ -1449,6 +1619,7 @@ export class ZoteroDatabase {
1449
1619
  WHERE f.fieldName = 'DOI'
1450
1620
  AND i.libraryID = ?
1451
1621
  AND iv.value != ''
1622
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
1452
1623
  GROUP BY LOWER(iv.value)
1453
1624
  HAVING COUNT(*) > 1
1454
1625
  ORDER BY count DESC
@@ -1466,6 +1637,7 @@ export class ZoteroDatabase {
1466
1637
  WHERE f.fieldName = 'ISBN'
1467
1638
  AND i.libraryID = ?
1468
1639
  AND iv.value != ''
1640
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
1469
1641
  GROUP BY REPLACE(REPLACE(iv.value, '-', ''), ' ', '')
1470
1642
  HAVING COUNT(*) > 1
1471
1643
  ORDER BY count DESC
@@ -1686,60 +1858,116 @@ export class ZoteroDatabase {
1686
1858
 
1687
1859
  /**
1688
1860
  * Merge items by transferring notes and tags from source items to target
1861
+ * Uses transaction to ensure data consistency
1689
1862
  */
1690
1863
  mergeItems(targetItemID: number, sourceItemIDs: number[]): {
1691
1864
  success: boolean;
1692
1865
  transferred: {
1693
1866
  notes: number;
1694
1867
  tags: number;
1868
+ attachments: number;
1695
1869
  };
1696
1870
  errors: string[];
1697
1871
  } {
1698
1872
  const errors: string[] = [];
1699
1873
  let notesTransferred = 0;
1700
1874
  let tagsTransferred = 0;
1875
+ let attachmentsTransferred = 0;
1701
1876
 
1702
- // Verify target exists
1877
+ // Verify target exists and is not deleted
1703
1878
  const target = this.getItemDetails(targetItemID);
1704
1879
  if (!target) {
1705
1880
  return {
1706
1881
  success: false,
1707
- transferred: { notes: 0, tags: 0 },
1882
+ transferred: { notes: 0, tags: 0, attachments: 0 },
1708
1883
  errors: ['Target item not found']
1709
1884
  };
1710
1885
  }
1711
1886
 
1712
- for (const sourceID of sourceItemIDs) {
1713
- if (sourceID === targetItemID) continue;
1887
+ // Check if target is in deletedItems
1888
+ const targetDeleted = this.queryOne('SELECT 1 FROM deletedItems WHERE itemID = ?', [targetItemID]);
1889
+ if (targetDeleted) {
1890
+ return {
1891
+ success: false,
1892
+ transferred: { notes: 0, tags: 0, attachments: 0 },
1893
+ errors: ['Target item is in trash']
1894
+ };
1895
+ }
1714
1896
 
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++;
1897
+ // Use transaction for atomic operation
1898
+ this.beginTransaction();
1899
+
1900
+ try {
1901
+ for (const sourceID of sourceItemIDs) {
1902
+ if (sourceID === targetItemID) continue;
1903
+
1904
+ // Verify source exists
1905
+ const source = this.queryOne('SELECT itemID FROM items WHERE itemID = ?', [sourceID]);
1906
+ if (!source) {
1907
+ errors.push(`Source item ${sourceID} not found`);
1908
+ continue;
1721
1909
  }
1722
- } catch (error) {
1723
- errors.push(`Failed to transfer notes from ${sourceID}: ${error}`);
1724
- }
1725
1910
 
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++;
1911
+ // Transfer notes
1912
+ try {
1913
+ const notes = this.getItemNotes(sourceID);
1914
+ for (const note of notes) {
1915
+ this.addItemNote(targetItemID, note.note, `[Merged] ${note.title || ''}`);
1916
+ notesTransferred++;
1917
+ }
1918
+ } catch (error) {
1919
+ errors.push(`Failed to transfer notes from ${sourceID}: ${error}`);
1920
+ }
1921
+
1922
+ // Transfer tags
1923
+ try {
1924
+ const tags = this.getItemTags(sourceID);
1925
+ for (const tag of tags) {
1926
+ this.addTagToItem(targetItemID, tag.name, tag.type);
1927
+ tagsTransferred++;
1928
+ }
1929
+ } catch (error) {
1930
+ errors.push(`Failed to transfer tags from ${sourceID}: ${error}`);
1931
+ }
1932
+
1933
+ // Transfer attachments (update parentItemID)
1934
+ try {
1935
+ const attachments = this.queryAll(`
1936
+ SELECT itemID FROM itemAttachments WHERE parentItemID = ?
1937
+ `, [sourceID]);
1938
+
1939
+ for (const att of attachments) {
1940
+ this.execute(`
1941
+ UPDATE itemAttachments SET parentItemID = ? WHERE itemID = ?
1942
+ `, [targetItemID, att.itemID]);
1943
+ attachmentsTransferred++;
1944
+ }
1945
+ } catch (error) {
1946
+ errors.push(`Failed to transfer attachments from ${sourceID}: ${error}`);
1732
1947
  }
1733
- } catch (error) {
1734
- errors.push(`Failed to transfer tags from ${sourceID}: ${error}`);
1735
1948
  }
1949
+
1950
+ this.commitTransaction();
1951
+
1952
+ if (!this.readonly) {
1953
+ this.save();
1954
+ }
1955
+ } catch (error) {
1956
+ this.rollbackTransaction();
1957
+ errors.push(`Transaction failed: ${error}`);
1958
+ return {
1959
+ success: false,
1960
+ transferred: { notes: 0, tags: 0, attachments: 0 },
1961
+ errors
1962
+ };
1736
1963
  }
1737
1964
 
1738
1965
  return {
1739
1966
  success: errors.length === 0,
1740
1967
  transferred: {
1741
1968
  notes: notesTransferred,
1742
- tags: tagsTransferred
1969
+ tags: tagsTransferred,
1970
+ attachments: attachmentsTransferred
1743
1971
  },
1744
1972
  errors
1745
1973
  };