@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,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
|
+
}
|