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.
- package/LICENSE +21 -0
- package/README-en.md +324 -0
- package/README.md +324 -0
- package/dist/database.d.ts +280 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/database.js +1031 -0
- package/dist/database.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +504 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf.d.ts +57 -0
- package/dist/pdf.d.ts.map +1 -0
- package/dist/pdf.js +143 -0
- package/dist/pdf.js.map +1 -0
- package/dist/tools.d.ts +396 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +425 -0
- package/dist/tools.js.map +1 -0
- package/package.json +50 -0
- package/server.json +20 -0
- package/src/database.ts +1225 -0
- package/src/index.ts +630 -0
- package/src/pdf.ts +184 -0
- package/src/tools.ts +489 -0
- package/tsconfig.json +20 -0
package/src/database.ts
ADDED
|
@@ -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
|
+
}
|