zotero-bridge 1.0.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.
@@ -0,0 +1,1031 @@
1
+ /**
2
+ * ZoteroBridge - Zotero SQLite Database Connection Module
3
+ *
4
+ * This module provides direct access to Zotero's SQLite database (zotero.sqlite)
5
+ * for reading and writing reference data.
6
+ *
7
+ * Uses sql.js for pure JavaScript SQLite support (no native compilation required)
8
+ *
9
+ * @author Combjellyshen
10
+ */
11
+ import initSqlJs from 'sql.js';
12
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
13
+ import { homedir } from 'os';
14
+ import { join } from 'path';
15
+ export class ZoteroDatabase {
16
+ db = null;
17
+ dbPath;
18
+ readonly;
19
+ SQL = null;
20
+ constructor(dbPath, readonly = false) {
21
+ this.dbPath = dbPath || this.findDefaultZoteroDB();
22
+ this.readonly = readonly;
23
+ }
24
+ /**
25
+ * Find the default Zotero database path based on OS
26
+ */
27
+ findDefaultZoteroDB() {
28
+ const home = homedir();
29
+ const possiblePaths = [];
30
+ // Windows paths
31
+ if (process.platform === 'win32') {
32
+ possiblePaths.push(join(home, 'Zotero', 'zotero.sqlite'), join(process.env.APPDATA || '', 'Zotero', 'Zotero', 'Profiles'));
33
+ }
34
+ // macOS paths
35
+ else if (process.platform === 'darwin') {
36
+ possiblePaths.push(join(home, 'Zotero', 'zotero.sqlite'), join(home, 'Library', 'Application Support', 'Zotero', 'Profiles'));
37
+ }
38
+ // Linux paths
39
+ else {
40
+ possiblePaths.push(join(home, 'Zotero', 'zotero.sqlite'), join(home, '.zotero', 'zotero'));
41
+ }
42
+ // Check each path
43
+ for (const p of possiblePaths) {
44
+ if (existsSync(p)) {
45
+ return p;
46
+ }
47
+ }
48
+ // Default fallback
49
+ return join(home, 'Zotero', 'zotero.sqlite');
50
+ }
51
+ /**
52
+ * Connect to the Zotero database
53
+ */
54
+ async connect() {
55
+ if (this.db) {
56
+ return;
57
+ }
58
+ if (!existsSync(this.dbPath)) {
59
+ throw new Error(`Zotero database not found at: ${this.dbPath}`);
60
+ }
61
+ // Initialize sql.js
62
+ this.SQL = await initSqlJs();
63
+ // Read the database file
64
+ const buffer = readFileSync(this.dbPath);
65
+ const db = new this.SQL.Database(buffer);
66
+ this.db = db;
67
+ // Enable foreign keys
68
+ db.run('PRAGMA foreign_keys = ON');
69
+ }
70
+ /**
71
+ * Save changes to the database file
72
+ */
73
+ save() {
74
+ if (!this.db || this.readonly) {
75
+ return;
76
+ }
77
+ const data = this.db.export();
78
+ const buffer = Buffer.from(data);
79
+ writeFileSync(this.dbPath, buffer);
80
+ }
81
+ /**
82
+ * Disconnect from the database
83
+ */
84
+ disconnect() {
85
+ if (this.db) {
86
+ if (!this.readonly) {
87
+ this.save();
88
+ }
89
+ this.db.close();
90
+ this.db = null;
91
+ }
92
+ }
93
+ /**
94
+ * Get the database instance
95
+ */
96
+ async getDB() {
97
+ if (!this.db) {
98
+ await this.connect();
99
+ }
100
+ return this.db;
101
+ }
102
+ /**
103
+ * Get database path
104
+ */
105
+ getPath() {
106
+ return this.dbPath;
107
+ }
108
+ /**
109
+ * Execute a query and return all results
110
+ */
111
+ queryAll(sql, params = []) {
112
+ if (!this.db) {
113
+ throw new Error('Database not connected');
114
+ }
115
+ const stmt = this.db.prepare(sql);
116
+ stmt.bind(params);
117
+ const results = [];
118
+ while (stmt.step()) {
119
+ const row = stmt.getAsObject();
120
+ results.push(row);
121
+ }
122
+ stmt.free();
123
+ return results;
124
+ }
125
+ /**
126
+ * Execute a query and return the first result
127
+ */
128
+ queryOne(sql, params = []) {
129
+ const results = this.queryAll(sql, params);
130
+ return results.length > 0 ? results[0] : null;
131
+ }
132
+ /**
133
+ * Execute a statement (INSERT, UPDATE, DELETE)
134
+ */
135
+ execute(sql, params = []) {
136
+ if (!this.db) {
137
+ throw new Error('Database not connected');
138
+ }
139
+ this.db.run(sql, params);
140
+ const changes = this.db.getRowsModified();
141
+ const lastId = this.queryOne('SELECT last_insert_rowid() as id');
142
+ return {
143
+ changes,
144
+ lastInsertRowid: lastId?.id || 0
145
+ };
146
+ }
147
+ // ============================================
148
+ // Collection (Directory) Operations
149
+ // ============================================
150
+ /**
151
+ * Get all collections
152
+ */
153
+ getCollections(libraryID = 1) {
154
+ return this.queryAll(`
155
+ SELECT collectionID, collectionName, parentCollectionID, key, libraryID
156
+ FROM collections
157
+ WHERE libraryID = ?
158
+ ORDER BY collectionName
159
+ `, [libraryID]);
160
+ }
161
+ /**
162
+ * Get collection by ID
163
+ */
164
+ getCollectionById(collectionID) {
165
+ return this.queryOne(`
166
+ SELECT collectionID, collectionName, parentCollectionID, key, libraryID
167
+ FROM collections
168
+ WHERE collectionID = ?
169
+ `, [collectionID]);
170
+ }
171
+ /**
172
+ * Get collection by name
173
+ */
174
+ getCollectionByName(name, libraryID = 1) {
175
+ return this.queryOne(`
176
+ SELECT collectionID, collectionName, parentCollectionID, key, libraryID
177
+ FROM collections
178
+ WHERE collectionName = ? AND libraryID = ?
179
+ `, [name, libraryID]);
180
+ }
181
+ /**
182
+ * Get subcollections of a collection
183
+ */
184
+ getSubcollections(parentCollectionID) {
185
+ return this.queryAll(`
186
+ SELECT collectionID, collectionName, parentCollectionID, key, libraryID
187
+ FROM collections
188
+ WHERE parentCollectionID = ?
189
+ ORDER BY collectionName
190
+ `, [parentCollectionID]);
191
+ }
192
+ /**
193
+ * Create a new collection
194
+ */
195
+ createCollection(name, parentCollectionID = null, libraryID = 1) {
196
+ const key = this.generateKey();
197
+ const result = this.execute(`
198
+ INSERT INTO collections (collectionName, parentCollectionID, libraryID, key, version)
199
+ VALUES (?, ?, ?, ?, 0)
200
+ `, [name, parentCollectionID, libraryID, key]);
201
+ if (!this.readonly) {
202
+ this.save();
203
+ }
204
+ return result.lastInsertRowid;
205
+ }
206
+ /**
207
+ * Rename a collection
208
+ */
209
+ renameCollection(collectionID, newName) {
210
+ const result = this.execute(`
211
+ UPDATE collections
212
+ SET collectionName = ?, version = version + 1
213
+ WHERE collectionID = ?
214
+ `, [newName, collectionID]);
215
+ if (!this.readonly && result.changes > 0) {
216
+ this.save();
217
+ }
218
+ return result.changes > 0;
219
+ }
220
+ /**
221
+ * Move a collection to a new parent
222
+ */
223
+ moveCollection(collectionID, newParentID) {
224
+ const result = this.execute(`
225
+ UPDATE collections
226
+ SET parentCollectionID = ?, version = version + 1
227
+ WHERE collectionID = ?
228
+ `, [newParentID, collectionID]);
229
+ if (!this.readonly && result.changes > 0) {
230
+ this.save();
231
+ }
232
+ return result.changes > 0;
233
+ }
234
+ /**
235
+ * Delete a collection
236
+ */
237
+ deleteCollection(collectionID) {
238
+ // First, remove all items from collection
239
+ this.execute('DELETE FROM collectionItems WHERE collectionID = ?', [collectionID]);
240
+ // Then delete the collection
241
+ const result = this.execute('DELETE FROM collections WHERE collectionID = ?', [collectionID]);
242
+ if (!this.readonly && result.changes > 0) {
243
+ this.save();
244
+ }
245
+ return result.changes > 0;
246
+ }
247
+ // ============================================
248
+ // Tag Operations
249
+ // ============================================
250
+ /**
251
+ * Get all tags with usage count
252
+ */
253
+ getTags() {
254
+ return this.queryAll(`
255
+ SELECT t.tagID, t.name, COUNT(it.itemID) as itemCount
256
+ FROM tags t
257
+ LEFT JOIN itemTags it ON t.tagID = it.tagID
258
+ GROUP BY t.tagID, t.name
259
+ ORDER BY t.name
260
+ `);
261
+ }
262
+ /**
263
+ * Get tag by name
264
+ */
265
+ getTagByName(name) {
266
+ return this.queryOne('SELECT tagID, name FROM tags WHERE name = ?', [name]);
267
+ }
268
+ /**
269
+ * Create a new tag
270
+ */
271
+ createTag(name, _type = 0) {
272
+ // Check if tag exists
273
+ const existing = this.getTagByName(name);
274
+ if (existing) {
275
+ return existing.tagID;
276
+ }
277
+ const result = this.execute('INSERT INTO tags (name) VALUES (?)', [name]);
278
+ if (!this.readonly) {
279
+ this.save();
280
+ }
281
+ return result.lastInsertRowid;
282
+ }
283
+ /**
284
+ * Add tag to item
285
+ */
286
+ addTagToItem(itemID, tagName, type = 0) {
287
+ // Get or create tag
288
+ const tagID = this.createTag(tagName, type);
289
+ // Check if already tagged
290
+ const existing = this.queryOne(`
291
+ SELECT 1 FROM itemTags WHERE itemID = ? AND tagID = ?
292
+ `, [itemID, tagID]);
293
+ if (existing) {
294
+ return false;
295
+ }
296
+ this.execute('INSERT INTO itemTags (itemID, tagID) VALUES (?, ?)', [itemID, tagID]);
297
+ if (!this.readonly) {
298
+ this.save();
299
+ }
300
+ return true;
301
+ }
302
+ /**
303
+ * Remove tag from item
304
+ */
305
+ removeTagFromItem(itemID, tagName) {
306
+ const tag = this.getTagByName(tagName);
307
+ if (!tag) {
308
+ return false;
309
+ }
310
+ const result = this.execute('DELETE FROM itemTags WHERE itemID = ? AND tagID = ?', [itemID, tag.tagID]);
311
+ if (!this.readonly && result.changes > 0) {
312
+ this.save();
313
+ }
314
+ return result.changes > 0;
315
+ }
316
+ /**
317
+ * Get all tags for an item
318
+ * Note: type is in itemTags table, not tags table
319
+ */
320
+ getItemTags(itemID) {
321
+ return this.queryAll(`
322
+ SELECT t.tagID, t.name, it.type
323
+ FROM tags t
324
+ JOIN itemTags it ON t.tagID = it.tagID
325
+ WHERE it.itemID = ?
326
+ ORDER BY t.name
327
+ `, [itemID]);
328
+ }
329
+ // ============================================
330
+ // Item Operations
331
+ // ============================================
332
+ /**
333
+ * Get items in a collection
334
+ */
335
+ getCollectionItems(collectionID) {
336
+ return this.queryAll(`
337
+ SELECT i.itemID, i.key, i.itemTypeID, i.dateAdded, i.dateModified, i.libraryID
338
+ FROM items i
339
+ JOIN collectionItems ci ON i.itemID = ci.itemID
340
+ WHERE ci.collectionID = ?
341
+ `, [collectionID]);
342
+ }
343
+ /**
344
+ * Add item to collection
345
+ */
346
+ addItemToCollection(itemID, collectionID) {
347
+ // Check if already in collection
348
+ const existing = this.queryOne(`
349
+ SELECT 1 FROM collectionItems WHERE itemID = ? AND collectionID = ?
350
+ `, [itemID, collectionID]);
351
+ if (existing) {
352
+ return false;
353
+ }
354
+ this.execute('INSERT INTO collectionItems (itemID, collectionID) VALUES (?, ?)', [itemID, collectionID]);
355
+ if (!this.readonly) {
356
+ this.save();
357
+ }
358
+ return true;
359
+ }
360
+ /**
361
+ * Remove item from collection
362
+ */
363
+ removeItemFromCollection(itemID, collectionID) {
364
+ const result = this.execute('DELETE FROM collectionItems WHERE itemID = ? AND collectionID = ?', [itemID, collectionID]);
365
+ if (!this.readonly && result.changes > 0) {
366
+ this.save();
367
+ }
368
+ return result.changes > 0;
369
+ }
370
+ /**
371
+ * Get item by key
372
+ */
373
+ getItemByKey(key) {
374
+ return this.queryOne(`
375
+ SELECT itemID, key, itemTypeID, dateAdded, dateModified, libraryID
376
+ FROM items
377
+ WHERE key = ?
378
+ `, [key]);
379
+ }
380
+ /**
381
+ * Search items by title
382
+ */
383
+ searchItems(query, limit = 50, libraryID = 1) {
384
+ return this.queryAll(`
385
+ SELECT DISTINCT i.itemID, i.key, i.itemTypeID, i.dateAdded, i.dateModified,
386
+ iv.value as title
387
+ FROM items i
388
+ JOIN itemData id ON i.itemID = id.itemID
389
+ JOIN itemDataValues iv ON id.valueID = iv.valueID
390
+ JOIN fields f ON id.fieldID = f.fieldID
391
+ WHERE f.fieldName = 'title'
392
+ AND iv.value LIKE ?
393
+ AND i.libraryID = ?
394
+ ORDER BY i.dateModified DESC
395
+ LIMIT ?
396
+ `, [`%${query}%`, libraryID, limit]);
397
+ }
398
+ /**
399
+ * Get item details with all fields
400
+ */
401
+ getItemDetails(itemID) {
402
+ // Get item basic info
403
+ const item = this.queryOne(`
404
+ SELECT itemID, key, itemTypeID, dateAdded, dateModified, libraryID
405
+ FROM items WHERE itemID = ?
406
+ `, [itemID]);
407
+ if (!item) {
408
+ return {};
409
+ }
410
+ // Get all item data fields
411
+ const fields = this.queryAll(`
412
+ SELECT f.fieldName, iv.value
413
+ FROM itemData id
414
+ JOIN fields f ON id.fieldID = f.fieldID
415
+ JOIN itemDataValues iv ON id.valueID = iv.valueID
416
+ WHERE id.itemID = ?
417
+ `, [itemID]);
418
+ // Get creators
419
+ const creators = this.queryAll(`
420
+ SELECT c.firstName, c.lastName, ct.creatorType, ic.orderIndex
421
+ FROM itemCreators ic
422
+ JOIN creators c ON ic.creatorID = c.creatorID
423
+ JOIN creatorTypes ct ON ic.creatorTypeID = ct.creatorTypeID
424
+ WHERE ic.itemID = ?
425
+ ORDER BY ic.orderIndex
426
+ `, [itemID]);
427
+ // Get tags
428
+ const tags = this.getItemTags(itemID);
429
+ // Get attachments
430
+ const attachments = this.queryAll(`
431
+ SELECT ia.itemID, ia.path, ia.contentType
432
+ FROM itemAttachments ia
433
+ WHERE ia.parentItemID = ?
434
+ `, [itemID]);
435
+ const result = {
436
+ ...item,
437
+ creators,
438
+ tags,
439
+ attachments
440
+ };
441
+ // Add field values
442
+ for (const field of fields) {
443
+ result[field.fieldName] = field.value;
444
+ }
445
+ return result;
446
+ }
447
+ // ============================================
448
+ // Item Abstract/Note Operations
449
+ // ============================================
450
+ /**
451
+ * Get item abstract
452
+ */
453
+ getItemAbstract(itemID) {
454
+ const result = this.queryOne(`
455
+ SELECT iv.value
456
+ FROM itemData id
457
+ JOIN fields f ON id.fieldID = f.fieldID
458
+ JOIN itemDataValues iv ON id.valueID = iv.valueID
459
+ WHERE id.itemID = ? AND f.fieldName = 'abstractNote'
460
+ `, [itemID]);
461
+ return result?.value || null;
462
+ }
463
+ /**
464
+ * Set item abstract
465
+ */
466
+ setItemAbstract(itemID, abstract) {
467
+ // Get abstractNote field ID
468
+ const field = this.queryOne("SELECT fieldID FROM fields WHERE fieldName = 'abstractNote'");
469
+ if (!field) {
470
+ return false;
471
+ }
472
+ // Get or create value
473
+ let valueRow = this.queryOne('SELECT valueID FROM itemDataValues WHERE value = ?', [abstract]);
474
+ if (!valueRow) {
475
+ const result = this.execute('INSERT INTO itemDataValues (value) VALUES (?)', [abstract]);
476
+ valueRow = { valueID: result.lastInsertRowid };
477
+ }
478
+ // Check if item already has abstract
479
+ const existing = this.queryOne(`
480
+ SELECT 1 FROM itemData WHERE itemID = ? AND fieldID = ?
481
+ `, [itemID, field.fieldID]);
482
+ if (existing) {
483
+ // Update
484
+ this.execute('UPDATE itemData SET valueID = ? WHERE itemID = ? AND fieldID = ?', [valueRow.valueID, itemID, field.fieldID]);
485
+ }
486
+ else {
487
+ // Insert
488
+ this.execute('INSERT INTO itemData (itemID, fieldID, valueID) VALUES (?, ?, ?)', [itemID, field.fieldID, valueRow.valueID]);
489
+ }
490
+ if (!this.readonly) {
491
+ this.save();
492
+ }
493
+ return true;
494
+ }
495
+ /**
496
+ * Get item notes
497
+ */
498
+ getItemNotes(itemID) {
499
+ return this.queryAll(`
500
+ SELECT in2.itemID, in2.note, in2.title
501
+ FROM itemNotes in2
502
+ WHERE in2.parentItemID = ?
503
+ `, [itemID]);
504
+ }
505
+ /**
506
+ * Add note to item
507
+ */
508
+ addItemNote(parentItemID, noteContent, title = '') {
509
+ // Get parent item's library ID
510
+ const parent = this.queryOne('SELECT libraryID FROM items WHERE itemID = ?', [parentItemID]);
511
+ if (!parent) {
512
+ throw new Error('Parent item not found');
513
+ }
514
+ // Get note item type ID
515
+ const noteType = this.queryOne("SELECT itemTypeID FROM itemTypes WHERE typeName = 'note'");
516
+ // Create item entry
517
+ const key = this.generateKey();
518
+ const now = new Date().toISOString().replace('T', ' ').replace('Z', '');
519
+ const itemResult = this.execute(`
520
+ INSERT INTO items (itemTypeID, dateAdded, dateModified, key, libraryID, version)
521
+ VALUES (?, ?, ?, ?, ?, 0)
522
+ `, [noteType.itemTypeID, now, now, key, parent.libraryID]);
523
+ const itemID = itemResult.lastInsertRowid;
524
+ // Create note entry
525
+ this.execute(`
526
+ INSERT INTO itemNotes (itemID, parentItemID, note, title)
527
+ VALUES (?, ?, ?, ?)
528
+ `, [itemID, parentItemID, noteContent, title]);
529
+ if (!this.readonly) {
530
+ this.save();
531
+ }
532
+ return itemID;
533
+ }
534
+ // ============================================
535
+ // Attachment Operations
536
+ // ============================================
537
+ /**
538
+ * Get attachment path
539
+ */
540
+ getAttachmentPath(itemID) {
541
+ const result = this.queryOne(`
542
+ SELECT path FROM itemAttachments WHERE itemID = ?
543
+ `, [itemID]);
544
+ if (!result?.path) {
545
+ return null;
546
+ }
547
+ // Handle different path formats
548
+ let path = result.path;
549
+ // Storage path format
550
+ if (path.startsWith('storage:')) {
551
+ const storagePath = this.getStoragePath();
552
+ const keyResult = this.queryOne('SELECT key FROM items WHERE itemID = ?', [itemID]);
553
+ path = join(storagePath, keyResult.key, path.replace('storage:', ''));
554
+ }
555
+ return path;
556
+ }
557
+ /**
558
+ * Get PDF attachments for an item
559
+ */
560
+ getPDFAttachments(parentItemID) {
561
+ return this.queryAll(`
562
+ SELECT ia.itemID, ia.path, ia.contentType
563
+ FROM itemAttachments ia
564
+ WHERE ia.parentItemID = ? AND ia.contentType = 'application/pdf'
565
+ `, [parentItemID]);
566
+ }
567
+ /**
568
+ * Get storage directory path
569
+ */
570
+ getStoragePath() {
571
+ // Storage is usually in the same directory as the database
572
+ return join(this.dbPath.replace('zotero.sqlite', ''), 'storage');
573
+ }
574
+ // ============================================
575
+ // Identifier Operations (DOI/ISBN)
576
+ // ============================================
577
+ /**
578
+ * Find item by DOI
579
+ */
580
+ findItemByDOI(doi) {
581
+ // Normalize DOI (remove common prefixes)
582
+ const normalizedDOI = doi.replace(/^https?:\/\/doi\.org\//i, '').replace(/^doi:/i, '').trim();
583
+ const result = this.queryOne(`
584
+ SELECT DISTINCT i.itemID, i.key, i.itemTypeID, i.dateAdded, i.dateModified,
585
+ iv.value as doi
586
+ FROM items i
587
+ JOIN itemData id ON i.itemID = id.itemID
588
+ JOIN itemDataValues iv ON id.valueID = iv.valueID
589
+ JOIN fields f ON id.fieldID = f.fieldID
590
+ WHERE f.fieldName = 'DOI' AND LOWER(iv.value) = LOWER(?)
591
+ `, [normalizedDOI]);
592
+ if (result) {
593
+ return this.getItemDetails(result.itemID);
594
+ }
595
+ return null;
596
+ }
597
+ /**
598
+ * Find item by ISBN
599
+ */
600
+ findItemByISBN(isbn) {
601
+ // Normalize ISBN (remove hyphens and spaces)
602
+ const normalizedISBN = isbn.replace(/[-\s]/g, '').trim();
603
+ const result = this.queryOne(`
604
+ SELECT DISTINCT i.itemID, i.key, i.itemTypeID, i.dateAdded, i.dateModified,
605
+ iv.value as isbn
606
+ FROM items i
607
+ JOIN itemData id ON i.itemID = id.itemID
608
+ JOIN itemDataValues iv ON id.valueID = iv.valueID
609
+ JOIN fields f ON id.fieldID = f.fieldID
610
+ WHERE f.fieldName = 'ISBN' AND REPLACE(REPLACE(iv.value, '-', ''), ' ', '') = ?
611
+ `, [normalizedISBN]);
612
+ if (result) {
613
+ return this.getItemDetails(result.itemID);
614
+ }
615
+ return null;
616
+ }
617
+ /**
618
+ * Find item by any identifier (DOI, ISBN, PMID, arXiv, etc.)
619
+ */
620
+ findItemByIdentifier(identifier, type) {
621
+ const fieldMap = {
622
+ 'doi': 'DOI',
623
+ 'isbn': 'ISBN',
624
+ 'pmid': 'extra', // PMID is usually stored in extra field
625
+ 'arxiv': 'extra',
626
+ 'url': 'url'
627
+ };
628
+ if (type && fieldMap[type.toLowerCase()]) {
629
+ const fieldName = fieldMap[type.toLowerCase()];
630
+ if (type.toLowerCase() === 'doi') {
631
+ return this.findItemByDOI(identifier);
632
+ }
633
+ else if (type.toLowerCase() === 'isbn') {
634
+ return this.findItemByISBN(identifier);
635
+ }
636
+ const result = this.queryOne(`
637
+ SELECT DISTINCT i.itemID, i.key
638
+ FROM items i
639
+ JOIN itemData id ON i.itemID = id.itemID
640
+ JOIN itemDataValues iv ON id.valueID = iv.valueID
641
+ JOIN fields f ON id.fieldID = f.fieldID
642
+ WHERE f.fieldName = ? AND iv.value LIKE ?
643
+ `, [fieldName, `%${identifier}%`]);
644
+ if (result) {
645
+ return this.getItemDetails(result.itemID);
646
+ }
647
+ }
648
+ // Try all identifier types
649
+ const doi = this.findItemByDOI(identifier);
650
+ if (doi)
651
+ return doi;
652
+ const isbn = this.findItemByISBN(identifier);
653
+ if (isbn)
654
+ return isbn;
655
+ return null;
656
+ }
657
+ // ============================================
658
+ // Annotation Operations
659
+ // ============================================
660
+ /**
661
+ * Get all annotations for an item's attachments
662
+ */
663
+ getItemAnnotations(parentItemID) {
664
+ return this.queryAll(`
665
+ SELECT
666
+ ia.itemID as annotationID,
667
+ ia.parentItemID as attachmentID,
668
+ ia.type as annotationType,
669
+ ia.text as annotationText,
670
+ ia.comment as annotationComment,
671
+ ia.color as annotationColor,
672
+ ia.pageLabel,
673
+ ia.sortIndex,
674
+ ia.position,
675
+ i.dateAdded,
676
+ i.dateModified,
677
+ i.key
678
+ FROM itemAnnotations ia
679
+ JOIN items i ON ia.itemID = i.itemID
680
+ JOIN itemAttachments att ON ia.parentItemID = att.itemID
681
+ WHERE att.parentItemID = ?
682
+ ORDER BY ia.sortIndex
683
+ `, [parentItemID]);
684
+ }
685
+ /**
686
+ * Get annotations from a specific attachment
687
+ */
688
+ getAttachmentAnnotations(attachmentID) {
689
+ return this.queryAll(`
690
+ SELECT
691
+ ia.itemID as annotationID,
692
+ ia.parentItemID as attachmentID,
693
+ ia.type as annotationType,
694
+ ia.text as annotationText,
695
+ ia.comment as annotationComment,
696
+ ia.color as annotationColor,
697
+ ia.pageLabel,
698
+ ia.sortIndex,
699
+ ia.position,
700
+ i.dateAdded,
701
+ i.dateModified,
702
+ i.key
703
+ FROM itemAnnotations ia
704
+ JOIN items i ON ia.itemID = i.itemID
705
+ WHERE ia.parentItemID = ?
706
+ ORDER BY ia.sortIndex
707
+ `, [attachmentID]);
708
+ }
709
+ /**
710
+ * Get annotations filtered by type
711
+ */
712
+ getAnnotationsByType(parentItemID, types) {
713
+ const placeholders = types.map(() => '?').join(',');
714
+ return this.queryAll(`
715
+ SELECT
716
+ ia.itemID as annotationID,
717
+ ia.parentItemID as attachmentID,
718
+ ia.type as annotationType,
719
+ ia.text as annotationText,
720
+ ia.comment as annotationComment,
721
+ ia.color as annotationColor,
722
+ ia.pageLabel,
723
+ ia.sortIndex,
724
+ ia.position,
725
+ i.dateAdded,
726
+ i.dateModified
727
+ FROM itemAnnotations ia
728
+ JOIN items i ON ia.itemID = i.itemID
729
+ JOIN itemAttachments att ON ia.parentItemID = att.itemID
730
+ WHERE att.parentItemID = ? AND ia.type IN (${placeholders})
731
+ ORDER BY ia.sortIndex
732
+ `, [parentItemID, ...types]);
733
+ }
734
+ /**
735
+ * Get annotations filtered by color
736
+ */
737
+ getAnnotationsByColor(parentItemID, colors) {
738
+ const placeholders = colors.map(() => '?').join(',');
739
+ return this.queryAll(`
740
+ SELECT
741
+ ia.itemID as annotationID,
742
+ ia.parentItemID as attachmentID,
743
+ ia.type as annotationType,
744
+ ia.text as annotationText,
745
+ ia.comment as annotationComment,
746
+ ia.color as annotationColor,
747
+ ia.pageLabel,
748
+ ia.sortIndex,
749
+ ia.position,
750
+ i.dateAdded,
751
+ i.dateModified
752
+ FROM itemAnnotations ia
753
+ JOIN items i ON ia.itemID = i.itemID
754
+ JOIN itemAttachments att ON ia.parentItemID = att.itemID
755
+ WHERE att.parentItemID = ? AND ia.color IN (${placeholders})
756
+ ORDER BY ia.sortIndex
757
+ `, [parentItemID, ...colors]);
758
+ }
759
+ /**
760
+ * Search annotations by text content
761
+ */
762
+ searchAnnotations(query, parentItemID) {
763
+ const baseQuery = `
764
+ SELECT
765
+ ia.itemID as annotationID,
766
+ ia.parentItemID as attachmentID,
767
+ ia.type as annotationType,
768
+ ia.text as annotationText,
769
+ ia.comment as annotationComment,
770
+ ia.color as annotationColor,
771
+ ia.pageLabel,
772
+ att.parentItemID as itemID,
773
+ i.dateAdded,
774
+ i.dateModified
775
+ FROM itemAnnotations ia
776
+ JOIN items i ON ia.itemID = i.itemID
777
+ JOIN itemAttachments att ON ia.parentItemID = att.itemID
778
+ WHERE (ia.text LIKE ? OR ia.comment LIKE ?)
779
+ `;
780
+ if (parentItemID) {
781
+ return this.queryAll(baseQuery + ' AND att.parentItemID = ? ORDER BY ia.sortIndex', [`%${query}%`, `%${query}%`, parentItemID]);
782
+ }
783
+ return this.queryAll(baseQuery + ' ORDER BY i.dateModified DESC', [`%${query}%`, `%${query}%`]);
784
+ }
785
+ // ============================================
786
+ // Fulltext Search Operations
787
+ // ============================================
788
+ /**
789
+ * Search in Zotero's fulltext index
790
+ * fulltextWords: wordID, word
791
+ * fulltextItemWords: wordID, itemID
792
+ */
793
+ searchFulltext(query, libraryID = 1) {
794
+ // Zotero stores fulltext words in fulltextWords table
795
+ // and word-item associations in fulltextItemWords
796
+ return this.queryAll(`
797
+ SELECT DISTINCT
798
+ i.itemID,
799
+ i.key,
800
+ att.parentItemID,
801
+ fi.indexedChars,
802
+ fi.totalChars,
803
+ fi.indexedPages,
804
+ fi.totalPages
805
+ FROM fulltextItems fi
806
+ JOIN itemAttachments att ON fi.itemID = att.itemID
807
+ JOIN items i ON att.itemID = i.itemID
808
+ JOIN items parent ON att.parentItemID = parent.itemID
809
+ WHERE parent.libraryID = ?
810
+ AND fi.itemID IN (
811
+ SELECT fiw.itemID
812
+ FROM fulltextItemWords fiw
813
+ JOIN fulltextWords fw ON fiw.wordID = fw.wordID
814
+ WHERE fw.word LIKE ?
815
+ )
816
+ ORDER BY parent.dateModified DESC
817
+ `, [libraryID, `%${query.toLowerCase()}%`]);
818
+ }
819
+ /**
820
+ * Get fulltext content for an attachment
821
+ * Note: fulltextContent table may not exist in all Zotero versions
822
+ */
823
+ getFulltextContent(attachmentID) {
824
+ // Try fulltextContent table first (Zotero 7+)
825
+ try {
826
+ const content = this.queryOne(`
827
+ SELECT content FROM fulltextContent WHERE itemID = ?
828
+ `, [attachmentID]);
829
+ if (content?.content) {
830
+ return content.content;
831
+ }
832
+ }
833
+ catch {
834
+ // Table doesn't exist, continue to fallback
835
+ }
836
+ // Fallback: try to reconstruct from fulltextWords and fulltextItemWords
837
+ try {
838
+ const words = this.queryAll(`
839
+ SELECT fw.word
840
+ FROM fulltextItemWords fiw
841
+ JOIN fulltextWords fw ON fiw.wordID = fw.wordID
842
+ WHERE fiw.itemID = ?
843
+ ORDER BY fw.word
844
+ `, [attachmentID]);
845
+ if (words.length > 0) {
846
+ return words.map(w => w.word).join(' ');
847
+ }
848
+ }
849
+ catch {
850
+ // Tables don't exist or query failed
851
+ }
852
+ return null;
853
+ }
854
+ /**
855
+ * Get fulltext index status for an item
856
+ */
857
+ getFulltextStatus(attachmentID) {
858
+ return this.queryOne(`
859
+ SELECT
860
+ itemID,
861
+ indexedChars,
862
+ totalChars,
863
+ indexedPages,
864
+ totalPages,
865
+ synced
866
+ FROM fulltextItems
867
+ WHERE itemID = ?
868
+ `, [attachmentID]);
869
+ }
870
+ /**
871
+ * Advanced fulltext search with context
872
+ */
873
+ searchFulltextWithContext(query, contextLength = 100, libraryID = 1) {
874
+ const results = this.searchFulltext(query, libraryID);
875
+ return results.map(result => {
876
+ const content = this.getFulltextContent(result.itemID);
877
+ let context = '';
878
+ if (content) {
879
+ const lowerContent = content.toLowerCase();
880
+ const lowerQuery = query.toLowerCase();
881
+ const index = lowerContent.indexOf(lowerQuery);
882
+ if (index !== -1) {
883
+ const start = Math.max(0, index - contextLength);
884
+ const end = Math.min(content.length, index + query.length + contextLength);
885
+ context = (start > 0 ? '...' : '') +
886
+ content.substring(start, end) +
887
+ (end < content.length ? '...' : '');
888
+ }
889
+ }
890
+ // Get parent item details
891
+ const parentDetails = result.parentItemID ? this.getItemDetails(result.parentItemID) : null;
892
+ return {
893
+ ...result,
894
+ context,
895
+ parentItem: parentDetails ? {
896
+ itemID: parentDetails.itemID,
897
+ key: parentDetails.key,
898
+ title: parentDetails.title,
899
+ creators: parentDetails.creators
900
+ } : null
901
+ };
902
+ });
903
+ }
904
+ // ============================================
905
+ // Related Items / Similar Items
906
+ // ============================================
907
+ /**
908
+ * Get related items (manually linked)
909
+ */
910
+ getRelatedItems(itemID) {
911
+ // Zotero stores relations in itemRelations table
912
+ const relations = this.queryAll(`
913
+ SELECT
914
+ ir.predicateID,
915
+ ir.object as relatedURI
916
+ FROM itemRelations ir
917
+ WHERE ir.itemID = ?
918
+ `, [itemID]);
919
+ // Extract item keys from URIs and get their details
920
+ const relatedItems = [];
921
+ for (const rel of relations) {
922
+ // URI format: http://zotero.org/users/xxx/items/ITEMKEY
923
+ const match = rel.relatedURI.match(/\/items\/([A-Z0-9]+)$/);
924
+ if (match) {
925
+ const relatedItem = this.getItemByKey(match[1]);
926
+ if (relatedItem) {
927
+ relatedItems.push(this.getItemDetails(relatedItem.itemID));
928
+ }
929
+ }
930
+ }
931
+ return relatedItems;
932
+ }
933
+ /**
934
+ * Find similar items by shared tags
935
+ */
936
+ findSimilarByTags(itemID, minSharedTags = 2) {
937
+ return this.queryAll(`
938
+ SELECT
939
+ i.itemID,
940
+ i.key,
941
+ COUNT(it2.tagID) as sharedTagCount,
942
+ GROUP_CONCAT(t.name, ', ') as sharedTags
943
+ FROM items i
944
+ JOIN itemTags it2 ON i.itemID = it2.itemID
945
+ JOIN tags t ON it2.tagID = t.tagID
946
+ WHERE it2.tagID IN (
947
+ SELECT tagID FROM itemTags WHERE itemID = ?
948
+ )
949
+ AND i.itemID != ?
950
+ GROUP BY i.itemID
951
+ HAVING COUNT(it2.tagID) >= ?
952
+ ORDER BY sharedTagCount DESC
953
+ LIMIT 20
954
+ `, [itemID, itemID, minSharedTags]);
955
+ }
956
+ /**
957
+ * Find similar items by shared creators
958
+ */
959
+ findSimilarByCreators(itemID) {
960
+ return this.queryAll(`
961
+ SELECT DISTINCT
962
+ i.itemID,
963
+ i.key,
964
+ COUNT(ic2.creatorID) as sharedCreatorCount
965
+ FROM items i
966
+ JOIN itemCreators ic2 ON i.itemID = ic2.itemID
967
+ WHERE ic2.creatorID IN (
968
+ SELECT creatorID FROM itemCreators WHERE itemID = ?
969
+ )
970
+ AND i.itemID != ?
971
+ GROUP BY i.itemID
972
+ ORDER BY sharedCreatorCount DESC
973
+ LIMIT 20
974
+ `, [itemID, itemID]);
975
+ }
976
+ /**
977
+ * Find similar items by shared collection
978
+ */
979
+ findSimilarByCollection(itemID) {
980
+ return this.queryAll(`
981
+ SELECT DISTINCT
982
+ i.itemID,
983
+ i.key,
984
+ c.collectionName
985
+ FROM items i
986
+ JOIN collectionItems ci2 ON i.itemID = ci2.itemID
987
+ JOIN collections c ON ci2.collectionID = c.collectionID
988
+ WHERE ci2.collectionID IN (
989
+ SELECT collectionID FROM collectionItems WHERE itemID = ?
990
+ )
991
+ AND i.itemID != ?
992
+ LIMIT 50
993
+ `, [itemID, itemID]);
994
+ }
995
+ // ============================================
996
+ // Utility Methods
997
+ // ============================================
998
+ /**
999
+ * Generate a unique Zotero key
1000
+ *
1001
+ * Zotero uses a specific character set that excludes ambiguous characters:
1002
+ * - No '0' (zero) - confused with 'O'
1003
+ * - No '1' (one) - confused with 'I' or 'L'
1004
+ * - No 'I' - confused with '1' or 'L'
1005
+ * - No 'L' - confused with '1' or 'I'
1006
+ * - No 'O' - confused with '0'
1007
+ *
1008
+ * Valid characters: 23456789ABCDEFGHJKMNPQRSTUVWXYZ
1009
+ */
1010
+ generateKey() {
1011
+ const chars = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
1012
+ let key = '';
1013
+ for (let i = 0; i < 8; i++) {
1014
+ key += chars.charAt(Math.floor(Math.random() * chars.length));
1015
+ }
1016
+ return key;
1017
+ }
1018
+ /**
1019
+ * Execute a raw SQL query
1020
+ */
1021
+ query(sql, params = []) {
1022
+ return this.queryAll(sql, params);
1023
+ }
1024
+ /**
1025
+ * Execute a raw SQL statement
1026
+ */
1027
+ run(sql, params = []) {
1028
+ return this.execute(sql, params);
1029
+ }
1030
+ }
1031
+ //# sourceMappingURL=database.js.map