@cyanheads/pubmed-mcp-server 2.10.10 → 2.10.11
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/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +10 -3
- package/dist/mcp-server/tools/definitions/_schemas.d.ts +16 -0
- package/dist/mcp-server/tools/definitions/_schemas.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_schemas.js +20 -0
- package/dist/mcp-server/tools/definitions/_schemas.js.map +1 -1
- package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts +6 -0
- package/dist/mcp-server/tools/definitions/convert-ids.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/convert-ids.tool.js +28 -3
- package/dist/mcp-server/tools/definitions/convert-ids.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +27 -0
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js +166 -24
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +10 -6
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +4 -0
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.js +40 -4
- package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.js +11 -13
- package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +42 -8
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +4 -0
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.js +37 -2
- package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
- package/dist/services/error-contracts.d.ts +17 -0
- package/dist/services/error-contracts.d.ts.map +1 -1
- package/dist/services/error-contracts.js +19 -0
- package/dist/services/error-contracts.js.map +1 -1
- package/dist/services/ncbi/formatting/citation-formatter.d.ts +0 -29
- package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
- package/dist/services/ncbi/formatting/citation-formatter.js +381 -30
- package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
- package/dist/services/ncbi/ncbi-service.d.ts +3 -2
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js +22 -10
- package/dist/services/ncbi/ncbi-service.js.map +1 -1
- package/dist/services/ncbi/parsing/article-parser.d.ts +45 -2
- package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/article-parser.js +208 -1
- package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/esummary-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/esummary-parser.js +32 -1
- package/dist/services/ncbi/parsing/esummary-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.js +7 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
- package/dist/services/ncbi/response-handler.d.ts.map +1 -1
- package/dist/services/ncbi/response-handler.js +42 -1
- package/dist/services/ncbi/response-handler.js.map +1 -1
- package/dist/services/ncbi/types.d.ts +172 -2
- package/dist/services/ncbi/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/server.json +3 -3
|
@@ -4,22 +4,90 @@
|
|
|
4
4
|
* Pure TypeScript, zero dependencies, Workers-compatible.
|
|
5
5
|
* @module src/services/ncbi/formatting/citation-formatter
|
|
6
6
|
*/
|
|
7
|
+
/**
|
|
8
|
+
* Whether a record cites a book rather than a journal article. Dispatch is on
|
|
9
|
+
* `recordType`, never on `publicationTypes`: a Bookshelf record's publication
|
|
10
|
+
* type is `Review` or `Study Guide`, so type strings cannot tell the two apart.
|
|
11
|
+
*/
|
|
12
|
+
function isBookRecord(article) {
|
|
13
|
+
return article.recordType !== 'journal-article' && article.book !== undefined;
|
|
14
|
+
}
|
|
7
15
|
// ---------------------------------------------------------------------------
|
|
8
16
|
// Helpers
|
|
9
17
|
// ---------------------------------------------------------------------------
|
|
10
18
|
/**
|
|
11
19
|
* Extract the publication year from a ParsedArticle.
|
|
12
|
-
* Prefers `journalInfo.publicationDate.year
|
|
13
|
-
* `articleDates` entry (
|
|
14
|
-
* Returns 'n.d.' (no date) when no year is available.
|
|
20
|
+
* Prefers `journalInfo.publicationDate.year`, then the containing book's own
|
|
21
|
+
* date, then the earliest `articleDates` entry (for a book, its contribution
|
|
22
|
+
* date) before giving up. Returns 'n.d.' (no date) when no year is available.
|
|
15
23
|
*/
|
|
16
24
|
function getYear(article) {
|
|
17
25
|
const journalYear = article.journalInfo?.publicationDate?.year;
|
|
18
26
|
if (journalYear)
|
|
19
27
|
return journalYear;
|
|
28
|
+
const bookYear = article.book?.pubDate ?? article.book?.beginningDate ?? article.book?.endingDate;
|
|
29
|
+
if (bookYear)
|
|
30
|
+
return bookYear;
|
|
20
31
|
const articleYear = article.articleDates?.find((d) => d.year)?.year;
|
|
21
32
|
return articleYear ?? 'n.d.';
|
|
22
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* The date NLM prints for a book: a closed range for a book published over
|
|
36
|
+
* several years (`1993-2026`), otherwise the single publication year.
|
|
37
|
+
*/
|
|
38
|
+
function bookDateSpan(book) {
|
|
39
|
+
if (book.beginningDate && book.endingDate && book.beginningDate !== book.endingDate) {
|
|
40
|
+
return `${book.beginningDate}-${book.endingDate}`;
|
|
41
|
+
}
|
|
42
|
+
return book.pubDate ?? book.beginningDate ?? book.endingDate;
|
|
43
|
+
}
|
|
44
|
+
/** `Place: Publisher`, or whichever half the record carries. */
|
|
45
|
+
function bookImprint(book) {
|
|
46
|
+
if (book.publisherLocation && book.publisher) {
|
|
47
|
+
return `${book.publisherLocation}: ${book.publisher}`;
|
|
48
|
+
}
|
|
49
|
+
return book.publisher ?? book.publisherLocation;
|
|
50
|
+
}
|
|
51
|
+
/** Where the book is readable — the NCBI Bookshelf permalink. */
|
|
52
|
+
function bookshelfUrl(book) {
|
|
53
|
+
return book.accession ? `https://www.ncbi.nlm.nih.gov/books/${book.accession}/` : undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The DOI a citation may carry — the record's own, and on a whole book the
|
|
57
|
+
* book-level `Book/ELocationID` as a fallback. A chapter never inherits the
|
|
58
|
+
* containing book's DOI: that identifier resolves to the book, so citing a
|
|
59
|
+
* chapter with it points a reader at the wrong work. (#114)
|
|
60
|
+
*/
|
|
61
|
+
function citableDoi(article) {
|
|
62
|
+
if (article.doi)
|
|
63
|
+
return article.doi;
|
|
64
|
+
return article.recordType === 'book' ? article.book?.doi : undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The access URL for a book citation: the DOI when the record has one — APA and
|
|
68
|
+
* most style guides prefer it — and the Bookshelf permalink otherwise.
|
|
69
|
+
*/
|
|
70
|
+
function bookAccessUrl(article) {
|
|
71
|
+
const doi = citableDoi(article);
|
|
72
|
+
return doi ? `https://doi.org/${doi}` : bookshelfUrl(article.book);
|
|
73
|
+
}
|
|
74
|
+
/** Book title with its medium marker, e.g. `GeneReviews® [Internet]`. */
|
|
75
|
+
function bookTitleWithMedium(book) {
|
|
76
|
+
if (!book.title)
|
|
77
|
+
return;
|
|
78
|
+
return book.medium ? `${book.title} [${book.medium}]` : book.title;
|
|
79
|
+
}
|
|
80
|
+
/** Strip a single trailing period so the caller can add its own. */
|
|
81
|
+
function stripTrailingPeriod(text) {
|
|
82
|
+
return text.replace(/\.\s*$/, '');
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* End a segment with exactly one period. An author list already closing on
|
|
86
|
+
* "et al." or on an initial keeps the period it has rather than gaining a second.
|
|
87
|
+
*/
|
|
88
|
+
function terminate(text) {
|
|
89
|
+
return text.endsWith('.') ? text : `${text}.`;
|
|
90
|
+
}
|
|
23
91
|
/**
|
|
24
92
|
* Split a pages string like "45-67" into start and end components.
|
|
25
93
|
* Handles en-dashes, em-dashes, and hyphens. Expands PubMed's truncated-end
|
|
@@ -38,6 +106,19 @@ function splitPages(pages) {
|
|
|
38
106
|
return { start, end };
|
|
39
107
|
return start ? { start } : {};
|
|
40
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The electronic article locator that stands in for a page range, or undefined.
|
|
111
|
+
*
|
|
112
|
+
* Returns a value only when the record carries no pagination. Publishers that
|
|
113
|
+
* report the article number as both `Pagination` and `ELocationID` (PLoS ONE,
|
|
114
|
+
* Scientific Reports) are already covered by the `pages` rendering, and printing
|
|
115
|
+
* both would duplicate the number in every style.
|
|
116
|
+
*/
|
|
117
|
+
function articleLocator(journal) {
|
|
118
|
+
if (journal?.pages)
|
|
119
|
+
return;
|
|
120
|
+
return journal?.elocationId || undefined;
|
|
121
|
+
}
|
|
41
122
|
/**
|
|
42
123
|
* Collapse internal whitespace (including embedded newlines from structured
|
|
43
124
|
* abstracts) to single spaces. Strict RIS parsers treat blank lines as record
|
|
@@ -46,16 +127,18 @@ function splitPages(pages) {
|
|
|
46
127
|
function collapseWhitespace(text) {
|
|
47
128
|
return text.replace(/\s+/g, ' ').trim();
|
|
48
129
|
}
|
|
49
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* PubMed `PublicationType` → BibTeX entry type. Defaults to `article`.
|
|
132
|
+
* Book records never reach this map — they dispatch on `recordType` instead,
|
|
133
|
+
* because PubMed labels a Bookshelf record `Review` or `Study Guide`.
|
|
134
|
+
*/
|
|
50
135
|
const BIBTEX_ENTRY_TYPE = {
|
|
51
136
|
Book: 'book',
|
|
52
|
-
'Book Chapter': 'inbook',
|
|
53
137
|
Preprint: 'misc',
|
|
54
138
|
};
|
|
55
139
|
/** PubMed `PublicationType` → RIS reference type. Defaults to `JOUR`. */
|
|
56
140
|
const RIS_REFERENCE_TYPE = {
|
|
57
141
|
Book: 'BOOK',
|
|
58
|
-
'Book Chapter': 'CHAP',
|
|
59
142
|
Preprint: 'GEN',
|
|
60
143
|
};
|
|
61
144
|
function firstMappedType(types, map, fallback) {
|
|
@@ -203,13 +286,86 @@ function formatAuthorBibtex(author) {
|
|
|
203
286
|
* Authors (Year). Title. *Journal*, *Volume*(Issue), Pages. https://doi.org/DOI
|
|
204
287
|
* ```
|
|
205
288
|
*/
|
|
289
|
+
/**
|
|
290
|
+
* Format an editor in APA's "In E. E. Editor (Ed.)," position: initials first,
|
|
291
|
+
* surname last — the inverse of the author position.
|
|
292
|
+
*/
|
|
293
|
+
function formatEditorApa(editor) {
|
|
294
|
+
if (editor.collectiveName)
|
|
295
|
+
return editor.collectiveName;
|
|
296
|
+
const initialsSource = editor.initials ??
|
|
297
|
+
editor.firstName
|
|
298
|
+
?.split(/[\s-]+/)
|
|
299
|
+
.filter(Boolean)
|
|
300
|
+
.map((part) => part[0])
|
|
301
|
+
.join('');
|
|
302
|
+
const initials = initialsSource
|
|
303
|
+
? Array.from(initialsSource.replace(/[^\p{L}]/gu, ''))
|
|
304
|
+
.map((c) => `${c}.`)
|
|
305
|
+
.join(' ')
|
|
306
|
+
: '';
|
|
307
|
+
return [initials, editor.lastName].filter(Boolean).join(' ');
|
|
308
|
+
}
|
|
309
|
+
/** APA editor list: comma-separated, `& ` before the last name. */
|
|
310
|
+
function formatEditorsApa(editors) {
|
|
311
|
+
const names = editors.map(formatEditorApa).filter(Boolean);
|
|
312
|
+
if (names.length === 0)
|
|
313
|
+
return '';
|
|
314
|
+
if (names.length === 1)
|
|
315
|
+
return names[0] ?? '';
|
|
316
|
+
return `${names.slice(0, -1).join(', ')}, & ${names.at(-1)}`;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Format a Bookshelf record as an APA 7th edition citation.
|
|
320
|
+
*
|
|
321
|
+
* Chapter in an edited book (APA 7 §10.3):
|
|
322
|
+
* ```
|
|
323
|
+
* Authors (Year). Chapter title. In E. Editor (Ed.), *Book title*. Publisher. URL
|
|
324
|
+
* ```
|
|
325
|
+
* A whole-book record drops the `In …` clause and italicizes its own title.
|
|
326
|
+
*/
|
|
327
|
+
function formatApaBook(article) {
|
|
328
|
+
const { book } = article;
|
|
329
|
+
const parts = [];
|
|
330
|
+
const authorStr = article.authors?.length ? formatAuthorsApa(article.authors) : '';
|
|
331
|
+
if (authorStr) {
|
|
332
|
+
parts.push(terminate(authorStr));
|
|
333
|
+
}
|
|
334
|
+
else if (article.recordType === 'book' && book.editors?.length) {
|
|
335
|
+
// APA 7: an edited book with no authors of its own is cited from the editor
|
|
336
|
+
// position — `Last, F. M. (Ed.).` — rather than opening on the year.
|
|
337
|
+
parts.push(`${formatAuthorsApa(book.editors)} (${book.editors.length > 1 ? 'Eds.' : 'Ed.'}).`);
|
|
338
|
+
}
|
|
339
|
+
parts.push(`(${getYear(article)}).`);
|
|
340
|
+
if (article.recordType === 'book-chapter') {
|
|
341
|
+
if (article.title)
|
|
342
|
+
parts.push(`${stripTrailingPeriod(article.title)}.`);
|
|
343
|
+
const editorStr = book.editors?.length ? formatEditorsApa(book.editors) : '';
|
|
344
|
+
const editorLabel = (book.editors?.length ?? 0) > 1 ? 'Eds.' : 'Ed.';
|
|
345
|
+
const container = book.title ? `*${stripTrailingPeriod(book.title)}*` : '';
|
|
346
|
+
if (container) {
|
|
347
|
+
parts.push(editorStr ? `In ${editorStr} (${editorLabel}), ${container}.` : `In ${container}.`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else if (book.title) {
|
|
351
|
+
parts.push(`*${stripTrailingPeriod(book.title)}*.`);
|
|
352
|
+
}
|
|
353
|
+
if (book.publisher)
|
|
354
|
+
parts.push(`${book.publisher}.`);
|
|
355
|
+
const url = bookAccessUrl(article);
|
|
356
|
+
if (url)
|
|
357
|
+
parts.push(url);
|
|
358
|
+
return parts.join(' ');
|
|
359
|
+
}
|
|
206
360
|
export function formatApa(article) {
|
|
361
|
+
if (isBookRecord(article))
|
|
362
|
+
return formatApaBook(article);
|
|
207
363
|
const parts = [];
|
|
208
364
|
// Authors — ensure trailing period (individual author initials end with '.',
|
|
209
365
|
// but collective names do not, which would otherwise produce "Name (Year).")
|
|
210
366
|
const authorStr = article.authors?.length ? formatAuthorsApa(article.authors) : '';
|
|
211
367
|
if (authorStr) {
|
|
212
|
-
parts.push(
|
|
368
|
+
parts.push(terminate(authorStr));
|
|
213
369
|
}
|
|
214
370
|
// Year
|
|
215
371
|
const year = getYear(article);
|
|
@@ -217,7 +373,7 @@ export function formatApa(article) {
|
|
|
217
373
|
// Title — use as-is from PubMed (sentence case already assumed)
|
|
218
374
|
if (article.title) {
|
|
219
375
|
// Strip trailing period from title if present; we add our own
|
|
220
|
-
const title = article.title
|
|
376
|
+
const title = stripTrailingPeriod(article.title);
|
|
221
377
|
parts.push(`${title}.`);
|
|
222
378
|
}
|
|
223
379
|
// Journal, volume, issue, pages
|
|
@@ -233,6 +389,12 @@ export function formatApa(article) {
|
|
|
233
389
|
if (journal.pages) {
|
|
234
390
|
journalPart += `, ${journal.pages}`;
|
|
235
391
|
}
|
|
392
|
+
else {
|
|
393
|
+
// APA 7 p. 294-295: an article number takes the page range's place
|
|
394
|
+
const locator = articleLocator(journal);
|
|
395
|
+
if (locator)
|
|
396
|
+
journalPart += `, Article ${locator}`;
|
|
397
|
+
}
|
|
236
398
|
journalPart += '.';
|
|
237
399
|
parts.push(journalPart);
|
|
238
400
|
}
|
|
@@ -253,17 +415,68 @@ export function formatApa(article) {
|
|
|
253
415
|
* Last, First, et al. "Title." *Journal*, vol. 12, no. 3, 2024, pp. 45-67. DOI.
|
|
254
416
|
* ```
|
|
255
417
|
*/
|
|
418
|
+
/** MLA renders editors first-name-first after `edited by`. */
|
|
419
|
+
function formatEditorsMla(editors) {
|
|
420
|
+
const names = editors
|
|
421
|
+
.map((editor) => editor.collectiveName
|
|
422
|
+
? editor.collectiveName
|
|
423
|
+
: [editor.firstName, editor.lastName].filter(Boolean).join(' '))
|
|
424
|
+
.filter(Boolean);
|
|
425
|
+
if (names.length === 0)
|
|
426
|
+
return '';
|
|
427
|
+
if (names.length === 1)
|
|
428
|
+
return names[0] ?? '';
|
|
429
|
+
if (names.length === 2)
|
|
430
|
+
return `${names[0]} and ${names[1]}`;
|
|
431
|
+
return `${names[0]}, et al.`;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Format a Bookshelf record as an MLA 9th edition citation.
|
|
435
|
+
*
|
|
436
|
+
* ```
|
|
437
|
+
* Author. "Chapter Title." *Book Title*, edited by E. Editor, Publisher, Year.
|
|
438
|
+
* ```
|
|
439
|
+
* A whole-book record italicizes its own title in place of the quoted chapter.
|
|
440
|
+
*/
|
|
441
|
+
function formatMlaBook(article) {
|
|
442
|
+
const { book } = article;
|
|
443
|
+
const parts = [];
|
|
444
|
+
const authorStr = article.authors?.length ? formatAuthorsMla(article.authors) : '';
|
|
445
|
+
if (authorStr)
|
|
446
|
+
parts.push(terminate(authorStr));
|
|
447
|
+
if (article.recordType === 'book-chapter' && article.title) {
|
|
448
|
+
parts.push(`"${stripTrailingPeriod(article.title)}."`);
|
|
449
|
+
}
|
|
450
|
+
const detailParts = [];
|
|
451
|
+
if (book.title)
|
|
452
|
+
detailParts.push(`*${stripTrailingPeriod(book.title)}*`);
|
|
453
|
+
const editorStr = book.editors?.length ? formatEditorsMla(book.editors) : '';
|
|
454
|
+
if (editorStr)
|
|
455
|
+
detailParts.push(`edited by ${editorStr}`);
|
|
456
|
+
if (book.edition)
|
|
457
|
+
detailParts.push(book.edition);
|
|
458
|
+
if (book.publisher)
|
|
459
|
+
detailParts.push(book.publisher);
|
|
460
|
+
const year = getYear(article);
|
|
461
|
+
if (year !== 'n.d.')
|
|
462
|
+
detailParts.push(year);
|
|
463
|
+
if (detailParts.length)
|
|
464
|
+
parts.push(`${detailParts.join(', ')}.`);
|
|
465
|
+
return parts.join(' ');
|
|
466
|
+
}
|
|
256
467
|
export function formatMla(article) {
|
|
468
|
+
if (isBookRecord(article))
|
|
469
|
+
return formatMlaBook(article);
|
|
257
470
|
const parts = [];
|
|
258
471
|
// Authors
|
|
259
472
|
const authorStr = article.authors?.length ? formatAuthorsMla(article.authors) : '';
|
|
260
473
|
if (authorStr) {
|
|
261
474
|
// Ensure author string ends with period
|
|
262
|
-
parts.push(
|
|
475
|
+
parts.push(terminate(authorStr));
|
|
263
476
|
}
|
|
264
477
|
// Title in quotes
|
|
265
478
|
if (article.title) {
|
|
266
|
-
const title = article.title
|
|
479
|
+
const title = stripTrailingPeriod(article.title);
|
|
267
480
|
parts.push(`"${title}."`);
|
|
268
481
|
}
|
|
269
482
|
// Journal and publication details
|
|
@@ -286,6 +499,13 @@ export function formatMla(article) {
|
|
|
286
499
|
const isRange = /[-\u2013\u2014]/.test(journal.pages);
|
|
287
500
|
detailParts.push(`${isRange ? 'pp.' : 'p.'} ${journal.pages}`);
|
|
288
501
|
}
|
|
502
|
+
else {
|
|
503
|
+
// MLA 9 codifies no article-number form; citation guides converge on
|
|
504
|
+
// "art. <value>" in the page position.
|
|
505
|
+
const locator = articleLocator(journal);
|
|
506
|
+
if (locator)
|
|
507
|
+
detailParts.push(`art. ${locator}`);
|
|
508
|
+
}
|
|
289
509
|
parts.push(`${detailParts.join(', ')}.`);
|
|
290
510
|
}
|
|
291
511
|
// DOI
|
|
@@ -312,7 +532,15 @@ export function formatMla(article) {
|
|
|
312
532
|
*/
|
|
313
533
|
export function formatBibtex(article) {
|
|
314
534
|
const key = `pmid${article.pmid}`;
|
|
315
|
-
const
|
|
535
|
+
const book = isBookRecord(article) ? article.book : undefined;
|
|
536
|
+
// `@incollection` is a chapter in a book gathered from several contributors;
|
|
537
|
+
// `@inbook` is a part attributed to the book's own author, which a Bookshelf
|
|
538
|
+
// chapter is not. A whole-book record is plainly `@book`.
|
|
539
|
+
const entryType = book
|
|
540
|
+
? article.recordType === 'book'
|
|
541
|
+
? 'book'
|
|
542
|
+
: 'incollection'
|
|
543
|
+
: firstMappedType(article.publicationTypes, BIBTEX_ENTRY_TYPE, 'article');
|
|
316
544
|
const fields = [];
|
|
317
545
|
// Authors
|
|
318
546
|
if (article.authors?.length) {
|
|
@@ -320,16 +548,34 @@ export function formatBibtex(article) {
|
|
|
320
548
|
if (authorStr)
|
|
321
549
|
fields.push(['author', authorStr]);
|
|
322
550
|
}
|
|
323
|
-
// Title — strip trailing period; biblatex styles append their own
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
551
|
+
// Title — strip trailing period; biblatex styles append their own.
|
|
552
|
+
// A whole-book record's own title is the book title, so it is not repeated
|
|
553
|
+
// as a `booktitle` below.
|
|
554
|
+
const title = book && article.recordType === 'book' ? book.title : article.title;
|
|
555
|
+
if (title) {
|
|
556
|
+
fields.push(['title', `{${escapeBibtex(stripTrailingPeriod(title))}}`]);
|
|
327
557
|
}
|
|
328
|
-
//
|
|
558
|
+
// Container — the journal, or the book a chapter sits in
|
|
329
559
|
const journal = article.journalInfo;
|
|
330
560
|
if (journal?.title) {
|
|
331
561
|
fields.push(['journal', escapeBibtex(journal.title)]);
|
|
332
562
|
}
|
|
563
|
+
if (book && article.recordType === 'book-chapter' && book.title) {
|
|
564
|
+
fields.push(['booktitle', escapeBibtex(book.title)]);
|
|
565
|
+
}
|
|
566
|
+
if (book?.editors?.length) {
|
|
567
|
+
const editorStr = book.editors.map(formatAuthorBibtex).filter(Boolean).join(' and ');
|
|
568
|
+
if (editorStr)
|
|
569
|
+
fields.push(['editor', editorStr]);
|
|
570
|
+
}
|
|
571
|
+
if (book?.publisher)
|
|
572
|
+
fields.push(['publisher', escapeBibtex(book.publisher)]);
|
|
573
|
+
if (book?.publisherLocation)
|
|
574
|
+
fields.push(['address', escapeBibtex(book.publisherLocation)]);
|
|
575
|
+
if (book?.edition)
|
|
576
|
+
fields.push(['edition', escapeBibtex(book.edition)]);
|
|
577
|
+
if (book?.collectionTitle)
|
|
578
|
+
fields.push(['series', escapeBibtex(book.collectionTitle)]);
|
|
333
579
|
// Year
|
|
334
580
|
const year = getYear(article);
|
|
335
581
|
if (year !== 'n.d.') {
|
|
@@ -343,18 +589,30 @@ export function formatBibtex(article) {
|
|
|
343
589
|
if (journal?.issue) {
|
|
344
590
|
fields.push(['number', escapeBibtex(journal.issue)]);
|
|
345
591
|
}
|
|
346
|
-
// Pages
|
|
592
|
+
// Pages — or, with no pagination, biblatex's `eid` for the article number.
|
|
593
|
+
// Classic BibTeX has no article-number field and overloading `pages` is
|
|
594
|
+
// imprecise; `eid` is broadly supported (acmart included).
|
|
347
595
|
if (journal?.pages) {
|
|
348
596
|
fields.push(['pages', escapeBibtex(journal.pages)]);
|
|
349
597
|
}
|
|
350
|
-
|
|
598
|
+
else if (!book) {
|
|
599
|
+
const locator = articleLocator(journal);
|
|
600
|
+
if (locator)
|
|
601
|
+
fields.push(['eid', escapeBibtex(locator)]);
|
|
602
|
+
}
|
|
603
|
+
// ISSN, or a book's ISBNs — a book commonly carries a print and an
|
|
604
|
+
// electronic one, and dropping either loses a real identifier
|
|
351
605
|
const issn = journal?.issn ?? journal?.eIssn;
|
|
352
606
|
if (issn) {
|
|
353
607
|
fields.push(['issn', escapeBibtex(issn)]);
|
|
354
608
|
}
|
|
609
|
+
if (book?.isbns?.length) {
|
|
610
|
+
fields.push(['isbn', book.isbns.map(escapeBibtex).join(', ')]);
|
|
611
|
+
}
|
|
355
612
|
// DOI
|
|
356
|
-
|
|
357
|
-
|
|
613
|
+
const doi = citableDoi(article);
|
|
614
|
+
if (doi) {
|
|
615
|
+
fields.push(['doi', doi]);
|
|
358
616
|
}
|
|
359
617
|
// PMID
|
|
360
618
|
fields.push(['pmid', article.pmid]);
|
|
@@ -362,6 +620,12 @@ export function formatBibtex(article) {
|
|
|
362
620
|
if (article.pmcId) {
|
|
363
621
|
fields.push(['pmcid', article.pmcId]);
|
|
364
622
|
}
|
|
623
|
+
// Bookshelf permalink — where the book is actually readable
|
|
624
|
+
if (book) {
|
|
625
|
+
const url = bookshelfUrl(book);
|
|
626
|
+
if (url)
|
|
627
|
+
fields.push(['url', url]);
|
|
628
|
+
}
|
|
365
629
|
// Keywords — merge article keywords with MeSH descriptor names
|
|
366
630
|
const keywordSet = new Set();
|
|
367
631
|
for (const k of article.keywords ?? [])
|
|
@@ -397,8 +661,13 @@ export function formatRis(article) {
|
|
|
397
661
|
if (value)
|
|
398
662
|
lines.push(`${code} - ${value}`);
|
|
399
663
|
};
|
|
400
|
-
|
|
401
|
-
|
|
664
|
+
const book = isBookRecord(article) ? article.book : undefined;
|
|
665
|
+
// Type of reference — the record type for a book, else the publication types
|
|
666
|
+
const refType = book
|
|
667
|
+
? article.recordType === 'book'
|
|
668
|
+
? 'BOOK'
|
|
669
|
+
: 'CHAP'
|
|
670
|
+
: firstMappedType(article.publicationTypes, RIS_REFERENCE_TYPE, 'JOUR');
|
|
402
671
|
lines.push(`TY - ${refType}`);
|
|
403
672
|
// Authors — one AU tag per author
|
|
404
673
|
if (article.authors?.length) {
|
|
@@ -417,7 +686,8 @@ export function formatRis(article) {
|
|
|
417
686
|
}
|
|
418
687
|
// Title
|
|
419
688
|
tag('TI', article.title);
|
|
420
|
-
//
|
|
689
|
+
// Container — the journal, or the book a chapter sits in. `BT` is the book
|
|
690
|
+
// title; on a whole-book record it would only repeat `TI`, so it is omitted.
|
|
421
691
|
const journal = article.journalInfo;
|
|
422
692
|
if (journal?.title) {
|
|
423
693
|
tag('JF', journal.title);
|
|
@@ -425,6 +695,22 @@ export function formatRis(article) {
|
|
|
425
695
|
if (journal?.isoAbbreviation) {
|
|
426
696
|
tag('JO', journal.isoAbbreviation);
|
|
427
697
|
}
|
|
698
|
+
if (book) {
|
|
699
|
+
if (article.recordType === 'book-chapter')
|
|
700
|
+
tag('BT', book.title);
|
|
701
|
+
for (const editor of book.editors ?? []) {
|
|
702
|
+
const last = editor.lastName ?? '';
|
|
703
|
+
const first = editor.firstName ?? '';
|
|
704
|
+
if (editor.collectiveName)
|
|
705
|
+
tag('A2', editor.collectiveName);
|
|
706
|
+
else if (last || first)
|
|
707
|
+
tag('A2', first ? `${last}, ${first}` : last);
|
|
708
|
+
}
|
|
709
|
+
tag('PB', book.publisher);
|
|
710
|
+
tag('CY', book.publisherLocation);
|
|
711
|
+
tag('ET', book.edition);
|
|
712
|
+
tag('T3', book.collectionTitle);
|
|
713
|
+
}
|
|
428
714
|
// Year
|
|
429
715
|
const year = getYear(article);
|
|
430
716
|
if (year !== 'n.d.') {
|
|
@@ -433,16 +719,24 @@ export function formatRis(article) {
|
|
|
433
719
|
// Volume & Issue
|
|
434
720
|
tag('VL', journal?.volume);
|
|
435
721
|
tag('IS', journal?.issue);
|
|
436
|
-
// Pages — split into start/end, expanding PubMed's truncated-end convention
|
|
722
|
+
// Pages — split into start/end, expanding PubMed's truncated-end convention.
|
|
723
|
+
// With no pagination, the article number goes on `C7` (the attested RIS
|
|
724
|
+
// convention for Article Number), never on SP/EP, which hold absolute pages.
|
|
437
725
|
if (journal?.pages) {
|
|
438
726
|
const { start, end } = splitPages(journal.pages);
|
|
439
727
|
tag('SP', start);
|
|
440
728
|
tag('EP', end);
|
|
441
729
|
}
|
|
442
|
-
|
|
730
|
+
else if (!book) {
|
|
731
|
+
tag('C7', articleLocator(journal));
|
|
732
|
+
}
|
|
733
|
+
// SN carries the ISSN for a serial and the ISBN for a book; a book with both
|
|
734
|
+
// a print and an electronic ISBN gets one line each.
|
|
443
735
|
tag('SN', journal?.issn ?? journal?.eIssn);
|
|
736
|
+
for (const isbn of book?.isbns ?? [])
|
|
737
|
+
tag('SN', isbn);
|
|
444
738
|
// DOI (without URL prefix — RIS DO tag holds the bare DOI)
|
|
445
|
-
tag('DO', article
|
|
739
|
+
tag('DO', citableDoi(article));
|
|
446
740
|
// Accession number (PMID)
|
|
447
741
|
tag('AN', article.pmid);
|
|
448
742
|
// PubMed URL
|
|
@@ -451,6 +745,12 @@ export function formatRis(article) {
|
|
|
451
745
|
if (article.pmcId) {
|
|
452
746
|
lines.push(`UR - https://pmc.ncbi.nlm.nih.gov/articles/${article.pmcId}/`);
|
|
453
747
|
}
|
|
748
|
+
// Bookshelf URL — where a book record is actually readable
|
|
749
|
+
if (book) {
|
|
750
|
+
const url = bookshelfUrl(book);
|
|
751
|
+
if (url)
|
|
752
|
+
lines.push(`UR - ${url}`);
|
|
753
|
+
}
|
|
454
754
|
// Keywords — merge article keywords with MeSH descriptor names
|
|
455
755
|
const keywordSet = new Set();
|
|
456
756
|
for (const k of article.keywords ?? [])
|
|
@@ -525,24 +825,67 @@ function formatAuthorsVancouver(authors) {
|
|
|
525
825
|
* ```
|
|
526
826
|
* Journal name uses the NLM/ISO abbreviation when available; pages are used as
|
|
527
827
|
* PubMed supplies them (often elided, e.g. "583-9"); the DOI carries no trailing
|
|
528
|
-
* period so it stays copy-pasteable.
|
|
828
|
+
* period so it stays copy-pasteable. An article number replaces nothing — with
|
|
829
|
+
* no pagination it trails the source as an NLM note (`. pii: 2400512.`).
|
|
529
830
|
*/
|
|
831
|
+
/**
|
|
832
|
+
* Format a Bookshelf record as a Vancouver (NLM) reference, following *Citing
|
|
833
|
+
* Medicine* 2e Ch. 22 §C, Contributions to Books on the Internet:
|
|
834
|
+
* ```
|
|
835
|
+
* Authors. Chapter title. In: Editors, editors. Book title [Internet].
|
|
836
|
+
* Place: Publisher; date. Available from: URL
|
|
837
|
+
* ```
|
|
838
|
+
* A whole-book record drops the contribution and the `In:`, taking the book
|
|
839
|
+
* title as its own. `[cited …]` and the extent (`[about 41 p.]`) are part of the
|
|
840
|
+
* NLM pattern but neither is derivable from an EFetch record, so both are
|
|
841
|
+
* omitted rather than invented. Editors stand in for absent authors on a whole
|
|
842
|
+
* book, which is the NLM form for an edited work.
|
|
843
|
+
*/
|
|
844
|
+
function formatVancouverBook(article) {
|
|
845
|
+
const { book } = article;
|
|
846
|
+
const segments = [];
|
|
847
|
+
const authorStr = article.authors?.length ? formatAuthorsVancouver(article.authors) : '';
|
|
848
|
+
const editorStr = book.editors?.length ? formatAuthorsVancouver(book.editors) : '';
|
|
849
|
+
if (authorStr)
|
|
850
|
+
segments.push(terminate(authorStr));
|
|
851
|
+
if (article.recordType === 'book-chapter') {
|
|
852
|
+
if (article.title)
|
|
853
|
+
segments.push(`${stripTrailingPeriod(article.title)}.`);
|
|
854
|
+
segments.push(editorStr ? `In: ${editorStr}, editors.` : 'In:');
|
|
855
|
+
}
|
|
856
|
+
else if (!authorStr && editorStr) {
|
|
857
|
+
segments.push(`${editorStr}, editors.`);
|
|
858
|
+
}
|
|
859
|
+
const container = bookTitleWithMedium(book);
|
|
860
|
+
if (container)
|
|
861
|
+
segments.push(`${stripTrailingPeriod(container)}.`);
|
|
862
|
+
const source = [bookImprint(book), bookDateSpan(book)].filter(Boolean).join('; ');
|
|
863
|
+
if (source)
|
|
864
|
+
segments.push(`${source}.`);
|
|
865
|
+
// No trailing period — it would be read as part of the URL
|
|
866
|
+
const url = bookshelfUrl(book);
|
|
867
|
+
if (url)
|
|
868
|
+
segments.push(`Available from: ${url}`);
|
|
869
|
+
return segments.join(' ');
|
|
870
|
+
}
|
|
530
871
|
export function formatVancouver(article) {
|
|
872
|
+
if (isBookRecord(article))
|
|
873
|
+
return formatVancouverBook(article);
|
|
531
874
|
const segments = [];
|
|
532
875
|
// Authors — terminate with a period unless the list already ends in "et al."
|
|
533
876
|
const authorStr = article.authors?.length ? formatAuthorsVancouver(article.authors) : '';
|
|
534
877
|
if (authorStr) {
|
|
535
|
-
segments.push(
|
|
878
|
+
segments.push(terminate(authorStr));
|
|
536
879
|
}
|
|
537
880
|
// Title — sentence case as supplied, single terminating period
|
|
538
881
|
if (article.title) {
|
|
539
|
-
segments.push(`${article.title
|
|
882
|
+
segments.push(`${stripTrailingPeriod(article.title)}.`);
|
|
540
883
|
}
|
|
541
884
|
// Journal — NLM/ISO abbreviation preferred, full title as fallback
|
|
542
885
|
const journal = article.journalInfo;
|
|
543
886
|
const journalName = journal?.isoAbbreviation ?? journal?.title;
|
|
544
887
|
if (journalName) {
|
|
545
|
-
segments.push(`${journalName
|
|
888
|
+
segments.push(`${stripTrailingPeriod(journalName)}.`);
|
|
546
889
|
}
|
|
547
890
|
// Source — "Year;Volume(Issue):Pages."
|
|
548
891
|
const year = getYear(article);
|
|
@@ -559,6 +902,14 @@ export function formatVancouver(article) {
|
|
|
559
902
|
}
|
|
560
903
|
if (source)
|
|
561
904
|
segments.push(`${source}.`);
|
|
905
|
+
// Article number — NLM's note form for a publisher locator that is not
|
|
906
|
+
// pagination ("Euro Surveill. 2008 May 8;13(19). pii: 18863."): a trailing
|
|
907
|
+
// note after Year;Volume(Issue), never inside the colon slot.
|
|
908
|
+
const locator = articleLocator(journal);
|
|
909
|
+
if (locator) {
|
|
910
|
+
const label = journal?.elocationIdType;
|
|
911
|
+
segments.push(label ? `${label}: ${locator}.` : `${locator}.`);
|
|
912
|
+
}
|
|
562
913
|
// DOI — NLM "doi: <doi>" form; no trailing period (would corrupt the DOI)
|
|
563
914
|
if (article.doi) {
|
|
564
915
|
segments.push(`doi: ${article.doi}`);
|