zotero-bridge 1.0.3 → 1.1.0
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 +1 -1
- package/README.md +323 -153
- package/dist/database.d.ts +53 -0
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +184 -11
- package/dist/database.js.map +1 -1
- package/package.json +1 -1
- package/src/database.ts +200 -13
package/src/database.ts
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import initSqlJs, { Database as SqlJsDatabase } from 'sql.js';
|
|
13
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, statSync } from 'fs';
|
|
14
14
|
import { homedir } from 'os';
|
|
15
|
-
import { join } from 'path';
|
|
15
|
+
import { join, dirname } from 'path';
|
|
16
|
+
import { execSync } from 'child_process';
|
|
16
17
|
|
|
17
18
|
export interface ZoteroItem {
|
|
18
19
|
itemID: number;
|
|
@@ -48,6 +49,8 @@ export class ZoteroDatabase {
|
|
|
48
49
|
private dbPath: string;
|
|
49
50
|
private readonly: boolean;
|
|
50
51
|
private SQL: any = null;
|
|
52
|
+
private backupPath: string | null = null;
|
|
53
|
+
private hasUnsavedChanges: boolean = false;
|
|
51
54
|
|
|
52
55
|
constructor(dbPath?: string, readonly: boolean = false) {
|
|
53
56
|
this.dbPath = dbPath || this.findDefaultZoteroDB();
|
|
@@ -94,6 +97,58 @@ export class ZoteroDatabase {
|
|
|
94
97
|
return join(home, 'Zotero', 'zotero.sqlite');
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Check if Zotero is currently running
|
|
102
|
+
*/
|
|
103
|
+
private isZoteroRunning(): boolean {
|
|
104
|
+
try {
|
|
105
|
+
if (process.platform === 'win32') {
|
|
106
|
+
const result = execSync('tasklist /FI "IMAGENAME eq zotero.exe" /NH', { encoding: 'utf8' });
|
|
107
|
+
return result.toLowerCase().includes('zotero.exe');
|
|
108
|
+
} else if (process.platform === 'darwin') {
|
|
109
|
+
const result = execSync('pgrep -x Zotero', { encoding: 'utf8' });
|
|
110
|
+
return result.trim().length > 0;
|
|
111
|
+
} else {
|
|
112
|
+
const result = execSync('pgrep -x zotero', { encoding: 'utf8' });
|
|
113
|
+
return result.trim().length > 0;
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if WAL files exist (indicates active Zotero session)
|
|
122
|
+
*/
|
|
123
|
+
private hasWALFiles(): boolean {
|
|
124
|
+
const walPath = this.dbPath + '-wal';
|
|
125
|
+
const shmPath = this.dbPath + '-shm';
|
|
126
|
+
return existsSync(walPath) || existsSync(shmPath);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Create a backup of the database before modification
|
|
131
|
+
*/
|
|
132
|
+
private createBackup(): string {
|
|
133
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
134
|
+
const backupPath = this.dbPath.replace('.sqlite', `.backup-${timestamp}.sqlite`);
|
|
135
|
+
copyFileSync(this.dbPath, backupPath);
|
|
136
|
+
return backupPath;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Verify database integrity
|
|
141
|
+
*/
|
|
142
|
+
private verifyIntegrity(): boolean {
|
|
143
|
+
if (!this.db) return false;
|
|
144
|
+
try {
|
|
145
|
+
const result = this.queryOne('PRAGMA integrity_check');
|
|
146
|
+
return result && result.integrity_check === 'ok';
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
97
152
|
/**
|
|
98
153
|
* Connect to the Zotero database
|
|
99
154
|
*/
|
|
@@ -106,6 +161,18 @@ export class ZoteroDatabase {
|
|
|
106
161
|
throw new Error(`Zotero database not found at: ${this.dbPath}`);
|
|
107
162
|
}
|
|
108
163
|
|
|
164
|
+
// WARNING: Check if Zotero is running (write operations are dangerous)
|
|
165
|
+
if (!this.readonly && this.isZoteroRunning()) {
|
|
166
|
+
console.warn('⚠️ WARNING: Zotero is currently running. Write operations may corrupt the database!');
|
|
167
|
+
console.warn(' Please close Zotero before making changes, or use readonly mode.');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// WARNING: Check for WAL files
|
|
171
|
+
if (!this.readonly && this.hasWALFiles()) {
|
|
172
|
+
console.warn('⚠️ WARNING: WAL files detected. Zotero may be running or was not closed properly.');
|
|
173
|
+
console.warn(' Writing to the database may cause corruption!');
|
|
174
|
+
}
|
|
175
|
+
|
|
109
176
|
// Initialize sql.js
|
|
110
177
|
this.SQL = await initSqlJs();
|
|
111
178
|
|
|
@@ -116,19 +183,57 @@ export class ZoteroDatabase {
|
|
|
116
183
|
|
|
117
184
|
// Enable foreign keys
|
|
118
185
|
db.run('PRAGMA foreign_keys = ON');
|
|
186
|
+
|
|
187
|
+
// Verify database integrity on connect
|
|
188
|
+
if (!this.verifyIntegrity()) {
|
|
189
|
+
console.error('❌ ERROR: Database integrity check failed! The database may already be corrupted.');
|
|
190
|
+
}
|
|
119
191
|
}
|
|
120
192
|
|
|
121
193
|
/**
|
|
122
194
|
* Save changes to the database file
|
|
195
|
+
* WARNING: This can corrupt the database if Zotero is running!
|
|
123
196
|
*/
|
|
124
197
|
save(): void {
|
|
125
|
-
if (!this.db || this.readonly) {
|
|
198
|
+
if (!this.db || this.readonly || !this.hasUnsavedChanges) {
|
|
126
199
|
return;
|
|
127
200
|
}
|
|
201
|
+
|
|
202
|
+
// Safety check before save
|
|
203
|
+
if (this.isZoteroRunning()) {
|
|
204
|
+
throw new Error('Cannot save: Zotero is currently running. Please close Zotero first to avoid database corruption.');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (this.hasWALFiles()) {
|
|
208
|
+
throw new Error('Cannot save: WAL files detected. Please close Zotero and wait for WAL files to be cleaned up.');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Create backup before saving
|
|
212
|
+
if (!this.backupPath) {
|
|
213
|
+
this.backupPath = this.createBackup();
|
|
214
|
+
console.log(`📁 Backup created at: ${this.backupPath}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Verify integrity before save (only check a few important tables to speed up)
|
|
218
|
+
try {
|
|
219
|
+
this.queryOne("SELECT 1 FROM items LIMIT 1");
|
|
220
|
+
this.queryOne("SELECT 1 FROM itemData LIMIT 1");
|
|
221
|
+
} catch (e) {
|
|
222
|
+
throw new Error('Cannot save: Basic database check failed. Database may be corrupted.');
|
|
223
|
+
}
|
|
128
224
|
|
|
129
225
|
const data = this.db.export();
|
|
130
226
|
const buffer = Buffer.from(data);
|
|
131
227
|
writeFileSync(this.dbPath, buffer);
|
|
228
|
+
this.hasUnsavedChanges = false;
|
|
229
|
+
console.log('✅ Database saved successfully.');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Mark that changes have been made (for tracking unsaved changes)
|
|
234
|
+
*/
|
|
235
|
+
private markDirty(): void {
|
|
236
|
+
this.hasUnsavedChanges = true;
|
|
132
237
|
}
|
|
133
238
|
|
|
134
239
|
/**
|
|
@@ -136,8 +241,12 @@ export class ZoteroDatabase {
|
|
|
136
241
|
*/
|
|
137
242
|
disconnect(): void {
|
|
138
243
|
if (this.db) {
|
|
139
|
-
if (!this.readonly) {
|
|
140
|
-
|
|
244
|
+
if (!this.readonly && this.hasUnsavedChanges) {
|
|
245
|
+
try {
|
|
246
|
+
this.save();
|
|
247
|
+
} catch (e) {
|
|
248
|
+
console.error('Failed to save on disconnect:', e);
|
|
249
|
+
}
|
|
141
250
|
}
|
|
142
251
|
this.db.close();
|
|
143
252
|
this.db = null;
|
|
@@ -161,6 +270,34 @@ export class ZoteroDatabase {
|
|
|
161
270
|
return this.dbPath;
|
|
162
271
|
}
|
|
163
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Get current timestamp in Zotero SQL format
|
|
275
|
+
*/
|
|
276
|
+
private getCurrentTimestamp(): string {
|
|
277
|
+
return new Date().toISOString().replace('T', ' ').replace('Z', '').slice(0, -4);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Update item metadata after modification (dateModified, version, synced)
|
|
282
|
+
* This is CRITICAL for Zotero compatibility!
|
|
283
|
+
*
|
|
284
|
+
* According to Zotero's official code:
|
|
285
|
+
* - dateModified must be updated on every change
|
|
286
|
+
* - version must be incremented
|
|
287
|
+
* - synced must be set to 0 (false) to indicate local change
|
|
288
|
+
*/
|
|
289
|
+
private updateItemMetadata(itemID: number): void {
|
|
290
|
+
const timestamp = this.getCurrentTimestamp();
|
|
291
|
+
this.db!.run(`
|
|
292
|
+
UPDATE items
|
|
293
|
+
SET dateModified = ?,
|
|
294
|
+
clientDateModified = ?,
|
|
295
|
+
version = version + 1,
|
|
296
|
+
synced = 0
|
|
297
|
+
WHERE itemID = ?
|
|
298
|
+
`, [timestamp, timestamp, itemID]);
|
|
299
|
+
}
|
|
300
|
+
|
|
164
301
|
/**
|
|
165
302
|
* Execute a query and return all results
|
|
166
303
|
*/
|
|
@@ -198,7 +335,13 @@ export class ZoteroDatabase {
|
|
|
198
335
|
throw new Error('Database not connected');
|
|
199
336
|
}
|
|
200
337
|
|
|
338
|
+
// Safety check before modifying database
|
|
339
|
+
if (this.isZoteroRunning()) {
|
|
340
|
+
throw new Error('Cannot modify database: Zotero is currently running. Please close Zotero first.');
|
|
341
|
+
}
|
|
342
|
+
|
|
201
343
|
this.db.run(sql, params);
|
|
344
|
+
this.markDirty(); // Mark that we have unsaved changes
|
|
202
345
|
|
|
203
346
|
const changes = this.db.getRowsModified();
|
|
204
347
|
const lastId = this.queryOne('SELECT last_insert_rowid() as id');
|
|
@@ -373,6 +516,8 @@ export class ZoteroDatabase {
|
|
|
373
516
|
|
|
374
517
|
/**
|
|
375
518
|
* Add tag to item
|
|
519
|
+
*
|
|
520
|
+
* Following Zotero's pattern: modifying item tags should update item metadata
|
|
376
521
|
*/
|
|
377
522
|
addTagToItem(itemID: number, tagName: string, type: number = 0): boolean {
|
|
378
523
|
// Get or create tag
|
|
@@ -389,6 +534,9 @@ export class ZoteroDatabase {
|
|
|
389
534
|
|
|
390
535
|
this.execute('INSERT INTO itemTags (itemID, tagID) VALUES (?, ?)', [itemID, tagID]);
|
|
391
536
|
|
|
537
|
+
// CRITICAL: Update item metadata for Zotero compatibility
|
|
538
|
+
this.updateItemMetadata(itemID);
|
|
539
|
+
|
|
392
540
|
if (!this.readonly) {
|
|
393
541
|
this.save();
|
|
394
542
|
}
|
|
@@ -407,6 +555,11 @@ export class ZoteroDatabase {
|
|
|
407
555
|
|
|
408
556
|
const result = this.execute('DELETE FROM itemTags WHERE itemID = ? AND tagID = ?', [itemID, tag.tagID]);
|
|
409
557
|
|
|
558
|
+
if (result.changes > 0) {
|
|
559
|
+
// CRITICAL: Update item metadata for Zotero compatibility
|
|
560
|
+
this.updateItemMetadata(itemID);
|
|
561
|
+
}
|
|
562
|
+
|
|
410
563
|
if (!this.readonly && result.changes > 0) {
|
|
411
564
|
this.save();
|
|
412
565
|
}
|
|
@@ -446,6 +599,8 @@ export class ZoteroDatabase {
|
|
|
446
599
|
|
|
447
600
|
/**
|
|
448
601
|
* Add item to collection
|
|
602
|
+
*
|
|
603
|
+
* Following Zotero's pattern: collection membership changes update item metadata
|
|
449
604
|
*/
|
|
450
605
|
addItemToCollection(itemID: number, collectionID: number): boolean {
|
|
451
606
|
// Check if already in collection
|
|
@@ -459,6 +614,9 @@ export class ZoteroDatabase {
|
|
|
459
614
|
|
|
460
615
|
this.execute('INSERT INTO collectionItems (itemID, collectionID) VALUES (?, ?)', [itemID, collectionID]);
|
|
461
616
|
|
|
617
|
+
// CRITICAL: Update item metadata for Zotero compatibility
|
|
618
|
+
this.updateItemMetadata(itemID);
|
|
619
|
+
|
|
462
620
|
if (!this.readonly) {
|
|
463
621
|
this.save();
|
|
464
622
|
}
|
|
@@ -468,10 +626,17 @@ export class ZoteroDatabase {
|
|
|
468
626
|
|
|
469
627
|
/**
|
|
470
628
|
* Remove item from collection
|
|
629
|
+
*
|
|
630
|
+
* Following Zotero's pattern: collection membership changes update item metadata
|
|
471
631
|
*/
|
|
472
632
|
removeItemFromCollection(itemID: number, collectionID: number): boolean {
|
|
473
633
|
const result = this.execute('DELETE FROM collectionItems WHERE itemID = ? AND collectionID = ?', [itemID, collectionID]);
|
|
474
634
|
|
|
635
|
+
if (result.changes > 0) {
|
|
636
|
+
// CRITICAL: Update item metadata for Zotero compatibility
|
|
637
|
+
this.updateItemMetadata(itemID);
|
|
638
|
+
}
|
|
639
|
+
|
|
475
640
|
if (!this.readonly && result.changes > 0) {
|
|
476
641
|
this.save();
|
|
477
642
|
}
|
|
@@ -587,6 +752,11 @@ export class ZoteroDatabase {
|
|
|
587
752
|
|
|
588
753
|
/**
|
|
589
754
|
* Set item abstract
|
|
755
|
+
*
|
|
756
|
+
* Following Zotero's official pattern for modifying item data:
|
|
757
|
+
* 1. Get or create value in itemDataValues
|
|
758
|
+
* 2. Insert or update itemData
|
|
759
|
+
* 3. Update item metadata (dateModified, version, synced)
|
|
590
760
|
*/
|
|
591
761
|
setItemAbstract(itemID: number, abstract: string): boolean {
|
|
592
762
|
// Get abstractNote field ID
|
|
@@ -595,7 +765,7 @@ export class ZoteroDatabase {
|
|
|
595
765
|
return false;
|
|
596
766
|
}
|
|
597
767
|
|
|
598
|
-
// Get or create value
|
|
768
|
+
// Get or create value (Zotero stores unique values in itemDataValues)
|
|
599
769
|
let valueRow = this.queryOne('SELECT valueID FROM itemDataValues WHERE value = ?', [abstract]);
|
|
600
770
|
|
|
601
771
|
if (!valueRow) {
|
|
@@ -618,13 +788,15 @@ export class ZoteroDatabase {
|
|
|
618
788
|
[itemID, field.fieldID, valueRow.valueID]);
|
|
619
789
|
}
|
|
620
790
|
|
|
791
|
+
// CRITICAL: Update item metadata for Zotero compatibility
|
|
792
|
+
this.updateItemMetadata(itemID);
|
|
793
|
+
|
|
621
794
|
if (!this.readonly) {
|
|
622
795
|
this.save();
|
|
623
796
|
}
|
|
624
797
|
|
|
625
798
|
return true;
|
|
626
799
|
}
|
|
627
|
-
|
|
628
800
|
/**
|
|
629
801
|
* Get item notes
|
|
630
802
|
*/
|
|
@@ -638,6 +810,11 @@ export class ZoteroDatabase {
|
|
|
638
810
|
|
|
639
811
|
/**
|
|
640
812
|
* Add note to item
|
|
813
|
+
*
|
|
814
|
+
* Following Zotero's official pattern for creating notes:
|
|
815
|
+
* 1. Create item with note type
|
|
816
|
+
* 2. Create itemNotes entry
|
|
817
|
+
* 3. Update parent item's metadata (as adding a child note is a modification)
|
|
641
818
|
*/
|
|
642
819
|
addItemNote(parentItemID: number, noteContent: string, title: string = ''): number {
|
|
643
820
|
// Get parent item's library ID
|
|
@@ -649,22 +826,32 @@ export class ZoteroDatabase {
|
|
|
649
826
|
// Get note item type ID
|
|
650
827
|
const noteType = this.queryOne("SELECT itemTypeID FROM itemTypes WHERE typeName = 'note'");
|
|
651
828
|
|
|
652
|
-
// Create item entry
|
|
829
|
+
// Create item entry with proper timestamp format
|
|
653
830
|
const key = this.generateKey();
|
|
654
|
-
const now =
|
|
831
|
+
const now = this.getCurrentTimestamp();
|
|
655
832
|
|
|
833
|
+
// Note: synced=0 for new local items, clientDateModified is also set
|
|
656
834
|
const itemResult = this.execute(`
|
|
657
|
-
INSERT INTO items (itemTypeID, dateAdded, dateModified, key, libraryID, version)
|
|
658
|
-
VALUES (?, ?, ?, ?, ?, 0)
|
|
659
|
-
`, [noteType.itemTypeID, now, now, key, parent.libraryID]);
|
|
835
|
+
INSERT INTO items (itemTypeID, dateAdded, dateModified, clientDateModified, key, libraryID, version, synced)
|
|
836
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, 0)
|
|
837
|
+
`, [noteType.itemTypeID, now, now, now, key, parent.libraryID]);
|
|
660
838
|
|
|
661
839
|
const itemID = itemResult.lastInsertRowid;
|
|
662
840
|
|
|
841
|
+
// Wrap note content in Zotero's expected format if not already
|
|
842
|
+
let formattedNote = noteContent;
|
|
843
|
+
if (!noteContent.startsWith('<div class="zotero-note')) {
|
|
844
|
+
formattedNote = `<div class="zotero-note znv1">${noteContent}</div>`;
|
|
845
|
+
}
|
|
846
|
+
|
|
663
847
|
// Create note entry
|
|
664
848
|
this.execute(`
|
|
665
849
|
INSERT INTO itemNotes (itemID, parentItemID, note, title)
|
|
666
850
|
VALUES (?, ?, ?, ?)
|
|
667
|
-
`, [itemID, parentItemID,
|
|
851
|
+
`, [itemID, parentItemID, formattedNote, title]);
|
|
852
|
+
|
|
853
|
+
// CRITICAL: Update parent item metadata (adding a note is considered a modification)
|
|
854
|
+
this.updateItemMetadata(parentItemID);
|
|
668
855
|
|
|
669
856
|
if (!this.readonly) {
|
|
670
857
|
this.save();
|