@chronicle.app/zotero 0.3.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.
@@ -0,0 +1,551 @@
1
+ import { SystemInfo } from '@chronicle.app/etl';
2
+ import { SqliteExtractor, timeRangeConditions, iterateRows } from '@chronicle.app/etl-sqlite';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { constants as fsConstants, copyFileSync, readFileSync, rmSync, statSync } from 'node:fs';
6
+ import { homedir, tmpdir } from 'node:os';
7
+ import { dirname, isAbsolute, join } from 'node:path';
8
+ import { z } from 'zod';
9
+ import ZoteroTransformer from './ZoteroTransformer.js';
10
+ /** Zotero's UTC `YYYY-MM-DD HH:MM:SS` form, so string comparison against `dateModified` is correct. */
11
+ function toSqliteTimestamp(d) {
12
+ return d.toISOString().slice(0, 19).replace('T', ' ');
13
+ }
14
+ function isSqliteBusy(err) {
15
+ return (typeof err === 'object' &&
16
+ err !== null &&
17
+ // node:sqlite reports the SQLite result code as `errcode` (possibly an
18
+ // extended code, whose low byte is the primary code); 5 is SQLITE_BUSY.
19
+ (err.errcode ?? 0) % 256 === 5);
20
+ }
21
+ /**
22
+ * A local Zotero library — works (references), the highlights and notes made
23
+ * on their attachments, standalone attachments filed without a parent work,
24
+ * and note items written on a work or standing alone. Reads `zotero.sqlite`
25
+ * directly; the user library only (group libraries are out of scope),
26
+ * excluding anything in the trash.
27
+ */
28
+ export class ZoteroExtractor extends SqliteExtractor {
29
+ static source = 'zotero';
30
+ static description = 'Works, highlights, notes, and reading activity';
31
+ static delivery = 'local';
32
+ static strategy = 'app-db';
33
+ static recordTypes = ['works', 'annotations', 'attachments', 'notes'];
34
+ static default = true;
35
+ static defaultTransformer = ZoteroTransformer;
36
+ // Re-reads the library's current state on every run — fields, tags,
37
+ // collections, and creators are all mutable in place — so attributes are
38
+ // sighted at read time rather than back-dated to dateAdded/dateModified.
39
+ // See Extractor.temporality.
40
+ static temporality = 'snapshot';
41
+ static schema = SqliteExtractor.schema.omit({ input: true }).extend({
42
+ input: z
43
+ .string()
44
+ .describe('Path to the Zotero data directory (contains zotero.sqlite and storage/)')
45
+ .default(join(homedir(), 'Zotero')),
46
+ });
47
+ dataDir;
48
+ localUserKey;
49
+ library;
50
+ machine;
51
+ dbMtime;
52
+ extractedCount = 0;
53
+ /** Set when Zotero's lock forced a filesystem-level clone; cleaned up in teardown(). */
54
+ tempDbPath;
55
+ /** Item keys of child attachments whose carrier (path or content hash) is shared with a different parent work; rebuilt per extract() run. */
56
+ flaggedAttachmentKeys;
57
+ /**
58
+ * The item's 8-char Zotero key. Every record type (work, annotation,
59
+ * attachment, note) is an `items` row, and the key is unique across all of
60
+ * them within a library — the same field each transformer keyset uses.
61
+ */
62
+ keyOf(record) {
63
+ return record.data.key ?? null;
64
+ }
65
+ async setup() {
66
+ const { input } = this.config;
67
+ this.dataDir = input.endsWith('.sqlite') ? dirname(input) : input;
68
+ const dbPath = input.endsWith('.sqlite') ? input : join(this.dataDir, 'zotero.sqlite');
69
+ // From the ORIGINAL file, even when the busy fallback below reads a
70
+ // freshly-made copy instead — the copy's mtime is just "now" and would
71
+ // defeat the byte-identical-on-unchanged-library property of asOfTime().
72
+ this.dbMtime = statSync(dbPath).mtime;
73
+ // Zotero uses journal_mode=delete, so a plain readonly open reads a
74
+ // consistent snapshot without contending with a running Zotero instance.
75
+ // Never opened read-write: the library must never be disturbed.
76
+ this.db = new DatabaseSync(dbPath, { readOnly: true });
77
+ try {
78
+ this.localUserKey = this.readLocalUserKey(dbPath);
79
+ }
80
+ catch (error) {
81
+ if (!isSqliteBusy(error))
82
+ throw error;
83
+ // Zotero holds the database in exclusive locking mode for its entire
84
+ // run, so a readonly open can never read while it's open (the open
85
+ // itself acquires no lock — the first real query is where this
86
+ // surfaces). Fall back to a filesystem-level clone: with
87
+ // journal_mode=delete the file is consistent except during the brief
88
+ // window of an active write, so a clone of the closed-journal db
89
+ // (instant on APFS via COPYFILE_FICLONE, a plain copy elsewhere) is
90
+ // consistent in practice. The clone is disposable, removed in
91
+ // teardown().
92
+ this.db.close();
93
+ this.tempDbPath = join(tmpdir(), `zotero-${randomUUID()}.sqlite`);
94
+ copyFileSync(dbPath, this.tempDbPath, fsConstants.COPYFILE_FICLONE);
95
+ this.db = new DatabaseSync(this.tempDbPath, { readOnly: true });
96
+ this.localUserKey = this.readLocalUserKey(this.tempDbPath);
97
+ }
98
+ // The zotero.org userID is globally stable and identical on every machine
99
+ // syncing this library, so it wins as the scope handle when present; a
100
+ // library that has never synced has no userID, so the install-local
101
+ // localUserKey is the only handle available and becomes the scope.
102
+ this.library = this.readUserID() ?? this.localUserKey;
103
+ this.machine = (await SystemInfo.getInstance()).getMachineName();
104
+ }
105
+ readLocalUserKey(dbPath) {
106
+ const row = this.db.prepare(`SELECT value FROM settings WHERE setting = 'account' AND key = 'localUserKey'`).get();
107
+ if (!row?.value) {
108
+ throw new Error(`No settings.account.localUserKey in ${dbPath} — is this a Zotero data directory? (the key exists even without a zotero.org account)`);
109
+ }
110
+ return row.value;
111
+ }
112
+ readUserID() {
113
+ const row = this.db.prepare(`SELECT value FROM settings WHERE setting = 'account' AND key = 'userID'`).get();
114
+ return row?.value === null || row?.value === undefined ? undefined : String(row.value);
115
+ }
116
+ async teardown() {
117
+ await super.teardown();
118
+ if (this.tempDbPath) {
119
+ rmSync(this.tempDbPath, { force: true });
120
+ this.tempDbPath = undefined;
121
+ }
122
+ }
123
+ // The DB file's own mtime, not the run start: an unchanged library re-reads
124
+ // the same "as of" time on every run, so the emitted records are
125
+ // byte-identical and the store's ingest is a no-op; an edited library moves
126
+ // the mtime forward, and every record is genuinely re-sighted then.
127
+ asOfTime() {
128
+ return this.dbMtime;
129
+ }
130
+ async *extract() {
131
+ if (!this.db)
132
+ throw new Error('Database not initialized');
133
+ this.extractedCount = 0;
134
+ this.flaggedAttachmentKeys = this.buildFlaggedAttachmentKeys();
135
+ const collections = this.loadCollections();
136
+ yield* this.iterateWorks(collections);
137
+ yield* this.iterateAnnotations();
138
+ yield* this.iterateOrphanAttachments(collections);
139
+ yield* this.iterateNotes();
140
+ }
141
+ /** The since/until window over a `dateModified` column, as ` AND …` terms appended to each stream's WHERE. */
142
+ sinceUntilFilter(column) {
143
+ const { conditions, values } = timeRangeConditions(column, this.config, {
144
+ convert: toSqliteTimestamp,
145
+ sinceOp: '>=',
146
+ untilOp: '<=',
147
+ });
148
+ return {
149
+ clause: conditions.map(c => ` AND ${c}`).join(''),
150
+ params: values,
151
+ };
152
+ }
153
+ *iterateWorks(collections) {
154
+ const { clause, params } = this.sinceUntilFilter('i.dateModified');
155
+ const sql = `
156
+ SELECT i.itemID AS itemID, i.key AS key, i.dateAdded AS dateAdded, i.dateModified AS dateModified,
157
+ it.typeName AS itemType
158
+ FROM items i
159
+ JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
160
+ JOIN libraries l ON l.libraryID = i.libraryID
161
+ WHERE l.type = 'user'
162
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
163
+ AND it.typeName NOT IN ('attachment', 'annotation', 'note')${clause}
164
+ ORDER BY i.dateModified DESC`;
165
+ for (const row of iterateRows(this.db.prepare(sql), ...params)) {
166
+ if (this.shouldStopExtracting(this.extractedCount))
167
+ return;
168
+ const data = {
169
+ key: row.key,
170
+ itemType: row.itemType,
171
+ dateAdded: row.dateAdded,
172
+ dateModified: row.dateModified,
173
+ fields: this.loadFields(row.itemID),
174
+ creators: this.loadCreators(row.itemID),
175
+ collections: this.getItemCollections(row.itemID, collections),
176
+ tags: this.loadTags(row.itemID),
177
+ attachments: this.loadWorkAttachments(row.itemID),
178
+ };
179
+ this.extractedCount++;
180
+ yield this.createRecord(data, {
181
+ recordType: 'works',
182
+ localUserKey: this.localUserKey,
183
+ library: this.library,
184
+ machine: this.machine,
185
+ });
186
+ }
187
+ }
188
+ *iterateAnnotations() {
189
+ const { clause, params } = this.sinceUntilFilter('i.dateModified');
190
+ const sql = `
191
+ SELECT i.itemID AS itemID, i.key AS key, i.dateAdded AS dateAdded, i.dateModified AS dateModified,
192
+ a.type AS type, a.authorName AS authorName, a.text AS text, a.comment AS comment,
193
+ a.color AS color, a.pageLabel AS pageLabel, a.sortIndex AS sortIndex, a.position AS position,
194
+ a.isExternal AS isExternal,
195
+ ai.itemID AS attItemID, ai.key AS attKey, ai.dateAdded AS attDateAdded,
196
+ att.parentItemID AS workItemID, att.linkMode AS attLinkMode, att.contentType AS attContentType,
197
+ att.path AS attPath, att.lastRead AS attLastRead
198
+ FROM itemAnnotations a
199
+ JOIN items i ON i.itemID = a.itemID
200
+ JOIN libraries l ON l.libraryID = i.libraryID
201
+ JOIN itemAttachments att ON att.itemID = a.parentItemID
202
+ JOIN items ai ON ai.itemID = att.itemID
203
+ WHERE l.type = 'user'
204
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
205
+ AND ai.itemID NOT IN (SELECT itemID FROM deletedItems)
206
+ AND (att.parentItemID IS NULL OR att.parentItemID NOT IN (SELECT itemID FROM deletedItems))${clause}
207
+ ORDER BY i.dateModified DESC`;
208
+ for (const row of iterateRows(this.db.prepare(sql), ...params)) {
209
+ if (this.shouldStopExtracting(this.extractedCount))
210
+ return;
211
+ const attachment = this.buildAttachment({
212
+ itemID: row.attItemID,
213
+ key: row.attKey,
214
+ dateAdded: row.attDateAdded,
215
+ parentItemID: row.workItemID,
216
+ linkMode: row.attLinkMode,
217
+ contentType: row.attContentType,
218
+ path: row.attPath,
219
+ lastRead: row.attLastRead,
220
+ });
221
+ const work = this.loadWorkRef(row.workItemID);
222
+ const data = {
223
+ key: row.key,
224
+ type: row.type,
225
+ text: row.text,
226
+ comment: row.comment,
227
+ color: row.color,
228
+ pageLabel: row.pageLabel,
229
+ sortIndex: row.sortIndex,
230
+ position: row.position,
231
+ isExternal: row.isExternal,
232
+ authorName: row.authorName,
233
+ dateAdded: row.dateAdded,
234
+ dateModified: row.dateModified,
235
+ attachment,
236
+ ...(work ? { work } : {}),
237
+ };
238
+ this.extractedCount++;
239
+ yield this.createRecord(data, {
240
+ recordType: 'annotations',
241
+ localUserKey: this.localUserKey,
242
+ library: this.library,
243
+ machine: this.machine,
244
+ });
245
+ }
246
+ }
247
+ *iterateOrphanAttachments(collections) {
248
+ const { clause, params } = this.sinceUntilFilter('i.dateModified');
249
+ const sql = `
250
+ SELECT i.itemID AS itemID, i.key AS key, i.dateAdded AS dateAdded, i.dateModified AS dateModified,
251
+ att.parentItemID AS parentItemID, att.linkMode AS linkMode, att.contentType AS contentType,
252
+ att.path AS path, att.lastRead AS lastRead
253
+ FROM itemAttachments att
254
+ JOIN items i ON i.itemID = att.itemID
255
+ JOIN libraries l ON l.libraryID = i.libraryID
256
+ WHERE l.type = 'user'
257
+ AND att.parentItemID IS NULL
258
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)${clause}
259
+ ORDER BY i.dateModified DESC`;
260
+ for (const row of iterateRows(this.db.prepare(sql), ...params)) {
261
+ if (this.shouldStopExtracting(this.extractedCount))
262
+ return;
263
+ const data = {
264
+ ...this.buildAttachment(row),
265
+ dateModified: row.dateModified,
266
+ tags: this.loadTags(row.itemID),
267
+ collections: this.getItemCollections(row.itemID, collections),
268
+ };
269
+ this.extractedCount++;
270
+ yield this.createRecord(data, {
271
+ recordType: 'attachments',
272
+ localUserKey: this.localUserKey,
273
+ library: this.library,
274
+ machine: this.machine,
275
+ });
276
+ }
277
+ }
278
+ *iterateNotes() {
279
+ const { clause, params } = this.sinceUntilFilter('i.dateModified');
280
+ const sql = `
281
+ SELECT i.itemID AS itemID, i.key AS key, i.dateAdded AS dateAdded, i.dateModified AS dateModified,
282
+ n.parentItemID AS parentItemID, n.note AS note, n.title AS title
283
+ FROM items i
284
+ JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
285
+ JOIN itemNotes n ON n.itemID = i.itemID
286
+ JOIN libraries l ON l.libraryID = i.libraryID
287
+ WHERE l.type = 'user'
288
+ AND it.typeName = 'note'
289
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
290
+ AND (n.parentItemID IS NULL OR n.parentItemID NOT IN (SELECT itemID FROM deletedItems))${clause}
291
+ ORDER BY i.dateModified DESC`;
292
+ for (const row of iterateRows(this.db.prepare(sql), ...params)) {
293
+ if (this.shouldStopExtracting(this.extractedCount))
294
+ return;
295
+ const work = this.loadWorkRef(row.parentItemID);
296
+ const data = {
297
+ key: row.key,
298
+ title: row.title,
299
+ note: row.note,
300
+ dateAdded: row.dateAdded,
301
+ dateModified: row.dateModified,
302
+ ...(work ? { work } : {}),
303
+ };
304
+ this.extractedCount++;
305
+ yield this.createRecord(data, {
306
+ recordType: 'notes',
307
+ localUserKey: this.localUserKey,
308
+ library: this.library,
309
+ machine: this.machine,
310
+ });
311
+ }
312
+ }
313
+ buildAttachment(row) {
314
+ const fields = this.loadFields(row.itemID);
315
+ const attachment = {
316
+ key: row.key,
317
+ linkMode: row.linkMode,
318
+ contentType: row.contentType,
319
+ path: row.path,
320
+ dateAdded: row.dateAdded,
321
+ };
322
+ const resolvedPath = this.resolveAttachmentPath(row.key, row.linkMode, row.path);
323
+ if (resolvedPath)
324
+ attachment.resolvedPath = resolvedPath;
325
+ if (fields.title)
326
+ attachment.title = fields.title;
327
+ if (fields.url)
328
+ attachment.url = fields.url;
329
+ if (row.lastRead !== null)
330
+ attachment.lastRead = row.lastRead;
331
+ // Orphans never carry the flag: flaggedAttachmentKeys only ever contains
332
+ // keys collected from children (see buildFlaggedAttachmentKeys), but the
333
+ // parentItemID check is kept here too so that stays true by construction,
334
+ // not by coincidence.
335
+ if (row.parentItemID !== null && this.flaggedAttachmentKeys.has(row.key)) {
336
+ attachment.sharedCarrier = true;
337
+ }
338
+ return attachment;
339
+ }
340
+ /**
341
+ * Item keys of child attachments whose carrier — resolved path, or file
342
+ * content hash — also appears under a DIFFERENT parent work. Built from
343
+ * every non-deleted attachment in the user library (children and orphans),
344
+ * but only children contribute to (and can be flagged by) the map: an
345
+ * orphan has no parent to weld two works through.
346
+ */
347
+ buildFlaggedAttachmentKeys() {
348
+ const rows = this.db.prepare(`SELECT i.key AS key, att.parentItemID AS parentItemID, att.linkMode AS linkMode, att.path AS path
349
+ FROM itemAttachments att
350
+ JOIN items i ON i.itemID = att.itemID
351
+ JOIN libraries l ON l.libraryID = i.libraryID
352
+ WHERE l.type = 'user'
353
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)`).all();
354
+ const carriers = rows.map(row => ({
355
+ key: row.key,
356
+ parentItemID: row.parentItemID,
357
+ carrierKeys: this.carrierKeys(row.key, row.linkMode, row.path),
358
+ }));
359
+ const carrierMap = new Map();
360
+ for (const c of carriers) {
361
+ if (c.parentItemID === null)
362
+ continue; // orphans don't count as parents
363
+ for (const k of c.carrierKeys) {
364
+ const parents = carrierMap.get(k) ?? new Set();
365
+ parents.add(c.parentItemID);
366
+ carrierMap.set(k, parents);
367
+ }
368
+ }
369
+ const flagged = new Set();
370
+ for (const c of carriers) {
371
+ if (c.parentItemID === null)
372
+ continue;
373
+ if (c.carrierKeys.some(k => (carrierMap.get(k)?.size ?? 0) >= 2)) {
374
+ flagged.add(c.key);
375
+ }
376
+ }
377
+ if (flagged.size > 0) {
378
+ this.logger.verboseInfo(`${flagged.size} attachment(s) share a carrier with a different parent work`);
379
+ }
380
+ return flagged;
381
+ }
382
+ /** The resolved path, plus a content-hash key when the file is readable — the two ways two attachments can carry the same bytes. */
383
+ carrierKeys(key, linkMode, path) {
384
+ const resolvedPath = this.resolveAttachmentPath(key, linkMode, path);
385
+ if (!resolvedPath)
386
+ return [];
387
+ const keys = [resolvedPath];
388
+ const hash = this.hashFile(resolvedPath);
389
+ if (hash)
390
+ keys.push(`md5:${hash}`);
391
+ return keys;
392
+ }
393
+ hashFile(path) {
394
+ try {
395
+ return createHash('md5').update(readFileSync(path)).digest('hex');
396
+ }
397
+ catch {
398
+ return undefined; // unreadable or missing — skip silently
399
+ }
400
+ }
401
+ /** Storage folders are named by the attachment's own key, not its filename. */
402
+ resolveAttachmentPath(key, linkMode, path) {
403
+ if (!path)
404
+ return undefined;
405
+ if (path.startsWith('storage:')) {
406
+ return join(this.dataDir, 'storage', key, path.slice('storage:'.length));
407
+ }
408
+ if (linkMode === 2 && isAbsolute(path)) {
409
+ return path;
410
+ }
411
+ return undefined;
412
+ }
413
+ loadWorkRef(workItemID) {
414
+ // The work's own trashed state is already excluded by the annotation
415
+ // query's join condition, so a non-null id here is always live.
416
+ if (workItemID === null)
417
+ return undefined;
418
+ const row = this.db.prepare(`SELECT i.key AS key, it.typeName AS itemType
419
+ FROM items i
420
+ JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
421
+ WHERE i.itemID = ?`).get(workItemID);
422
+ if (!row)
423
+ return undefined;
424
+ const ref = { key: row.key, itemType: row.itemType };
425
+ const { title } = this.loadFields(workItemID);
426
+ if (title)
427
+ ref.title = title;
428
+ return ref;
429
+ }
430
+ loadWorkAttachments(workItemID) {
431
+ const rows = this.db.prepare(`SELECT i.itemID AS itemID, i.key AS key, i.dateAdded AS dateAdded,
432
+ att.parentItemID AS parentItemID, att.linkMode AS linkMode, att.contentType AS contentType,
433
+ att.path AS path, att.lastRead AS lastRead
434
+ FROM itemAttachments att
435
+ JOIN items i ON i.itemID = att.itemID
436
+ WHERE att.parentItemID = ?
437
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)`).all(workItemID);
438
+ return rows.map(row => this.buildAttachment(row));
439
+ }
440
+ loadFields(itemID) {
441
+ const rows = this.db.prepare(`SELECT f.fieldName AS fieldName, v.value AS value
442
+ FROM itemData d
443
+ JOIN fields f ON f.fieldID = d.fieldID
444
+ JOIN itemDataValues v ON v.valueID = d.valueID
445
+ WHERE d.itemID = ?`).all(itemID);
446
+ const fields = {};
447
+ for (const row of rows)
448
+ fields[row.fieldName] = row.value;
449
+ return fields;
450
+ }
451
+ loadCreators(itemID) {
452
+ return this.db.prepare(`SELECT c.firstName AS firstName, c.lastName AS lastName, c.fieldMode AS fieldMode,
453
+ ct.creatorType AS creatorType, ic.orderIndex AS orderIndex
454
+ FROM itemCreators ic
455
+ JOIN creators c ON c.creatorID = ic.creatorID
456
+ JOIN creatorTypes ct ON ct.creatorTypeID = ic.creatorTypeID
457
+ WHERE ic.itemID = ?
458
+ ORDER BY ic.orderIndex`).all(itemID);
459
+ }
460
+ loadTags(itemID) {
461
+ return this.db.prepare(`SELECT t.name AS name, itag.type AS type
462
+ FROM itemTags itag
463
+ JOIN tags t ON t.tagID = itag.tagID
464
+ WHERE itag.itemID = ?`).all(itemID);
465
+ }
466
+ loadCollections() {
467
+ const rows = this.db.prepare(`SELECT c.collectionID AS collectionID, c.key AS key, c.collectionName AS name,
468
+ c.parentCollectionID AS parentCollectionID
469
+ FROM collections c
470
+ JOIN libraries l ON l.libraryID = c.libraryID
471
+ WHERE l.type = 'user'`).all();
472
+ return new Map(rows.map(row => [row.collectionID, row]));
473
+ }
474
+ buildCollectionChain(collectionID, collections, seen = new Set()) {
475
+ if (seen.has(collectionID))
476
+ return undefined; // guard against a cyclical parent chain
477
+ seen.add(collectionID);
478
+ const row = collections.get(collectionID);
479
+ if (!row)
480
+ return undefined;
481
+ const parent = row.parentCollectionID === null
482
+ ? undefined
483
+ : this.buildCollectionChain(row.parentCollectionID, collections, seen);
484
+ return { key: row.key, name: row.name, ...(parent ? { parent } : {}) };
485
+ }
486
+ getItemCollections(itemID, collections) {
487
+ const rows = this.db.prepare(`SELECT collectionID FROM collectionItems WHERE itemID = ?`).all(itemID);
488
+ const result = [];
489
+ for (const row of rows) {
490
+ const chain = this.buildCollectionChain(row.collectionID, collections);
491
+ if (chain)
492
+ result.push(chain);
493
+ }
494
+ return result;
495
+ }
496
+ async determineCount() {
497
+ if (!this.db)
498
+ return null;
499
+ try {
500
+ const worksFilter = this.sinceUntilFilter('i.dateModified');
501
+ const works = this.db
502
+ .prepare(`SELECT COUNT(*) AS n
503
+ FROM items i
504
+ JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
505
+ JOIN libraries l ON l.libraryID = i.libraryID
506
+ WHERE l.type = 'user'
507
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
508
+ AND it.typeName NOT IN ('attachment', 'annotation', 'note')${worksFilter.clause}`)
509
+ .get(...worksFilter.params);
510
+ const annotationsFilter = this.sinceUntilFilter('i.dateModified');
511
+ const annotations = this.db
512
+ .prepare(`SELECT COUNT(*) AS n
513
+ FROM itemAnnotations a
514
+ JOIN items i ON i.itemID = a.itemID
515
+ JOIN libraries l ON l.libraryID = i.libraryID
516
+ JOIN itemAttachments att ON att.itemID = a.parentItemID
517
+ JOIN items ai ON ai.itemID = att.itemID
518
+ WHERE l.type = 'user'
519
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
520
+ AND ai.itemID NOT IN (SELECT itemID FROM deletedItems)
521
+ AND (att.parentItemID IS NULL OR att.parentItemID NOT IN (SELECT itemID FROM deletedItems))${annotationsFilter.clause}`)
522
+ .get(...annotationsFilter.params);
523
+ const attachmentsFilter = this.sinceUntilFilter('i.dateModified');
524
+ const attachments = this.db
525
+ .prepare(`SELECT COUNT(*) AS n
526
+ FROM itemAttachments att
527
+ JOIN items i ON i.itemID = att.itemID
528
+ JOIN libraries l ON l.libraryID = i.libraryID
529
+ WHERE l.type = 'user'
530
+ AND att.parentItemID IS NULL
531
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)${attachmentsFilter.clause}`)
532
+ .get(...attachmentsFilter.params);
533
+ const notesFilter = this.sinceUntilFilter('i.dateModified');
534
+ const notes = this.db
535
+ .prepare(`SELECT COUNT(*) AS n
536
+ FROM items i
537
+ JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
538
+ JOIN itemNotes n ON n.itemID = i.itemID
539
+ JOIN libraries l ON l.libraryID = i.libraryID
540
+ WHERE l.type = 'user'
541
+ AND it.typeName = 'note'
542
+ AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
543
+ AND (n.parentItemID IS NULL OR n.parentItemID NOT IN (SELECT itemID FROM deletedItems))${notesFilter.clause}`)
544
+ .get(...notesFilter.params);
545
+ return works.n + annotations.n + attachments.n + notes.n;
546
+ }
547
+ catch {
548
+ return null;
549
+ }
550
+ }
551
+ }
@@ -0,0 +1,139 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ /**
4
+ * A local Zotero library (`zotero.sqlite`) → Chronicle JSON-LD.
5
+ *
6
+ * Four record shapes come off the extractor: `work` (a reference item with
7
+ * its creators/collections/tags/attachments joined in), `annotation` (a
8
+ * highlight/note made in the reader, with its attachment and parent work
9
+ * context), `attachment` (an orphan file saved directly to the library, with
10
+ * no parent work), and `note` (an HTML note item, on a work or standalone).
11
+ * All four are re-read on every crawl (`snapshot` temporality), so mutable
12
+ * attributes — collection membership, tags, the last-read timestamp — are
13
+ * sighted at crawl time, not back-dated.
14
+ */
15
+ export default class ZoteroTransformer extends ChronicleTransformer {
16
+ static source: string;
17
+ transform(record: Record): Promise<ActionAndChildren[]>;
18
+ /**
19
+ * The library owner. The Zotero DB carries no name for the local user, only
20
+ * the install-stable `localUserKey`, so that's the whole identity. Not
21
+ * realm-scoped: a person is not a per-library thing.
22
+ */
23
+ private buildAgent;
24
+ /**
25
+ * The library's scope realm. Zotero item keys are only per-library unique,
26
+ * so every item-keyed entity below (works, attachments, Quotations,
27
+ * Comments, Collections) carries this in both its `@key` and `inRealm` —
28
+ * the obsidian note-key precedent, applied to a db-local id instead of a
29
+ * filename.
30
+ */
31
+ private buildRealm;
32
+ private buildWorkActions;
33
+ /**
34
+ * `lastRead` is a single overwritten "last opened" timestamp, not an
35
+ * append-only log — key the timestamp itself in so each newly sighted
36
+ * value lands as its own event instead of one event that moves in place.
37
+ * It's `ViewAction`, not `ReadAction`: `ReadAction` opens a `consuming` run
38
+ * that only a `CompleteAction` closes, and Zotero has no completion signal
39
+ * — a `ReadAction` here would strand the work in an eternal
40
+ * "currently reading" state. A single sighted open is one-shot
41
+ * consumption; `ViewAction` is its honest weight.
42
+ */
43
+ private buildViewAction;
44
+ private buildWorkEntity;
45
+ private buildPublisher;
46
+ /**
47
+ * Creators ordered by `orderIndex`. `author` credits the primary authors;
48
+ * `editor`/`translator`/`contributor` (and any other credited role Zotero
49
+ * reports) land in the generic `creator` bucket; `seriesEditor` is skipped
50
+ * outright — it credits the series, not this work.
51
+ */
52
+ private buildCreators;
53
+ /** fieldMode 0 = two-field person; fieldMode 1 = single-field institution (name in lastName). */
54
+ private buildCreatorNode;
55
+ /**
56
+ * Protocol `sameAs` edges (the goodreads idiom): each is an entity in the
57
+ * other namespace, keyed there — type-free, since a DOI/ISBN/arXiv/PubMed
58
+ * id names one work however a source typed it — carrying the work's title
59
+ * as name so the edge still identifies the work if it's what mints it.
60
+ * ISSN is handled separately (`buildPeriodical`) — it identifies the
61
+ * periodical, not the work itself.
62
+ */
63
+ private buildWorkSameAs;
64
+ /**
65
+ * The DOI `sameAs` edge. Its own resolver url mints the url hub, so a
66
+ * browser visit to the DOI link folds onto the work.
67
+ */
68
+ private buildDoiEdge;
69
+ /** ISBN `sameAs` edges — the field can hold several space-separated ISBNs. */
70
+ private buildIsbnEdges;
71
+ /**
72
+ * arXiv `sameAs` edges, mined from up to three places and deduped by id:
73
+ * `archiveID` (an `arXiv:<id>` prefix, or a bare id when `repository` says
74
+ * arXiv), an `arXiv:<id>` line in the free-text `extra` field, and an
75
+ * arXiv-minted DOI (`10.48550/arxiv.<id>`).
76
+ */
77
+ private buildArxivSameAs;
78
+ /**
79
+ * PMID/PMCID `sameAs` edges, mined from `PMID: <digits>` / `PMCID:
80
+ * PMC<digits>` lines in the free-text `extra` field.
81
+ */
82
+ private buildPubmedSameAs;
83
+ private buildWorkIsPartOf;
84
+ /**
85
+ * The containing book of a bookSection, name-keyed (cross-library merge by
86
+ * title is fine — same idiom as the periodical/series below) and carrying
87
+ * the section's ISBN as its own `sameAs` edges, since that ISBN identifies
88
+ * the book, not the section. No bookTitle → nothing honest to key the book
89
+ * on, so the ISBN is dropped entirely (see buildWorkSameAs).
90
+ */
91
+ private buildContainingBook;
92
+ /** The journal/magazine a work ran in. ISSN identifies IT, not the work. Name-keyed — not realm-scoped, cross-library merge by name is fine. */
93
+ private buildPeriodical;
94
+ /** A Zotero collection, realm-scoped (per-library keys), with its ancestor chain carried as nested `isPartOf`. */
95
+ private buildCollectionNode;
96
+ private buildTags;
97
+ /**
98
+ * A child attachment as a SPARSE facet of its work, not a separate entity
99
+ * (option B): the file merges INTO the work via `sameAs`, so it carries no
100
+ * `name` of its own — the merged entity's name comes uncontested from the
101
+ * work facet. `sameAs` is the collapsing edge — the work ref, plus the
102
+ * filesystem node when this copy has one — replacing `instanceOf`, which
103
+ * this transformer no longer emits anywhere.
104
+ */
105
+ private buildFileFacet;
106
+ /**
107
+ * A sparse reference to the file facet — used off the annotation record
108
+ * (Quotation.isPartOf, the comment-only `about` fallback) so an anchored
109
+ * selector records which copy it's valid in. No name (sparse, like the
110
+ * facet); carries `sameAs: [work ref]` so an annotation arriving before
111
+ * its work record still witnesses the merge.
112
+ */
113
+ private buildFileFacetRef;
114
+ private buildFilesystemSameAs;
115
+ /** A slim, typed, named, realm-scoped reference to a work — used off actions that don't own the work's full state. */
116
+ private buildWorkRef;
117
+ /** A slim, typed, named, realm-scoped reference to an attachment (orphan-annotation subject, standalone-attachment ref). */
118
+ private buildAttachmentRef;
119
+ private buildAnnotationActions;
120
+ /** Parses the raw `position` JSON defensively; omits the selector on any failure or unrecognized shape. */
121
+ private buildSelector;
122
+ private buildOrphanAttachmentActions;
123
+ /**
124
+ * A standalone (orphan) attachment: unlike a child attachment it IS the
125
+ * catalog item, not a facet of anything — its own named entity (via the
126
+ * display-name heuristic), unconditional filesystem `sameAs` (orphans
127
+ * never set `sharedCarrier`), plus its own collections/tags.
128
+ */
129
+ private buildStandaloneAttachmentEntity;
130
+ /**
131
+ * A Zotero note item — an HTML note written on a work (child) or standing
132
+ * alone in the library — becomes an AnnotateAction over a Comment. Unlike
133
+ * a highlight-comment (which shares its parent annotation's key), this
134
+ * Comment is keyed on the note item's OWN key; the keyset shape is
135
+ * identical either way, so there's no collision.
136
+ */
137
+ private buildNoteActions;
138
+ }
139
+ //# sourceMappingURL=ZoteroTransformer.d.ts.map