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