@chronicle.app/zotero 0.3.0 → 0.5.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/dist/connectors/ZoteroExtractor.d.ts +3 -1
- package/dist/connectors/ZoteroExtractor.js +80 -80
- package/dist/connectors/ZoteroLocator.d.ts +12 -0
- package/dist/connectors/ZoteroLocator.js +20 -0
- package/dist/connectors/ZoteroTransformer.d.ts +2 -0
- package/dist/connectors/ZoteroTransformer.js +26 -18
- package/dist/dataDir.d.ts +7 -0
- package/dist/dataDir.js +51 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +19 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Record } from '@chronicle.app/etl';
|
|
1
|
+
import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
|
|
2
2
|
import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
|
|
3
3
|
import ZoteroTransformer from './ZoteroTransformer.js';
|
|
4
4
|
/**
|
|
@@ -69,5 +69,7 @@ export declare class ZoteroExtractor extends SqliteExtractor<typeof ZoteroExtrac
|
|
|
69
69
|
private buildCollectionChain;
|
|
70
70
|
private getItemCollections;
|
|
71
71
|
determineCount(): Promise<number | null>;
|
|
72
|
+
/** The items a run reads of each record type, and when they were added. */
|
|
73
|
+
survey(options?: SurveyOptions): Promise<Survey | null>;
|
|
72
74
|
}
|
|
73
75
|
//# sourceMappingURL=ZoteroExtractor.d.ts.map
|
|
@@ -1,16 +1,56 @@
|
|
|
1
|
-
import { SystemInfo } from '@chronicle.app/etl';
|
|
2
|
-
import { SqliteExtractor, timeRangeConditions, iterateRows } from '@chronicle.app/etl-sqlite';
|
|
1
|
+
import { SystemInfo, assertReadable, } from '@chronicle.app/etl';
|
|
2
|
+
import { SqliteExtractor, allRows, countsByMonth, monthSql, timeRangeConditions, iterateRows, } from '@chronicle.app/etl-sqlite';
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { constants as fsConstants, copyFileSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
6
|
-
import {
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
7
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
8
8
|
import { z } from 'zod';
|
|
9
|
-
import ZoteroTransformer from './ZoteroTransformer.js';
|
|
9
|
+
import ZoteroTransformer, { parseZoteroDate } from './ZoteroTransformer.js';
|
|
10
|
+
import { zoteroDataDir } from '../dataDir.js';
|
|
10
11
|
/** Zotero's UTC `YYYY-MM-DD HH:MM:SS` form, so string comparison against `dateModified` is correct. */
|
|
11
12
|
function toSqliteTimestamp(d) {
|
|
12
13
|
return d.toISOString().slice(0, 19).replace('T', ' ');
|
|
13
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* The items behind each record type, as `FROM … WHERE …` before since/until:
|
|
17
|
+
* the user library's, and none in the trash or under an item in it.
|
|
18
|
+
*/
|
|
19
|
+
const RECORD_ROWS = {
|
|
20
|
+
works: `
|
|
21
|
+
FROM items i
|
|
22
|
+
JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
|
|
23
|
+
JOIN libraries l ON l.libraryID = i.libraryID
|
|
24
|
+
WHERE l.type = 'user'
|
|
25
|
+
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
26
|
+
AND it.typeName NOT IN ('attachment', 'annotation', 'note')`,
|
|
27
|
+
annotations: `
|
|
28
|
+
FROM itemAnnotations a
|
|
29
|
+
JOIN items i ON i.itemID = a.itemID
|
|
30
|
+
JOIN libraries l ON l.libraryID = i.libraryID
|
|
31
|
+
JOIN itemAttachments att ON att.itemID = a.parentItemID
|
|
32
|
+
JOIN items ai ON ai.itemID = att.itemID
|
|
33
|
+
WHERE l.type = 'user'
|
|
34
|
+
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
35
|
+
AND ai.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
36
|
+
AND (att.parentItemID IS NULL OR att.parentItemID NOT IN (SELECT itemID FROM deletedItems))`,
|
|
37
|
+
attachments: `
|
|
38
|
+
FROM itemAttachments att
|
|
39
|
+
JOIN items i ON i.itemID = att.itemID
|
|
40
|
+
JOIN libraries l ON l.libraryID = i.libraryID
|
|
41
|
+
WHERE l.type = 'user'
|
|
42
|
+
AND att.parentItemID IS NULL
|
|
43
|
+
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)`,
|
|
44
|
+
notes: `
|
|
45
|
+
FROM items i
|
|
46
|
+
JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
|
|
47
|
+
JOIN itemNotes n ON n.itemID = i.itemID
|
|
48
|
+
JOIN libraries l ON l.libraryID = i.libraryID
|
|
49
|
+
WHERE l.type = 'user'
|
|
50
|
+
AND it.typeName = 'note'
|
|
51
|
+
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
52
|
+
AND (n.parentItemID IS NULL OR n.parentItemID NOT IN (SELECT itemID FROM deletedItems))`,
|
|
53
|
+
};
|
|
14
54
|
function isSqliteBusy(err) {
|
|
15
55
|
return (typeof err === 'object' &&
|
|
16
56
|
err !== null &&
|
|
@@ -42,7 +82,7 @@ export class ZoteroExtractor extends SqliteExtractor {
|
|
|
42
82
|
input: z
|
|
43
83
|
.string()
|
|
44
84
|
.describe('Path to the Zotero data directory (contains zotero.sqlite and storage/)')
|
|
45
|
-
.default(
|
|
85
|
+
.default(() => zoteroDataDir()),
|
|
46
86
|
});
|
|
47
87
|
dataDir;
|
|
48
88
|
localUserKey;
|
|
@@ -69,6 +109,7 @@ export class ZoteroExtractor extends SqliteExtractor {
|
|
|
69
109
|
// From the ORIGINAL file, even when the busy fallback below reads a
|
|
70
110
|
// freshly-made copy instead — the copy's mtime is just "now" and would
|
|
71
111
|
// defeat the byte-identical-on-unchanged-library property of asOfTime().
|
|
112
|
+
assertReadable(dbPath, 'Zotero database');
|
|
72
113
|
this.dbMtime = statSync(dbPath).mtime;
|
|
73
114
|
// Zotero uses journal_mode=delete, so a plain readonly open reads a
|
|
74
115
|
// consistent snapshot without contending with a running Zotero instance.
|
|
@@ -155,12 +196,7 @@ export class ZoteroExtractor extends SqliteExtractor {
|
|
|
155
196
|
const sql = `
|
|
156
197
|
SELECT i.itemID AS itemID, i.key AS key, i.dateAdded AS dateAdded, i.dateModified AS dateModified,
|
|
157
198
|
it.typeName AS itemType
|
|
158
|
-
|
|
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}
|
|
199
|
+
${RECORD_ROWS.works}${clause}
|
|
164
200
|
ORDER BY i.dateModified DESC`;
|
|
165
201
|
for (const row of iterateRows(this.db.prepare(sql), ...params)) {
|
|
166
202
|
if (this.shouldStopExtracting(this.extractedCount))
|
|
@@ -195,15 +231,7 @@ export class ZoteroExtractor extends SqliteExtractor {
|
|
|
195
231
|
ai.itemID AS attItemID, ai.key AS attKey, ai.dateAdded AS attDateAdded,
|
|
196
232
|
att.parentItemID AS workItemID, att.linkMode AS attLinkMode, att.contentType AS attContentType,
|
|
197
233
|
att.path AS attPath, att.lastRead AS attLastRead
|
|
198
|
-
|
|
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}
|
|
234
|
+
${RECORD_ROWS.annotations}${clause}
|
|
207
235
|
ORDER BY i.dateModified DESC`;
|
|
208
236
|
for (const row of iterateRows(this.db.prepare(sql), ...params)) {
|
|
209
237
|
if (this.shouldStopExtracting(this.extractedCount))
|
|
@@ -250,12 +278,7 @@ export class ZoteroExtractor extends SqliteExtractor {
|
|
|
250
278
|
SELECT i.itemID AS itemID, i.key AS key, i.dateAdded AS dateAdded, i.dateModified AS dateModified,
|
|
251
279
|
att.parentItemID AS parentItemID, att.linkMode AS linkMode, att.contentType AS contentType,
|
|
252
280
|
att.path AS path, att.lastRead AS lastRead
|
|
253
|
-
|
|
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}
|
|
281
|
+
${RECORD_ROWS.attachments}${clause}
|
|
259
282
|
ORDER BY i.dateModified DESC`;
|
|
260
283
|
for (const row of iterateRows(this.db.prepare(sql), ...params)) {
|
|
261
284
|
if (this.shouldStopExtracting(this.extractedCount))
|
|
@@ -280,14 +303,7 @@ export class ZoteroExtractor extends SqliteExtractor {
|
|
|
280
303
|
const sql = `
|
|
281
304
|
SELECT i.itemID AS itemID, i.key AS key, i.dateAdded AS dateAdded, i.dateModified AS dateModified,
|
|
282
305
|
n.parentItemID AS parentItemID, n.note AS note, n.title AS title
|
|
283
|
-
|
|
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}
|
|
306
|
+
${RECORD_ROWS.notes}${clause}
|
|
291
307
|
ORDER BY i.dateModified DESC`;
|
|
292
308
|
for (const row of iterateRows(this.db.prepare(sql), ...params)) {
|
|
293
309
|
if (this.shouldStopExtracting(this.extractedCount))
|
|
@@ -497,55 +513,39 @@ export class ZoteroExtractor extends SqliteExtractor {
|
|
|
497
513
|
if (!this.db)
|
|
498
514
|
return null;
|
|
499
515
|
try {
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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;
|
|
516
|
+
const { clause, params } = this.sinceUntilFilter('i.dateModified');
|
|
517
|
+
let total = 0;
|
|
518
|
+
for (const rows of Object.values(RECORD_ROWS)) {
|
|
519
|
+
const { n } = this.db.prepare(`SELECT COUNT(*) AS n ${rows}${clause}`).get(...params);
|
|
520
|
+
total += n;
|
|
521
|
+
}
|
|
522
|
+
return total;
|
|
546
523
|
}
|
|
547
524
|
catch {
|
|
548
525
|
return null;
|
|
549
526
|
}
|
|
550
527
|
}
|
|
528
|
+
/** The items a run reads of each record type, and when they were added. */
|
|
529
|
+
async survey(options = {}) {
|
|
530
|
+
if (!this.db)
|
|
531
|
+
return null;
|
|
532
|
+
const { clause, params } = this.sinceUntilFilter('i.dateModified');
|
|
533
|
+
// dateAdded is UTC text, so its month is from its Unix seconds.
|
|
534
|
+
const month = monthSql("CAST(strftime('%s', i.dateAdded) AS INTEGER)");
|
|
535
|
+
const survey = {};
|
|
536
|
+
for (const [recordType, rows] of Object.entries(RECORD_ROWS)) {
|
|
537
|
+
const { n, first, last } = this.db
|
|
538
|
+
.prepare(`SELECT COUNT(*) AS n, MIN(i.dateAdded) AS first, MAX(i.dateAdded) AS last ${rows}${clause}`)
|
|
539
|
+
.get(...params);
|
|
540
|
+
const byMonth = options.byMonth &&
|
|
541
|
+
countsByMonth(allRows(this.db.prepare(`SELECT ${month} AS month, COUNT(*) AS n ${rows}${clause} GROUP BY month`), ...params));
|
|
542
|
+
survey[recordType] = {
|
|
543
|
+
count: n,
|
|
544
|
+
...(first !== null && { earliest: parseZoteroDate(first) }),
|
|
545
|
+
...(last !== null && { latest: parseZoteroDate(last) }),
|
|
546
|
+
...(byMonth && { byMonth }),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
return survey;
|
|
550
|
+
}
|
|
551
551
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Locator, type Candidate, type LocationMatch } from '@chronicle.app/etl';
|
|
2
|
+
/**
|
|
3
|
+
* Zotero's library where its preferences say it is, which is where a run
|
|
4
|
+
* reads it, and one left in the default `~/Zotero` the manifest names, if
|
|
5
|
+
* that's another.
|
|
6
|
+
*/
|
|
7
|
+
export declare class ZoteroLocator extends Locator {
|
|
8
|
+
static source: string;
|
|
9
|
+
static strategy: string;
|
|
10
|
+
locate(matches: LocationMatch[]): AsyncGenerator<Candidate>;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=ZoteroLocator.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { Locator } from '@chronicle.app/etl';
|
|
3
|
+
import { zoteroDataDir } from '../dataDir.js';
|
|
4
|
+
/**
|
|
5
|
+
* Zotero's library where its preferences say it is, which is where a run
|
|
6
|
+
* reads it, and one left in the default `~/Zotero` the manifest names, if
|
|
7
|
+
* that's another.
|
|
8
|
+
*/
|
|
9
|
+
export class ZoteroLocator extends Locator {
|
|
10
|
+
static source = 'zotero';
|
|
11
|
+
static strategy = 'app-db';
|
|
12
|
+
async *locate(matches) {
|
|
13
|
+
const libraries = new Set([
|
|
14
|
+
join(zoteroDataDir(this.context.home), 'zotero.sqlite'),
|
|
15
|
+
...matches.map(match => match.path),
|
|
16
|
+
]);
|
|
17
|
+
for (const file of libraries)
|
|
18
|
+
yield { config: { input: dirname(file) }, path: file };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ChronicleTransformer, Record } from '@chronicle.app/etl';
|
|
2
2
|
import { ActionAndChildren } from '@chronicle.app/schema';
|
|
3
|
+
/** Zotero timestamps are UTC `YYYY-MM-DD HH:MM:SS`, no zone marker. */
|
|
4
|
+
export declare function parseZoteroDate(raw: string): Date;
|
|
3
5
|
/**
|
|
4
6
|
* A local Zotero library (`zotero.sqlite`) → Chronicle JSON-LD.
|
|
5
7
|
*
|
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { ChronicleTransformer } from '@chronicle.app/etl';
|
|
3
3
|
const SOURCE = 'zotero';
|
|
4
4
|
/** Zotero timestamps are UTC `YYYY-MM-DD HH:MM:SS`, no zone marker. */
|
|
5
|
-
function parseZoteroDate(raw) {
|
|
5
|
+
export function parseZoteroDate(raw) {
|
|
6
6
|
return new Date(`${raw.replace(' ', 'T')}Z`);
|
|
7
7
|
}
|
|
8
8
|
/** Zotero itemType → the Chronicle work subtype (every value here exists in the schema). */
|
|
@@ -239,9 +239,9 @@ export default class ZoteroTransformer extends ChronicleTransformer {
|
|
|
239
239
|
const actions = [
|
|
240
240
|
{
|
|
241
241
|
'@type': 'BookmarkAction',
|
|
242
|
-
|
|
242
|
+
// Keyed by the work it saved.
|
|
243
|
+
'@key': ['@type', 'source', 'object.sourceId', 'object.inRealm.handle'],
|
|
243
244
|
source: SOURCE,
|
|
244
|
-
sourceId: work.key,
|
|
245
245
|
timestamp: parseZoteroDate(work.dateAdded),
|
|
246
246
|
agent: me,
|
|
247
247
|
object: workEntity,
|
|
@@ -255,7 +255,7 @@ export default class ZoteroTransformer extends ChronicleTransformer {
|
|
|
255
255
|
// so each viewing independently witnesses the merge regardless of
|
|
256
256
|
// fold order.
|
|
257
257
|
const facet = this.buildFileFacet(attachment, context, workRef);
|
|
258
|
-
actions.push(this.buildViewAction(attachment.
|
|
258
|
+
actions.push(this.buildViewAction(attachment.lastRead, facet, me));
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
return actions;
|
|
@@ -270,12 +270,12 @@ export default class ZoteroTransformer extends ChronicleTransformer {
|
|
|
270
270
|
* "currently reading" state. A single sighted open is one-shot
|
|
271
271
|
* consumption; `ViewAction` is its honest weight.
|
|
272
272
|
*/
|
|
273
|
-
buildViewAction(
|
|
273
|
+
buildViewAction(lastRead, object, agent) {
|
|
274
274
|
return {
|
|
275
275
|
'@type': 'ViewAction',
|
|
276
|
-
|
|
276
|
+
// Keyed by the file it opened, and when.
|
|
277
|
+
'@key': ['@type', 'source', 'object.sourceId', 'object.inRealm.handle', 'timestamp'],
|
|
277
278
|
source: SOURCE,
|
|
278
|
-
sourceId,
|
|
279
279
|
timestamp: new Date(lastRead * 1000),
|
|
280
280
|
agent,
|
|
281
281
|
object,
|
|
@@ -754,29 +754,37 @@ export default class ZoteroTransformer extends ChronicleTransformer {
|
|
|
754
754
|
quotation.selector = [selector];
|
|
755
755
|
actions.push({
|
|
756
756
|
'@type': 'QuoteAction',
|
|
757
|
-
|
|
757
|
+
// Keyed by the quotation it made, which has the annotation's key.
|
|
758
|
+
'@key': ['@type', 'source', 'result.sourceId', 'result.inRealm.handle'],
|
|
758
759
|
source: SOURCE,
|
|
759
|
-
sourceId: annotation.key,
|
|
760
760
|
timestamp: parseZoteroDate(annotation.dateAdded),
|
|
761
761
|
agent: me,
|
|
762
762
|
result: quotation,
|
|
763
763
|
});
|
|
764
764
|
}
|
|
765
765
|
if (hasComment) {
|
|
766
|
+
// A comment on a highlight is keyed by the quotation it's about, which
|
|
767
|
+
// has the annotation's key. A comment on its own has the key itself.
|
|
766
768
|
const comment = {
|
|
767
769
|
'@type': 'Comment',
|
|
768
|
-
|
|
770
|
+
...(quotation
|
|
771
|
+
? { '@key': ['@type', 'source', 'about[*].sourceId', 'inRealm.handle'] }
|
|
772
|
+
: {
|
|
773
|
+
'@key': ['@type', 'source', 'sourceId', 'inRealm.handle'],
|
|
774
|
+
sourceId: annotation.key,
|
|
775
|
+
}),
|
|
769
776
|
source: SOURCE,
|
|
770
|
-
sourceId: annotation.key,
|
|
771
777
|
inRealm: realm,
|
|
772
778
|
description: annotation.comment,
|
|
773
779
|
about: [quotation ?? subjectRef],
|
|
774
780
|
};
|
|
775
781
|
actions.push({
|
|
776
782
|
'@type': 'AnnotateAction',
|
|
777
|
-
|
|
783
|
+
// Keyed by the comment it made.
|
|
784
|
+
'@key': quotation
|
|
785
|
+
? ['@type', 'source', 'result.about[*].sourceId', 'result.inRealm.handle']
|
|
786
|
+
: ['@type', 'source', 'result.sourceId', 'result.inRealm.handle'],
|
|
778
787
|
source: SOURCE,
|
|
779
|
-
sourceId: annotation.key,
|
|
780
788
|
timestamp: parseZoteroDate(annotation.dateAdded),
|
|
781
789
|
agent: me,
|
|
782
790
|
result: comment,
|
|
@@ -831,16 +839,16 @@ export default class ZoteroTransformer extends ChronicleTransformer {
|
|
|
831
839
|
const actions = [
|
|
832
840
|
{
|
|
833
841
|
'@type': 'BookmarkAction',
|
|
834
|
-
|
|
842
|
+
// Keyed by the file it saved.
|
|
843
|
+
'@key': ['@type', 'source', 'object.sourceId', 'object.inRealm.handle'],
|
|
835
844
|
source: SOURCE,
|
|
836
|
-
sourceId: attachment.key,
|
|
837
845
|
timestamp: parseZoteroDate(attachment.dateAdded),
|
|
838
846
|
agent: me,
|
|
839
847
|
object: entity,
|
|
840
848
|
},
|
|
841
849
|
];
|
|
842
850
|
if (attachment.lastRead) {
|
|
843
|
-
actions.push(this.buildViewAction(attachment.
|
|
851
|
+
actions.push(this.buildViewAction(attachment.lastRead, this.buildAttachmentRef(attachment, context), me));
|
|
844
852
|
}
|
|
845
853
|
return actions;
|
|
846
854
|
}
|
|
@@ -929,9 +937,9 @@ export default class ZoteroTransformer extends ChronicleTransformer {
|
|
|
929
937
|
return [
|
|
930
938
|
{
|
|
931
939
|
'@type': 'AnnotateAction',
|
|
932
|
-
|
|
940
|
+
// Keyed by the note it made.
|
|
941
|
+
'@key': ['@type', 'source', 'result.sourceId', 'result.inRealm.handle'],
|
|
933
942
|
source: SOURCE,
|
|
934
|
-
sourceId: note.key,
|
|
935
943
|
timestamp: parseZoteroDate(note.dateAdded),
|
|
936
944
|
agent: me,
|
|
937
945
|
result: comment,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zotero's data folder: the one a profile's preferences name, when it was
|
|
3
|
+
* moved from the default, else `~/Zotero`. Read the way Zotero reads it, so
|
|
4
|
+
* a run and a scan find the same library.
|
|
5
|
+
*/
|
|
6
|
+
export declare function zoteroDataDir(home?: string): string;
|
|
7
|
+
//# sourceMappingURL=dataDir.d.ts.map
|
package/dist/dataDir.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** Where Zotero keeps its profiles, on macOS and on Linux. */
|
|
5
|
+
const PROFILES = [
|
|
6
|
+
['Library', 'Application Support', 'Zotero', 'Profiles'],
|
|
7
|
+
['.zotero', 'zotero'],
|
|
8
|
+
];
|
|
9
|
+
/** A preference's value from a profile's `prefs.js`, as the JSON Zotero writes it. */
|
|
10
|
+
function pref(prefs, name) {
|
|
11
|
+
const value = prefs.match(new RegExp(`user_pref\\("${name.replaceAll('.', '\\.')}",\\s*(.+?)\\);`));
|
|
12
|
+
if (!value)
|
|
13
|
+
return undefined;
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(value[1]);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Zotero's data folder: the one a profile's preferences name, when it was
|
|
23
|
+
* moved from the default, else `~/Zotero`. Read the way Zotero reads it, so
|
|
24
|
+
* a run and a scan find the same library.
|
|
25
|
+
*/
|
|
26
|
+
export function zoteroDataDir(home = homedir()) {
|
|
27
|
+
for (const parts of PROFILES) {
|
|
28
|
+
const root = join(home, ...parts);
|
|
29
|
+
let profiles;
|
|
30
|
+
try {
|
|
31
|
+
profiles = readdirSync(root).sort();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
for (const profile of profiles) {
|
|
37
|
+
let prefs;
|
|
38
|
+
try {
|
|
39
|
+
prefs = readFileSync(join(root, profile, 'prefs.js'), 'utf8');
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const dir = pref(prefs, 'extensions.zotero.dataDir');
|
|
45
|
+
if (pref(prefs, 'extensions.zotero.useDataDir') === true && typeof dir === 'string') {
|
|
46
|
+
return dir;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return join(home, 'Zotero');
|
|
51
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { ZoteroExtractor } from './connectors/ZoteroExtractor.js';
|
|
2
|
+
export { ZoteroLocator } from './connectors/ZoteroLocator.js';
|
|
3
|
+
export { zoteroDataDir } from './dataDir.js';
|
|
2
4
|
export { default as ZoteroTransformer } from './connectors/ZoteroTransformer.js';
|
|
3
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/zotero",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"files": [
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
"type": "module",
|
|
23
23
|
"description": "Chronicle plugin for a local Zotero library: works, highlights, notes, and reading events",
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@chronicle.app/etl": "0.
|
|
26
|
-
"@chronicle.app/schema": "0.
|
|
27
|
-
"@chronicle.app/tsconfig": "0.
|
|
25
|
+
"@chronicle.app/etl": "0.5.0",
|
|
26
|
+
"@chronicle.app/schema": "0.5.0",
|
|
27
|
+
"@chronicle.app/tsconfig": "0.5.0",
|
|
28
28
|
"@types/node": "^22.10.1",
|
|
29
29
|
"typescript": "^5.7.2"
|
|
30
30
|
},
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"registry": "https://registry.npmjs.org/"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@chronicle.app/etl-sqlite": "0.
|
|
42
|
+
"@chronicle.app/etl-sqlite": "0.5.0",
|
|
43
43
|
"zod": "^3.24.1"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
@@ -68,10 +68,23 @@
|
|
|
68
68
|
"attachments",
|
|
69
69
|
"notes"
|
|
70
70
|
],
|
|
71
|
-
"default": true
|
|
71
|
+
"default": true,
|
|
72
|
+
"locations": [
|
|
73
|
+
{
|
|
74
|
+
"path": "~/Zotero/zotero.sqlite"
|
|
75
|
+
}
|
|
76
|
+
]
|
|
72
77
|
}
|
|
73
78
|
}
|
|
74
79
|
}
|
|
80
|
+
},
|
|
81
|
+
"deepLinks": {
|
|
82
|
+
"zotero": {
|
|
83
|
+
"*": "zotero://select/library/items/{sourceId}",
|
|
84
|
+
"DocumentObject": "zotero://open-pdf/library/items/{sourceId}",
|
|
85
|
+
"Collection": "",
|
|
86
|
+
"Person": ""
|
|
87
|
+
}
|
|
75
88
|
}
|
|
76
89
|
}
|
|
77
90
|
}
|