@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,941 @@
1
+ import path from 'node:path';
2
+ import { ChronicleTransformer } from '@chronicle.app/etl';
3
+ const SOURCE = 'zotero';
4
+ /** Zotero timestamps are UTC `YYYY-MM-DD HH:MM:SS`, no zone marker. */
5
+ function parseZoteroDate(raw) {
6
+ return new Date(`${raw.replace(' ', 'T')}Z`);
7
+ }
8
+ /** Zotero itemType → the Chronicle work subtype (every value here exists in the schema). */
9
+ const WORK_TYPE_BY_ITEM_TYPE = {
10
+ book: 'Book',
11
+ bookSection: 'Book',
12
+ journalArticle: 'Article',
13
+ magazineArticle: 'Article',
14
+ newspaperArticle: 'Article',
15
+ encyclopediaArticle: 'Article',
16
+ dictionaryEntry: 'Article',
17
+ conferencePaper: 'Article',
18
+ preprint: 'Article',
19
+ report: 'Article',
20
+ thesis: 'Article',
21
+ blogPost: 'Post',
22
+ forumPost: 'Post',
23
+ email: 'Message',
24
+ instantMessage: 'Message',
25
+ film: 'VideoObject',
26
+ videoRecording: 'VideoObject',
27
+ tvBroadcast: 'VideoObject',
28
+ audioRecording: 'AudioObject',
29
+ radioBroadcast: 'AudioObject',
30
+ podcast: 'PodcastEpisode', // Zotero's "podcast" item is a single episode
31
+ computerProgram: 'SoftwareApplication',
32
+ artwork: 'ImageObject',
33
+ };
34
+ /**
35
+ * The work entity keys on `(source, sourceId)` (plus the library realm) —
36
+ * `@type` isn't part of its identity — so refining this map later retypes an
37
+ * existing entity in place instead of re-minting it.
38
+ */
39
+ function mapWorkType(itemType) {
40
+ return WORK_TYPE_BY_ITEM_TYPE[itemType] ?? 'CreativeWork';
41
+ }
42
+ /** image/* → ImageObject; pdf/epub/text/* → DocumentObject; everything else (incl. null) → MediaObject. */
43
+ function mapAttachmentType(contentType) {
44
+ if (!contentType)
45
+ return 'MediaObject';
46
+ if (contentType.startsWith('image/'))
47
+ return 'ImageObject';
48
+ if (contentType === 'application/pdf' ||
49
+ contentType === 'application/epub+zip' ||
50
+ contentType.startsWith('text/')) {
51
+ return 'DocumentObject';
52
+ }
53
+ return 'MediaObject';
54
+ }
55
+ /** Basename of a Zotero attachment path (e.g. `storage:Paper.pdf` → `Paper.pdf`). */
56
+ function filenameFromPath(rawPath) {
57
+ if (!rawPath)
58
+ return undefined;
59
+ const base = rawPath
60
+ .replace(/^storage:/, '')
61
+ .split('/')
62
+ .pop();
63
+ return base || undefined;
64
+ }
65
+ /**
66
+ * Zotero's multipart `date` field, e.g. `'2024-07-00 7/2024'` — take the first
67
+ * whitespace token and strip trailing `-00` (unknown month/day) segments.
68
+ * Stays a string so day/month/year precision survives; never fabricates an
69
+ * instant.
70
+ */
71
+ function parseDatePublished(raw) {
72
+ const token = raw.trim().split(/\s+/)[0];
73
+ return token.replace(/(-00)+$/, '');
74
+ }
75
+ /** Strip a `doi.org`/`doi:` prefix and lowercase, per the DOI display convention. */
76
+ function normalizeDoi(raw) {
77
+ return raw
78
+ .trim()
79
+ .replace(/^(?:https?:\/\/doi\.org\/|doi\.org\/|doi:)/i, '')
80
+ .toLowerCase();
81
+ }
82
+ /**
83
+ * Normalize one ISBN token to ISBN-13: a 13-digit ISBN passes through; a
84
+ * 10-char ISBN-10 converts (978 prefix + its first 9 digits, with a
85
+ * recomputed EAN-13 check digit); anything else is skipped.
86
+ */
87
+ function toIsbn13(raw) {
88
+ const cleaned = raw.replaceAll(/[^0-9Xx]/g, '').toUpperCase();
89
+ if (cleaned.length === 13)
90
+ return cleaned;
91
+ if (cleaned.length === 10) {
92
+ const core = `978${cleaned.slice(0, 9)}`;
93
+ let sum = 0;
94
+ for (let i = 0; i < 12; i++) {
95
+ sum += i % 2 === 0 ? Number(core[i]) : Number(core[i]) * 3;
96
+ }
97
+ const check = (10 - (sum % 10)) % 10;
98
+ return `${core}${check}`;
99
+ }
100
+ return undefined;
101
+ }
102
+ /** `archiveID` holds an `arXiv:<id>` prefix, or a bare id when `repository` says arXiv. */
103
+ function extractArxivFromArchiveId(archiveID, repository) {
104
+ if (!archiveID)
105
+ return undefined;
106
+ const trimmed = archiveID.trim();
107
+ const prefixed = /^arXiv:(.+)$/i.exec(trimmed);
108
+ if (prefixed)
109
+ return prefixed[1].trim();
110
+ if (repository === 'arXiv')
111
+ return trimmed;
112
+ return undefined;
113
+ }
114
+ /** Every `arXiv:<id>` line in the free-text `extra` field, stripping a trailing ` [cs.XX]` classification. */
115
+ function extractArxivIdsFromExtra(extra) {
116
+ if (!extra)
117
+ return [];
118
+ const ids = [];
119
+ for (const line of extra.split(/\r?\n/)) {
120
+ const match = /arXiv:\s*(\S+)/i.exec(line);
121
+ if (!match)
122
+ continue;
123
+ const id = match[1].replace(/\[[^\]]*\]$/, '').trim();
124
+ if (id)
125
+ ids.push(id);
126
+ }
127
+ return ids;
128
+ }
129
+ /** An arXiv-minted DOI (`10.48550/arxiv.<id>`) names the same paper by its arXiv id. */
130
+ function extractArxivIdFromDoi(normalizedDoi) {
131
+ if (!normalizedDoi)
132
+ return undefined;
133
+ const match = /^10\.48550\/arxiv\.(.+)$/i.exec(normalizedDoi);
134
+ return match ? match[1] : undefined;
135
+ }
136
+ const GENERIC_ATTACHMENT_TITLES = new Set([
137
+ 'pdf',
138
+ 'full text pdf',
139
+ 'snapshot',
140
+ 'ebook',
141
+ 'epub',
142
+ 'attachment',
143
+ ]);
144
+ /**
145
+ * Zotero auto-titles child attachments with generic labels ("PDF", "Full
146
+ * Text PDF", "Snapshot", "Ebook", "EPUB", "Attachment") that make a
147
+ * meaningless feed row — the filename is usually the descriptive one. Prefer
148
+ * the title unless it's one of those generic labels, in which case prefer
149
+ * the filename; fall back to the title when there's no filename to prefer.
150
+ * Both are real source data — this only chooses which one names the entity.
151
+ */
152
+ function attachmentDisplayName(attachment) {
153
+ const filename = filenameFromPath(attachment.path);
154
+ if (attachment.title && !GENERIC_ATTACHMENT_TITLES.has(attachment.title.trim().toLowerCase())) {
155
+ return attachment.title;
156
+ }
157
+ return filename ?? attachment.title;
158
+ }
159
+ const ANNOTATION_STYLES = {
160
+ 1: 'highlight',
161
+ 2: 'note',
162
+ 3: 'image',
163
+ 4: 'ink',
164
+ 5: 'underline',
165
+ 6: 'text',
166
+ };
167
+ /**
168
+ * A local Zotero library (`zotero.sqlite`) → Chronicle JSON-LD.
169
+ *
170
+ * Four record shapes come off the extractor: `work` (a reference item with
171
+ * its creators/collections/tags/attachments joined in), `annotation` (a
172
+ * highlight/note made in the reader, with its attachment and parent work
173
+ * context), `attachment` (an orphan file saved directly to the library, with
174
+ * no parent work), and `note` (an HTML note item, on a work or standalone).
175
+ * All four are re-read on every crawl (`snapshot` temporality), so mutable
176
+ * attributes — collection membership, tags, the last-read timestamp — are
177
+ * sighted at crawl time, not back-dated.
178
+ */
179
+ export default class ZoteroTransformer extends ChronicleTransformer {
180
+ static source = SOURCE;
181
+ async transform(record) {
182
+ const context = record.context;
183
+ switch (record.extraction.recordType) {
184
+ case 'works':
185
+ return this.buildWorkActions(record.data, context);
186
+ case 'annotations':
187
+ return this.buildAnnotationActions(record.data, context);
188
+ case 'attachments':
189
+ return this.buildOrphanAttachmentActions(record.data, context);
190
+ case 'notes':
191
+ return this.buildNoteActions(record.data, context);
192
+ default:
193
+ return [];
194
+ }
195
+ }
196
+ // ---------------------------------------------------------------------
197
+ // Agent / realm
198
+ // ---------------------------------------------------------------------
199
+ /**
200
+ * The library owner. The Zotero DB carries no name for the local user, only
201
+ * the install-stable `localUserKey`, so that's the whole identity. Not
202
+ * realm-scoped: a person is not a per-library thing.
203
+ */
204
+ buildAgent(context) {
205
+ return {
206
+ '@type': 'Person',
207
+ '@key': ['@type', 'source', 'sourceId'],
208
+ source: SOURCE,
209
+ sourceId: context.localUserKey,
210
+ sameAs: ['@me'],
211
+ };
212
+ }
213
+ /**
214
+ * The library's scope realm. Zotero item keys are only per-library unique,
215
+ * so every item-keyed entity below (works, attachments, Quotations,
216
+ * Comments, Collections) carries this in both its `@key` and `inRealm` —
217
+ * the obsidian note-key precedent, applied to a db-local id instead of a
218
+ * filename.
219
+ */
220
+ buildRealm(context) {
221
+ return {
222
+ '@type': 'Realm',
223
+ '@key': ['@type', 'source', 'handle'],
224
+ source: SOURCE,
225
+ handle: context.library,
226
+ };
227
+ }
228
+ // ---------------------------------------------------------------------
229
+ // work
230
+ // ---------------------------------------------------------------------
231
+ buildWorkActions(work, context) {
232
+ const me = this.buildAgent(context);
233
+ const workEntity = this.buildWorkEntity(work, context);
234
+ const workRef = this.buildWorkRef(work.key, work.itemType, context, work.fields.title);
235
+ // Zotero operates on the catalog-level work, not its files: one save
236
+ // gesture, one BookmarkAction, object = the work. Files never get their
237
+ // own save event — they enter the log only through viewing (below), so an
238
+ // attachment never opened in the reader appears nowhere in the output.
239
+ const actions = [
240
+ {
241
+ '@type': 'BookmarkAction',
242
+ '@key': ['@type', 'source', 'sourceId', 'object.inRealm.handle'],
243
+ source: SOURCE,
244
+ sourceId: work.key,
245
+ timestamp: parseZoteroDate(work.dateAdded),
246
+ agent: me,
247
+ object: workEntity,
248
+ },
249
+ ];
250
+ for (const attachment of work.attachments) {
251
+ if (attachment.lastRead) {
252
+ // The file is what was actually opened (lastRead is its own
253
+ // timestamp), so it's `object` — a sparse facet of the work, not a
254
+ // separate entity (option B): `sameAs` collapses it onto the work,
255
+ // so each viewing independently witnesses the merge regardless of
256
+ // fold order.
257
+ const facet = this.buildFileFacet(attachment, context, workRef);
258
+ actions.push(this.buildViewAction(attachment.key, attachment.lastRead, facet, me));
259
+ }
260
+ }
261
+ return actions;
262
+ }
263
+ /**
264
+ * `lastRead` is a single overwritten "last opened" timestamp, not an
265
+ * append-only log — key the timestamp itself in so each newly sighted
266
+ * value lands as its own event instead of one event that moves in place.
267
+ * It's `ViewAction`, not `ReadAction`: `ReadAction` opens a `consuming` run
268
+ * that only a `CompleteAction` closes, and Zotero has no completion signal
269
+ * — a `ReadAction` here would strand the work in an eternal
270
+ * "currently reading" state. A single sighted open is one-shot
271
+ * consumption; `ViewAction` is its honest weight.
272
+ */
273
+ buildViewAction(sourceId, lastRead, object, agent) {
274
+ return {
275
+ '@type': 'ViewAction',
276
+ '@key': ['@type', 'source', 'sourceId', 'object.inRealm.handle', 'timestamp'],
277
+ source: SOURCE,
278
+ sourceId,
279
+ timestamp: new Date(lastRead * 1000),
280
+ agent,
281
+ object,
282
+ };
283
+ }
284
+ buildWorkEntity(work, context) {
285
+ const type = mapWorkType(work.itemType);
286
+ const { fields } = work;
287
+ // A Zotero item key is unique only per library — the entity IS its key
288
+ // scoped to the library realm; the type is a refinable attribute
289
+ // (obsidian's note-key precedent: "a retag refines, never re-mints").
290
+ const node = {
291
+ '@type': type,
292
+ '@key': ['source', 'sourceId', 'inRealm.handle'],
293
+ source: SOURCE,
294
+ sourceId: work.key,
295
+ inRealm: this.buildRealm(context),
296
+ };
297
+ if (fields.title)
298
+ node.name = fields.title;
299
+ if (fields.abstractNote)
300
+ node.description = fields.abstractNote;
301
+ if (fields.date)
302
+ node.datePublished = parseDatePublished(fields.date);
303
+ // The zotero:// deep link is not the work's own address — it stays
304
+ // derivable from (source, sourceId) — so `url` carries only a real web
305
+ // address.
306
+ if (fields.url)
307
+ node.url = fields.url;
308
+ // pageCount only exists on the Book branch of the schema.
309
+ if (type === 'Book' && fields.numPages) {
310
+ const pages = Number.parseInt(fields.numPages, 10);
311
+ if (Number.isFinite(pages))
312
+ node.pageCount = pages;
313
+ }
314
+ if (fields.publisher)
315
+ node.publisher = [this.buildPublisher(fields.publisher)];
316
+ const { authors, creators } = this.buildCreators(work.creators);
317
+ if (authors.length > 0)
318
+ node.author = authors;
319
+ if (creators.length > 0)
320
+ node.creator = creators;
321
+ const sameAs = this.buildWorkSameAs(work, type);
322
+ if (sameAs.length > 0)
323
+ node.sameAs = sameAs;
324
+ const isPartOf = this.buildWorkIsPartOf(work, context);
325
+ if (isPartOf.length > 0)
326
+ node.isPartOf = isPartOf;
327
+ const about = this.buildTags(work.tags);
328
+ if (about.length > 0)
329
+ node.about = about;
330
+ // A crawl re-enumerates the work's full current collection membership and
331
+ // tag set, so assert completeness on both: un-filing a work from a
332
+ // collection or clearing a tag must close the old membership on the next
333
+ // crawl (the goodreads/pinboard `@asserts` idiom for a snapshot source).
334
+ node['@asserts'] = ['isPartOf', 'about'];
335
+ return node;
336
+ }
337
+ buildPublisher(name) {
338
+ return {
339
+ '@type': 'Organization',
340
+ '@key': ['@type', 'source', 'name'],
341
+ source: SOURCE,
342
+ name,
343
+ };
344
+ }
345
+ /**
346
+ * Creators ordered by `orderIndex`. `author` credits the primary authors;
347
+ * `editor`/`translator`/`contributor` (and any other credited role Zotero
348
+ * reports) land in the generic `creator` bucket; `seriesEditor` is skipped
349
+ * outright — it credits the series, not this work.
350
+ */
351
+ buildCreators(creators) {
352
+ const authors = [];
353
+ const creatorsOut = [];
354
+ const ordered = [...creators].sort((a, b) => a.orderIndex - b.orderIndex);
355
+ for (const creator of ordered) {
356
+ if (creator.creatorType === 'seriesEditor')
357
+ continue;
358
+ const node = this.buildCreatorNode(creator);
359
+ if (!node)
360
+ continue;
361
+ if (creator.creatorType === 'author')
362
+ authors.push(node);
363
+ else
364
+ creatorsOut.push(node);
365
+ }
366
+ return { authors, creators: creatorsOut };
367
+ }
368
+ /** fieldMode 0 = two-field person; fieldMode 1 = single-field institution (name in lastName). */
369
+ buildCreatorNode(creator) {
370
+ if (creator.fieldMode === 1) {
371
+ const name = creator.lastName?.trim();
372
+ if (!name)
373
+ return undefined;
374
+ return {
375
+ '@type': 'Organization',
376
+ '@key': ['@type', 'source', 'name'],
377
+ source: SOURCE,
378
+ name,
379
+ };
380
+ }
381
+ const name = [creator.firstName, creator.lastName]
382
+ .filter((part) => part !== null && part.trim().length > 0)
383
+ .join(' ')
384
+ .trim();
385
+ if (!name)
386
+ return undefined;
387
+ return {
388
+ '@type': 'Person',
389
+ '@key': ['@type', 'source', 'name'],
390
+ source: SOURCE,
391
+ name,
392
+ };
393
+ }
394
+ /**
395
+ * Protocol `sameAs` edges (the goodreads idiom): each is an entity in the
396
+ * other namespace, keyed there — type-free, since a DOI/ISBN/arXiv/PubMed
397
+ * id names one work however a source typed it — carrying the work's title
398
+ * as name so the edge still identifies the work if it's what mints it.
399
+ * ISSN is handled separately (`buildPeriodical`) — it identifies the
400
+ * periodical, not the work itself.
401
+ */
402
+ buildWorkSameAs(work, type) {
403
+ const { fields } = work;
404
+ const { title } = fields;
405
+ const edges = [];
406
+ if (fields.DOI) {
407
+ const doiEdge = this.buildDoiEdge(fields.DOI, type, title);
408
+ if (doiEdge)
409
+ edges.push(doiEdge);
410
+ }
411
+ // bookSection: Zotero stamps the containing BOOK's ISBN on every section
412
+ // row — kept here it would merge different chapters of one book onto a
413
+ // single isbn edge. Redirected onto a containing-book isPartOf entry
414
+ // instead (see buildContainingBook), or dropped when there's no
415
+ // bookTitle to hang it on.
416
+ if (work.itemType !== 'bookSection' && fields.ISBN) {
417
+ edges.push(...this.buildIsbnEdges(fields.ISBN, type, title));
418
+ }
419
+ edges.push(...this.buildArxivSameAs(fields, type, title), ...this.buildPubmedSameAs(fields, type, title));
420
+ return edges;
421
+ }
422
+ /**
423
+ * The DOI `sameAs` edge. Its own resolver url mints the url hub, so a
424
+ * browser visit to the DOI link folds onto the work.
425
+ */
426
+ buildDoiEdge(doiField, type, title) {
427
+ const doi = normalizeDoi(doiField);
428
+ if (!doi)
429
+ return undefined;
430
+ const node = {
431
+ '@type': type,
432
+ '@key': ['source', 'sourceId'],
433
+ source: 'doi',
434
+ sourceId: doi,
435
+ url: `https://doi.org/${doi}`,
436
+ };
437
+ if (title)
438
+ node.name = title;
439
+ return node;
440
+ }
441
+ /** ISBN `sameAs` edges — the field can hold several space-separated ISBNs. */
442
+ buildIsbnEdges(isbnField, type, title) {
443
+ const edges = [];
444
+ for (const token of isbnField.split(/\s+/).filter(Boolean)) {
445
+ const isbn13 = toIsbn13(token);
446
+ if (isbn13) {
447
+ edges.push({
448
+ '@type': type,
449
+ '@key': ['source', 'sourceId'],
450
+ source: 'isbn',
451
+ sourceId: isbn13,
452
+ ...(title && { name: title }),
453
+ });
454
+ }
455
+ }
456
+ return edges;
457
+ }
458
+ /**
459
+ * arXiv `sameAs` edges, mined from up to three places and deduped by id:
460
+ * `archiveID` (an `arXiv:<id>` prefix, or a bare id when `repository` says
461
+ * arXiv), an `arXiv:<id>` line in the free-text `extra` field, and an
462
+ * arXiv-minted DOI (`10.48550/arxiv.<id>`).
463
+ */
464
+ buildArxivSameAs(fields, type, title) {
465
+ const ids = new Set();
466
+ const fromArchiveId = extractArxivFromArchiveId(fields.archiveID, fields.repository);
467
+ if (fromArchiveId)
468
+ ids.add(fromArchiveId);
469
+ for (const id of extractArxivIdsFromExtra(fields.extra))
470
+ ids.add(id);
471
+ const fromDoi = fields.DOI ? extractArxivIdFromDoi(normalizeDoi(fields.DOI)) : undefined;
472
+ if (fromDoi)
473
+ ids.add(fromDoi);
474
+ return [...ids].map(id => {
475
+ const node = {
476
+ '@type': type,
477
+ '@key': ['source', 'sourceId'],
478
+ source: 'arxiv',
479
+ sourceId: id,
480
+ url: `https://arxiv.org/abs/${id}`,
481
+ };
482
+ if (title)
483
+ node.name = title;
484
+ return node;
485
+ });
486
+ }
487
+ /**
488
+ * PMID/PMCID `sameAs` edges, mined from `PMID: <digits>` / `PMCID:
489
+ * PMC<digits>` lines in the free-text `extra` field.
490
+ */
491
+ buildPubmedSameAs(fields, type, title) {
492
+ const { extra } = fields;
493
+ if (!extra)
494
+ return [];
495
+ const edges = [];
496
+ const pmid = /PMID:\s*(\d+)/i.exec(extra);
497
+ if (pmid) {
498
+ edges.push({
499
+ '@type': type,
500
+ '@key': ['source', 'sourceId'],
501
+ source: 'pubmed',
502
+ sourceId: pmid[1],
503
+ ...(title && { name: title }),
504
+ });
505
+ }
506
+ const pmcid = /PMCID:\s*(PMC\d+)/i.exec(extra);
507
+ if (pmcid) {
508
+ edges.push({
509
+ '@type': type,
510
+ '@key': ['source', 'sourceId'],
511
+ source: 'pmc',
512
+ sourceId: pmcid[1],
513
+ ...(title && { name: title }),
514
+ });
515
+ }
516
+ return edges;
517
+ }
518
+ buildWorkIsPartOf(work, context) {
519
+ const { fields } = work;
520
+ const isPartOf = [];
521
+ if (work.itemType === 'bookSection') {
522
+ const containingBook = this.buildContainingBook(fields);
523
+ if (containingBook)
524
+ isPartOf.push(containingBook);
525
+ }
526
+ if (fields.publicationTitle) {
527
+ isPartOf.push(this.buildPeriodical(fields.publicationTitle, fields.ISSN));
528
+ }
529
+ if (fields.series) {
530
+ isPartOf.push({
531
+ '@type': 'Collection',
532
+ '@key': ['@type', 'source', 'name'],
533
+ source: SOURCE,
534
+ name: fields.series,
535
+ });
536
+ }
537
+ for (const collection of work.collections) {
538
+ isPartOf.push(this.buildCollectionNode(collection, context));
539
+ }
540
+ return isPartOf;
541
+ }
542
+ /**
543
+ * The containing book of a bookSection, name-keyed (cross-library merge by
544
+ * title is fine — same idiom as the periodical/series below) and carrying
545
+ * the section's ISBN as its own `sameAs` edges, since that ISBN identifies
546
+ * the book, not the section. No bookTitle → nothing honest to key the book
547
+ * on, so the ISBN is dropped entirely (see buildWorkSameAs).
548
+ */
549
+ buildContainingBook(fields) {
550
+ if (!fields.bookTitle)
551
+ return undefined;
552
+ const book = {
553
+ '@type': 'Book',
554
+ '@key': ['@type', 'source', 'name'],
555
+ source: SOURCE,
556
+ name: fields.bookTitle,
557
+ };
558
+ if (fields.ISBN) {
559
+ const isbnEdges = this.buildIsbnEdges(fields.ISBN, 'Book', fields.bookTitle);
560
+ if (isbnEdges.length > 0)
561
+ book.sameAs = isbnEdges;
562
+ }
563
+ return book;
564
+ }
565
+ /** 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. */
566
+ buildPeriodical(publicationTitle, issnField) {
567
+ const periodical = {
568
+ '@type': 'CreativeWork',
569
+ '@key': ['@type', 'source', 'name'],
570
+ source: SOURCE,
571
+ name: publicationTitle,
572
+ };
573
+ if (issnField) {
574
+ const issns = issnField
575
+ .split(',')
576
+ .map(issn => issn.trim())
577
+ .filter(Boolean);
578
+ if (issns.length > 0) {
579
+ periodical.sameAs = issns.map(issn => ({
580
+ '@type': 'CreativeWork',
581
+ '@key': ['source', 'sourceId'],
582
+ source: 'issn',
583
+ sourceId: issn,
584
+ name: publicationTitle,
585
+ }));
586
+ }
587
+ }
588
+ return periodical;
589
+ }
590
+ /** A Zotero collection, realm-scoped (per-library keys), with its ancestor chain carried as nested `isPartOf`. */
591
+ buildCollectionNode(collection, context) {
592
+ const node = {
593
+ '@type': 'Collection',
594
+ '@key': ['@type', 'source', 'sourceId', 'inRealm.handle'],
595
+ source: SOURCE,
596
+ sourceId: collection.key,
597
+ name: collection.name,
598
+ inRealm: this.buildRealm(context),
599
+ };
600
+ if (collection.parent)
601
+ node.isPartOf = [this.buildCollectionNode(collection.parent, context)];
602
+ return node;
603
+ }
604
+ buildTags(tags) {
605
+ return tags.map(tag => ({
606
+ '@type': 'Tag',
607
+ '@key': ['@type', 'source', 'handle'],
608
+ source: SOURCE,
609
+ handle: tag.name,
610
+ }));
611
+ }
612
+ /**
613
+ * A child attachment as a SPARSE facet of its work, not a separate entity
614
+ * (option B): the file merges INTO the work via `sameAs`, so it carries no
615
+ * `name` of its own — the merged entity's name comes uncontested from the
616
+ * work facet. `sameAs` is the collapsing edge — the work ref, plus the
617
+ * filesystem node when this copy has one — replacing `instanceOf`, which
618
+ * this transformer no longer emits anywhere.
619
+ */
620
+ buildFileFacet(attachment, context, workRef) {
621
+ const type = mapAttachmentType(attachment.contentType);
622
+ const node = {
623
+ '@type': type,
624
+ '@key': ['source', 'sourceId', 'inRealm.handle'],
625
+ source: SOURCE,
626
+ sourceId: attachment.key,
627
+ inRealm: this.buildRealm(context),
628
+ };
629
+ if (attachment.contentType)
630
+ node.mimeType = attachment.contentType;
631
+ // The zotero://open-pdf deep link is not the attachment's own address —
632
+ // it stays derivable from (source, sourceId) — so `url` carries only a
633
+ // real web address (a linked-url/snapshot attachment's own url).
634
+ if (attachment.url)
635
+ node.url = attachment.url;
636
+ const sameAs = [workRef];
637
+ // A shared carrier (the same file/path attached to several works — an
638
+ // anthology PDF fanned across its chapter items) can't witness any one
639
+ // work's copy: welding it in would collapse those distinct works through
640
+ // the shared file. Withhold the carrier merge edges (filesystem sameAs,
641
+ // contentPath) for a shared carrier; the work sameAs, mimeType, and url
642
+ // still apply.
643
+ if (attachment.resolvedPath && !attachment.sharedCarrier) {
644
+ node.contentPath = attachment.resolvedPath;
645
+ sameAs.push(this.buildFilesystemSameAs(attachment.resolvedPath, type, context.machine));
646
+ }
647
+ node.sameAs = sameAs;
648
+ // linkMode 1 = an imported url snapshot: a captured copy of a web page,
649
+ // not an arbitrary file — point it at the url hub it's a snapshot of
650
+ // (the same edge browsing-history snapshots use), so the two auto-merge.
651
+ if (attachment.linkMode === 1 && attachment.url) {
652
+ node.snapshotOf = [{ '@type': 'Entity', '@key': ['url'], url: attachment.url }];
653
+ }
654
+ return node;
655
+ }
656
+ /**
657
+ * A sparse reference to the file facet — used off the annotation record
658
+ * (Quotation.isPartOf, the comment-only `about` fallback) so an anchored
659
+ * selector records which copy it's valid in. No name (sparse, like the
660
+ * facet); carries `sameAs: [work ref]` so an annotation arriving before
661
+ * its work record still witnesses the merge.
662
+ */
663
+ buildFileFacetRef(attachment, context, workRef) {
664
+ const node = {
665
+ '@type': mapAttachmentType(attachment.contentType),
666
+ '@key': ['source', 'sourceId', 'inRealm.handle'],
667
+ source: SOURCE,
668
+ sourceId: attachment.key,
669
+ inRealm: this.buildRealm(context),
670
+ sameAs: [workRef],
671
+ };
672
+ return node;
673
+ }
674
+ buildFilesystemSameAs(resolvedPath, leafType, machine) {
675
+ const node = {
676
+ '@type': leafType,
677
+ '@key': ['source', 'handle', 'inRealm.handle'],
678
+ source: 'filesystem',
679
+ handle: resolvedPath,
680
+ name: path.basename(resolvedPath),
681
+ inRealm: {
682
+ '@type': 'Realm',
683
+ '@key': ['@type', 'source', 'handle'],
684
+ source: 'hostname',
685
+ handle: machine,
686
+ },
687
+ };
688
+ return node;
689
+ }
690
+ /** A slim, typed, named, realm-scoped reference to a work — used off actions that don't own the work's full state. */
691
+ buildWorkRef(key, itemType, context, title) {
692
+ const node = {
693
+ '@type': mapWorkType(itemType),
694
+ '@key': ['source', 'sourceId', 'inRealm.handle'],
695
+ source: SOURCE,
696
+ sourceId: key,
697
+ inRealm: this.buildRealm(context),
698
+ };
699
+ if (title)
700
+ node.name = title;
701
+ return node;
702
+ }
703
+ /** A slim, typed, named, realm-scoped reference to an attachment (orphan-annotation subject, standalone-attachment ref). */
704
+ buildAttachmentRef(attachment, context) {
705
+ const node = {
706
+ '@type': mapAttachmentType(attachment.contentType),
707
+ '@key': ['source', 'sourceId', 'inRealm.handle'],
708
+ source: SOURCE,
709
+ sourceId: attachment.key,
710
+ inRealm: this.buildRealm(context),
711
+ };
712
+ const name = attachmentDisplayName(attachment);
713
+ if (name)
714
+ node.name = name;
715
+ return node;
716
+ }
717
+ // ---------------------------------------------------------------------
718
+ // annotation
719
+ // ---------------------------------------------------------------------
720
+ buildAnnotationActions(annotation, context) {
721
+ const hasText = Boolean(annotation.text);
722
+ const hasComment = Boolean(annotation.comment);
723
+ if (!hasText && !hasComment)
724
+ return [];
725
+ const me = this.buildAgent(context);
726
+ const realm = this.buildRealm(context);
727
+ // Anchoring the selector on the file facet (not the work ref) records
728
+ // which copy it's valid in; under the merge they resolve to the same
729
+ // entity either way. Orphans have no work, so their subject stays the
730
+ // attachment's own (named) entity.
731
+ const workRef = annotation.work
732
+ ? this.buildWorkRef(annotation.work.key, annotation.work.itemType, context, annotation.work.title)
733
+ : undefined;
734
+ const subjectRef = workRef
735
+ ? this.buildFileFacetRef(annotation.attachment, context, workRef)
736
+ : this.buildAttachmentRef(annotation.attachment, context);
737
+ const actions = [];
738
+ let quotation;
739
+ if (hasText) {
740
+ const style = ANNOTATION_STYLES[annotation.type];
741
+ quotation = {
742
+ '@type': 'Quotation',
743
+ '@key': ['@type', 'source', 'sourceId', 'inRealm.handle'],
744
+ source: SOURCE,
745
+ sourceId: annotation.key,
746
+ inRealm: realm,
747
+ body: annotation.text,
748
+ ...(annotation.color && { color: annotation.color }),
749
+ ...(style && { annotationStyle: style }),
750
+ isPartOf: [subjectRef],
751
+ };
752
+ const selector = this.buildSelector(annotation.position, annotation.pageLabel);
753
+ if (selector)
754
+ quotation.selector = [selector];
755
+ actions.push({
756
+ '@type': 'QuoteAction',
757
+ '@key': ['@type', 'source', 'sourceId', 'result.inRealm.handle'],
758
+ source: SOURCE,
759
+ sourceId: annotation.key,
760
+ timestamp: parseZoteroDate(annotation.dateAdded),
761
+ agent: me,
762
+ result: quotation,
763
+ });
764
+ }
765
+ if (hasComment) {
766
+ const comment = {
767
+ '@type': 'Comment',
768
+ '@key': ['@type', 'source', 'sourceId', 'inRealm.handle'],
769
+ source: SOURCE,
770
+ sourceId: annotation.key,
771
+ inRealm: realm,
772
+ description: annotation.comment,
773
+ about: [quotation ?? subjectRef],
774
+ };
775
+ actions.push({
776
+ '@type': 'AnnotateAction',
777
+ '@key': ['@type', 'source', 'sourceId', 'result.inRealm.handle'],
778
+ source: SOURCE,
779
+ sourceId: annotation.key,
780
+ timestamp: parseZoteroDate(annotation.dateAdded),
781
+ agent: me,
782
+ result: comment,
783
+ });
784
+ }
785
+ return actions;
786
+ }
787
+ /** Parses the raw `position` JSON defensively; omits the selector on any failure or unrecognized shape. */
788
+ buildSelector(positionJson, pageLabel) {
789
+ let position;
790
+ try {
791
+ position = JSON.parse(positionJson);
792
+ }
793
+ catch {
794
+ return undefined;
795
+ }
796
+ if (!position || typeof position !== 'object')
797
+ return undefined;
798
+ const pos = position;
799
+ // PDF: 0-based pageIndex → the 1-based page number readers display. Keyed
800
+ // on its own value, like every selector (no source/sourceId — the
801
+ // selector isn't its own identity-bearing subject).
802
+ if (typeof pos.pageIndex === 'number') {
803
+ const selector = {
804
+ '@type': 'PageSelector',
805
+ '@key': ['@type', 'selectorValue'],
806
+ selectorValue: String(pos.pageIndex + 1),
807
+ };
808
+ if (pageLabel)
809
+ selector.selectorLabel = pageLabel;
810
+ return selector;
811
+ }
812
+ // EPUB: an EPUB CFI fragment selector.
813
+ if (typeof pos.value === 'string' && pos.value.startsWith('epubcfi(')) {
814
+ return {
815
+ '@type': 'EpubCfiSelector',
816
+ '@key': ['@type', 'selectorValue'],
817
+ selectorValue: pos.value,
818
+ };
819
+ }
820
+ return undefined;
821
+ }
822
+ // ---------------------------------------------------------------------
823
+ // attachment (orphan)
824
+ // ---------------------------------------------------------------------
825
+ buildOrphanAttachmentActions(attachment, context) {
826
+ const me = this.buildAgent(context);
827
+ const entity = this.buildStandaloneAttachmentEntity(attachment, context);
828
+ // An orphan attachment IS the catalog item — no work sits above it — so,
829
+ // unlike a child attachment, it gets its own BookmarkAction. Its
830
+ // ViewAction's object is the same slim attachment ref either way.
831
+ const actions = [
832
+ {
833
+ '@type': 'BookmarkAction',
834
+ '@key': ['@type', 'source', 'sourceId', 'object.inRealm.handle'],
835
+ source: SOURCE,
836
+ sourceId: attachment.key,
837
+ timestamp: parseZoteroDate(attachment.dateAdded),
838
+ agent: me,
839
+ object: entity,
840
+ },
841
+ ];
842
+ if (attachment.lastRead) {
843
+ actions.push(this.buildViewAction(attachment.key, attachment.lastRead, this.buildAttachmentRef(attachment, context), me));
844
+ }
845
+ return actions;
846
+ }
847
+ /**
848
+ * A standalone (orphan) attachment: unlike a child attachment it IS the
849
+ * catalog item, not a facet of anything — its own named entity (via the
850
+ * display-name heuristic), unconditional filesystem `sameAs` (orphans
851
+ * never set `sharedCarrier`), plus its own collections/tags.
852
+ */
853
+ buildStandaloneAttachmentEntity(attachment, context) {
854
+ const type = mapAttachmentType(attachment.contentType);
855
+ const node = {
856
+ '@type': type,
857
+ '@key': ['source', 'sourceId', 'inRealm.handle'],
858
+ source: SOURCE,
859
+ sourceId: attachment.key,
860
+ inRealm: this.buildRealm(context),
861
+ };
862
+ const name = attachmentDisplayName(attachment);
863
+ if (name)
864
+ node.name = name;
865
+ if (attachment.contentType)
866
+ node.mimeType = attachment.contentType;
867
+ if (attachment.resolvedPath)
868
+ node.contentPath = attachment.resolvedPath;
869
+ // The zotero://open-pdf deep link is not the attachment's own address —
870
+ // it stays derivable from (source, sourceId) — so `url` carries only a
871
+ // real web address (a linked-url/snapshot attachment's own url).
872
+ if (attachment.url)
873
+ node.url = attachment.url;
874
+ // linkMode 1 = an imported url snapshot: a captured copy of a web page,
875
+ // not an arbitrary file — point it at the url hub it's a snapshot of
876
+ // (the same edge browsing-history snapshots use), so the two auto-merge.
877
+ if (attachment.linkMode === 1 && attachment.url) {
878
+ node.snapshotOf = [{ '@type': 'Entity', '@key': ['url'], url: attachment.url }];
879
+ }
880
+ // The attachment stays PRIMARILY keyed on its Zotero (source, sourceId,
881
+ // inRealm.handle): that identity is portable across machines under
882
+ // Zotero storage sync, while a filesystem path is per-machine. Each
883
+ // machine's crawl contributes its own filesystem `sameAs` edge, matching
884
+ // timing's `buildResource`/`buildRealm` keyset exactly so a timing
885
+ // app-session on the same file folds onto this attachment.
886
+ if (attachment.resolvedPath) {
887
+ node.sameAs = [this.buildFilesystemSameAs(attachment.resolvedPath, type, context.machine)];
888
+ }
889
+ const isPartOf = attachment.collections.map(collection => this.buildCollectionNode(collection, context));
890
+ if (isPartOf.length > 0)
891
+ node.isPartOf = isPartOf;
892
+ const about = this.buildTags(attachment.tags);
893
+ if (about.length > 0)
894
+ node.about = about;
895
+ // Same snapshot-source completeness idiom as the work entity: a crawl
896
+ // re-enumerates this attachment's full collection membership and tags.
897
+ node['@asserts'] = ['isPartOf', 'about'];
898
+ return node;
899
+ }
900
+ // ---------------------------------------------------------------------
901
+ // note
902
+ // ---------------------------------------------------------------------
903
+ /**
904
+ * A Zotero note item — an HTML note written on a work (child) or standing
905
+ * alone in the library — becomes an AnnotateAction over a Comment. Unlike
906
+ * a highlight-comment (which shares its parent annotation's key), this
907
+ * Comment is keyed on the note item's OWN key; the keyset shape is
908
+ * identical either way, so there's no collision.
909
+ */
910
+ buildNoteActions(note, context) {
911
+ const me = this.buildAgent(context);
912
+ const comment = {
913
+ '@type': 'Comment',
914
+ '@key': ['@type', 'source', 'sourceId', 'inRealm.handle'],
915
+ source: SOURCE,
916
+ sourceId: note.key,
917
+ inRealm: this.buildRealm(context),
918
+ // Zotero's own HTML, wrapper divs included, verbatim — the source
919
+ // truth; a display layer may strip it, the transformer never does.
920
+ body: note.note,
921
+ };
922
+ if (note.title)
923
+ comment.name = note.title;
924
+ if (note.work) {
925
+ comment.about = [
926
+ this.buildWorkRef(note.work.key, note.work.itemType, context, note.work.title),
927
+ ];
928
+ }
929
+ return [
930
+ {
931
+ '@type': 'AnnotateAction',
932
+ '@key': ['@type', 'source', 'sourceId', 'result.inRealm.handle'],
933
+ source: SOURCE,
934
+ sourceId: note.key,
935
+ timestamp: parseZoteroDate(note.dateAdded),
936
+ agent: me,
937
+ result: comment,
938
+ },
939
+ ];
940
+ }
941
+ }