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/README-en.md +28 -2
- package/README.md +28 -2
- package/dist/database.d.ts +48 -1
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +255 -48
- package/dist/database.js.map +1 -1
- package/package.json +1 -1
- package/src/database.ts +274 -46
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
|
-
|
|
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
|
-
|
|
652
|
-
SELECT itemID, key, itemTypeID, dateAdded, dateModified, libraryID
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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'
|
|
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 (
|
|
933
|
-
return
|
|
1047
|
+
if (allMatches.length === 0) {
|
|
1048
|
+
return null;
|
|
934
1049
|
}
|
|
935
|
-
|
|
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
|
-
|
|
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'
|
|
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 (
|
|
956
|
-
return
|
|
1088
|
+
if (allMatches.length === 0) {
|
|
1089
|
+
return null;
|
|
957
1090
|
}
|
|
958
|
-
|
|
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
|
-
|
|
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 = ?
|
|
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 (
|
|
992
|
-
|
|
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 (
|
|
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
|
-
|
|
1713
|
-
|
|
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
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
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
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
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
|
};
|