@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.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/connectors/ZoteroExtractor.d.ts +73 -0
- package/dist/connectors/ZoteroExtractor.js +551 -0
- package/dist/connectors/ZoteroTransformer.d.ts +139 -0
- package/dist/connectors/ZoteroTransformer.js +941 -0
- package/dist/connectors/records.d.ts +175 -0
- package/dist/connectors/records.js +11 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/package.json +77 -0
|
@@ -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
|