@chronicle.app/zotero 0.4.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 +79 -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 +11 -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, assertReadable } 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;
|
|
@@ -156,12 +196,7 @@ export class ZoteroExtractor extends SqliteExtractor {
|
|
|
156
196
|
const sql = `
|
|
157
197
|
SELECT i.itemID AS itemID, i.key AS key, i.dateAdded AS dateAdded, i.dateModified AS dateModified,
|
|
158
198
|
it.typeName AS itemType
|
|
159
|
-
|
|
160
|
-
JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
|
|
161
|
-
JOIN libraries l ON l.libraryID = i.libraryID
|
|
162
|
-
WHERE l.type = 'user'
|
|
163
|
-
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
164
|
-
AND it.typeName NOT IN ('attachment', 'annotation', 'note')${clause}
|
|
199
|
+
${RECORD_ROWS.works}${clause}
|
|
165
200
|
ORDER BY i.dateModified DESC`;
|
|
166
201
|
for (const row of iterateRows(this.db.prepare(sql), ...params)) {
|
|
167
202
|
if (this.shouldStopExtracting(this.extractedCount))
|
|
@@ -196,15 +231,7 @@ export class ZoteroExtractor extends SqliteExtractor {
|
|
|
196
231
|
ai.itemID AS attItemID, ai.key AS attKey, ai.dateAdded AS attDateAdded,
|
|
197
232
|
att.parentItemID AS workItemID, att.linkMode AS attLinkMode, att.contentType AS attContentType,
|
|
198
233
|
att.path AS attPath, att.lastRead AS attLastRead
|
|
199
|
-
|
|
200
|
-
JOIN items i ON i.itemID = a.itemID
|
|
201
|
-
JOIN libraries l ON l.libraryID = i.libraryID
|
|
202
|
-
JOIN itemAttachments att ON att.itemID = a.parentItemID
|
|
203
|
-
JOIN items ai ON ai.itemID = att.itemID
|
|
204
|
-
WHERE l.type = 'user'
|
|
205
|
-
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
206
|
-
AND ai.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
207
|
-
AND (att.parentItemID IS NULL OR att.parentItemID NOT IN (SELECT itemID FROM deletedItems))${clause}
|
|
234
|
+
${RECORD_ROWS.annotations}${clause}
|
|
208
235
|
ORDER BY i.dateModified DESC`;
|
|
209
236
|
for (const row of iterateRows(this.db.prepare(sql), ...params)) {
|
|
210
237
|
if (this.shouldStopExtracting(this.extractedCount))
|
|
@@ -251,12 +278,7 @@ export class ZoteroExtractor extends SqliteExtractor {
|
|
|
251
278
|
SELECT i.itemID AS itemID, i.key AS key, i.dateAdded AS dateAdded, i.dateModified AS dateModified,
|
|
252
279
|
att.parentItemID AS parentItemID, att.linkMode AS linkMode, att.contentType AS contentType,
|
|
253
280
|
att.path AS path, att.lastRead AS lastRead
|
|
254
|
-
|
|
255
|
-
JOIN items i ON i.itemID = att.itemID
|
|
256
|
-
JOIN libraries l ON l.libraryID = i.libraryID
|
|
257
|
-
WHERE l.type = 'user'
|
|
258
|
-
AND att.parentItemID IS NULL
|
|
259
|
-
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)${clause}
|
|
281
|
+
${RECORD_ROWS.attachments}${clause}
|
|
260
282
|
ORDER BY i.dateModified DESC`;
|
|
261
283
|
for (const row of iterateRows(this.db.prepare(sql), ...params)) {
|
|
262
284
|
if (this.shouldStopExtracting(this.extractedCount))
|
|
@@ -281,14 +303,7 @@ export class ZoteroExtractor extends SqliteExtractor {
|
|
|
281
303
|
const sql = `
|
|
282
304
|
SELECT i.itemID AS itemID, i.key AS key, i.dateAdded AS dateAdded, i.dateModified AS dateModified,
|
|
283
305
|
n.parentItemID AS parentItemID, n.note AS note, n.title AS title
|
|
284
|
-
|
|
285
|
-
JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
|
|
286
|
-
JOIN itemNotes n ON n.itemID = i.itemID
|
|
287
|
-
JOIN libraries l ON l.libraryID = i.libraryID
|
|
288
|
-
WHERE l.type = 'user'
|
|
289
|
-
AND it.typeName = 'note'
|
|
290
|
-
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
291
|
-
AND (n.parentItemID IS NULL OR n.parentItemID NOT IN (SELECT itemID FROM deletedItems))${clause}
|
|
306
|
+
${RECORD_ROWS.notes}${clause}
|
|
292
307
|
ORDER BY i.dateModified DESC`;
|
|
293
308
|
for (const row of iterateRows(this.db.prepare(sql), ...params)) {
|
|
294
309
|
if (this.shouldStopExtracting(this.extractedCount))
|
|
@@ -498,55 +513,39 @@ export class ZoteroExtractor extends SqliteExtractor {
|
|
|
498
513
|
if (!this.db)
|
|
499
514
|
return null;
|
|
500
515
|
try {
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
509
|
-
AND it.typeName NOT IN ('attachment', 'annotation', 'note')${worksFilter.clause}`)
|
|
510
|
-
.get(...worksFilter.params);
|
|
511
|
-
const annotationsFilter = this.sinceUntilFilter('i.dateModified');
|
|
512
|
-
const annotations = this.db
|
|
513
|
-
.prepare(`SELECT COUNT(*) AS n
|
|
514
|
-
FROM itemAnnotations a
|
|
515
|
-
JOIN items i ON i.itemID = a.itemID
|
|
516
|
-
JOIN libraries l ON l.libraryID = i.libraryID
|
|
517
|
-
JOIN itemAttachments att ON att.itemID = a.parentItemID
|
|
518
|
-
JOIN items ai ON ai.itemID = att.itemID
|
|
519
|
-
WHERE l.type = 'user'
|
|
520
|
-
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
521
|
-
AND ai.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
522
|
-
AND (att.parentItemID IS NULL OR att.parentItemID NOT IN (SELECT itemID FROM deletedItems))${annotationsFilter.clause}`)
|
|
523
|
-
.get(...annotationsFilter.params);
|
|
524
|
-
const attachmentsFilter = this.sinceUntilFilter('i.dateModified');
|
|
525
|
-
const attachments = this.db
|
|
526
|
-
.prepare(`SELECT COUNT(*) AS n
|
|
527
|
-
FROM itemAttachments att
|
|
528
|
-
JOIN items i ON i.itemID = att.itemID
|
|
529
|
-
JOIN libraries l ON l.libraryID = i.libraryID
|
|
530
|
-
WHERE l.type = 'user'
|
|
531
|
-
AND att.parentItemID IS NULL
|
|
532
|
-
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)${attachmentsFilter.clause}`)
|
|
533
|
-
.get(...attachmentsFilter.params);
|
|
534
|
-
const notesFilter = this.sinceUntilFilter('i.dateModified');
|
|
535
|
-
const notes = this.db
|
|
536
|
-
.prepare(`SELECT COUNT(*) AS n
|
|
537
|
-
FROM items i
|
|
538
|
-
JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
|
|
539
|
-
JOIN itemNotes n ON n.itemID = i.itemID
|
|
540
|
-
JOIN libraries l ON l.libraryID = i.libraryID
|
|
541
|
-
WHERE l.type = 'user'
|
|
542
|
-
AND it.typeName = 'note'
|
|
543
|
-
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
544
|
-
AND (n.parentItemID IS NULL OR n.parentItemID NOT IN (SELECT itemID FROM deletedItems))${notesFilter.clause}`)
|
|
545
|
-
.get(...notesFilter.params);
|
|
546
|
-
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;
|
|
547
523
|
}
|
|
548
524
|
catch {
|
|
549
525
|
return null;
|
|
550
526
|
}
|
|
551
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
|
+
}
|
|
552
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,7 +68,12 @@
|
|
|
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
|
}
|