@cyanheads/pubmed-mcp-server 2.10.10 → 2.10.12
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/resources/definitions/database-info.resource.js +6 -6
- package/dist/mcp-server/resources/definitions/database-info.resource.js.map +1 -1
- 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 +4 -20
- package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
- package/dist/services/ncbi/formatting/citation-formatter.js +406 -31
- 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 +201 -2
- package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/esummary-parser.d.ts +11 -1
- package/dist/services/ncbi/parsing/esummary-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/esummary-parser.js +48 -8
- 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 +33 -4
- package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +19 -0
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js +81 -2
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
- package/dist/services/ncbi/parsing/xml-helpers.d.ts +29 -3
- package/dist/services/ncbi/parsing/xml-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/xml-helpers.js +46 -0
- package/dist/services/ncbi/parsing/xml-helpers.js.map +1 -1
- package/dist/services/ncbi/response-handler.d.ts.map +1 -1
- package/dist/services/ncbi/response-handler.js +96 -28
- 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) {
|
|
@@ -195,6 +278,89 @@ function formatAuthorBibtex(author) {
|
|
|
195
278
|
// ---------------------------------------------------------------------------
|
|
196
279
|
// APA 7th Edition
|
|
197
280
|
// ---------------------------------------------------------------------------
|
|
281
|
+
/**
|
|
282
|
+
* Format an editor in APA's "In E. E. Editor (Ed.)," position: initials first,
|
|
283
|
+
* surname last — the inverse of the author position.
|
|
284
|
+
*/
|
|
285
|
+
function formatEditorApa(editor) {
|
|
286
|
+
if (editor.collectiveName)
|
|
287
|
+
return editor.collectiveName;
|
|
288
|
+
const initialsSource = editor.initials ??
|
|
289
|
+
editor.firstName
|
|
290
|
+
?.split(/[\s-]+/)
|
|
291
|
+
.filter(Boolean)
|
|
292
|
+
.map((part) => part[0])
|
|
293
|
+
.join('');
|
|
294
|
+
const initials = initialsSource
|
|
295
|
+
? Array.from(initialsSource.replace(/[^\p{L}]/gu, ''))
|
|
296
|
+
.map((c) => `${c}.`)
|
|
297
|
+
.join(' ')
|
|
298
|
+
: '';
|
|
299
|
+
return [initials, editor.lastName].filter(Boolean).join(' ');
|
|
300
|
+
}
|
|
301
|
+
/** APA editor list: comma-separated, `& ` before the last name. */
|
|
302
|
+
function formatEditorsApa(editors) {
|
|
303
|
+
const names = editors.map(formatEditorApa).filter(Boolean);
|
|
304
|
+
if (names.length === 0)
|
|
305
|
+
return '';
|
|
306
|
+
if (names.length === 1)
|
|
307
|
+
return names[0] ?? '';
|
|
308
|
+
return `${names.slice(0, -1).join(', ')}, & ${names.at(-1)}`;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Format a Bookshelf record as an APA 7th edition citation.
|
|
312
|
+
*
|
|
313
|
+
* Chapter in an edited book (APA 7 §10.3):
|
|
314
|
+
* ```
|
|
315
|
+
* Authors (Year). Chapter title. In E. Editor (Ed.), *Book title*. Publisher. URL
|
|
316
|
+
* ```
|
|
317
|
+
* A whole-book record drops the `In …` clause and italicizes its own title.
|
|
318
|
+
*
|
|
319
|
+
* A record crediting nobody at all — no authors, and for a whole book no
|
|
320
|
+
* editors either — moves its title into the author position instead (APA 7
|
|
321
|
+
* §9.12). Bookshelf makes that common: a whole-book record frequently credits
|
|
322
|
+
* neither. The title is not italicized there, and the year follows it. (#139)
|
|
323
|
+
*/
|
|
324
|
+
function formatApaBook(article) {
|
|
325
|
+
const { book } = article;
|
|
326
|
+
const parts = [];
|
|
327
|
+
const authorStr = article.authors?.length ? formatAuthorsApa(article.authors) : '';
|
|
328
|
+
/** The record's own title, which stands in the author position when set. */
|
|
329
|
+
const leadTitle = article.recordType === 'book-chapter' ? article.title : book.title;
|
|
330
|
+
let titleLedTheReference = false;
|
|
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
|
+
else if (leadTitle) {
|
|
340
|
+
parts.push(`${stripTrailingPeriod(leadTitle)}.`);
|
|
341
|
+
titleLedTheReference = true;
|
|
342
|
+
}
|
|
343
|
+
parts.push(`(${getYear(article)}).`);
|
|
344
|
+
if (article.recordType === 'book-chapter') {
|
|
345
|
+
if (article.title && !titleLedTheReference)
|
|
346
|
+
parts.push(`${stripTrailingPeriod(article.title)}.`);
|
|
347
|
+
const editorStr = book.editors?.length ? formatEditorsApa(book.editors) : '';
|
|
348
|
+
const editorLabel = (book.editors?.length ?? 0) > 1 ? 'Eds.' : 'Ed.';
|
|
349
|
+
const container = book.title ? `*${stripTrailingPeriod(book.title)}*` : '';
|
|
350
|
+
if (container) {
|
|
351
|
+
parts.push(editorStr ? `In ${editorStr} (${editorLabel}), ${container}.` : `In ${container}.`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
else if (book.title && !titleLedTheReference) {
|
|
355
|
+
parts.push(`*${stripTrailingPeriod(book.title)}*.`);
|
|
356
|
+
}
|
|
357
|
+
if (book.publisher)
|
|
358
|
+
parts.push(`${book.publisher}.`);
|
|
359
|
+
const url = bookAccessUrl(article);
|
|
360
|
+
if (url)
|
|
361
|
+
parts.push(url);
|
|
362
|
+
return parts.join(' ');
|
|
363
|
+
}
|
|
198
364
|
/**
|
|
199
365
|
* Format a PubMed article as an APA 7th edition citation.
|
|
200
366
|
*
|
|
@@ -202,22 +368,36 @@ function formatAuthorBibtex(author) {
|
|
|
202
368
|
* ```
|
|
203
369
|
* Authors (Year). Title. *Journal*, *Volume*(Issue), Pages. https://doi.org/DOI
|
|
204
370
|
* ```
|
|
371
|
+
* With no author the title takes the author position (APA 7 §9.12):
|
|
372
|
+
* ```
|
|
373
|
+
* Title. (Year). *Journal*, *Volume*(Issue), Pages. https://doi.org/DOI
|
|
374
|
+
* ```
|
|
375
|
+
* A Bookshelf record routes to {@link formatApaBook}.
|
|
205
376
|
*/
|
|
206
377
|
export function formatApa(article) {
|
|
378
|
+
if (isBookRecord(article))
|
|
379
|
+
return formatApaBook(article);
|
|
207
380
|
const parts = [];
|
|
208
381
|
// Authors — ensure trailing period (individual author initials end with '.',
|
|
209
382
|
// but collective names do not, which would otherwise produce "Name (Year).")
|
|
210
383
|
const authorStr = article.authors?.length ? formatAuthorsApa(article.authors) : '';
|
|
384
|
+
// APA 7 §9.12: with no author the title takes the author position — the
|
|
385
|
+
// reference reads `Title. (Year). *Journal*, …` rather than opening on the
|
|
386
|
+
// date. It is not italicized there. (#139)
|
|
387
|
+
const titleLedTheReference = !authorStr && Boolean(article.title);
|
|
211
388
|
if (authorStr) {
|
|
212
|
-
parts.push(
|
|
389
|
+
parts.push(terminate(authorStr));
|
|
390
|
+
}
|
|
391
|
+
else if (article.title) {
|
|
392
|
+
parts.push(`${stripTrailingPeriod(article.title)}.`);
|
|
213
393
|
}
|
|
214
394
|
// Year
|
|
215
395
|
const year = getYear(article);
|
|
216
396
|
parts.push(`(${year}).`);
|
|
217
397
|
// Title — use as-is from PubMed (sentence case already assumed)
|
|
218
|
-
if (article.title) {
|
|
398
|
+
if (article.title && !titleLedTheReference) {
|
|
219
399
|
// Strip trailing period from title if present; we add our own
|
|
220
|
-
const title = article.title
|
|
400
|
+
const title = stripTrailingPeriod(article.title);
|
|
221
401
|
parts.push(`${title}.`);
|
|
222
402
|
}
|
|
223
403
|
// Journal, volume, issue, pages
|
|
@@ -233,6 +413,12 @@ export function formatApa(article) {
|
|
|
233
413
|
if (journal.pages) {
|
|
234
414
|
journalPart += `, ${journal.pages}`;
|
|
235
415
|
}
|
|
416
|
+
else {
|
|
417
|
+
// APA 7 p. 294-295: an article number takes the page range's place
|
|
418
|
+
const locator = articleLocator(journal);
|
|
419
|
+
if (locator)
|
|
420
|
+
journalPart += `, Article ${locator}`;
|
|
421
|
+
}
|
|
236
422
|
journalPart += '.';
|
|
237
423
|
parts.push(journalPart);
|
|
238
424
|
}
|
|
@@ -253,17 +439,68 @@ export function formatApa(article) {
|
|
|
253
439
|
* Last, First, et al. "Title." *Journal*, vol. 12, no. 3, 2024, pp. 45-67. DOI.
|
|
254
440
|
* ```
|
|
255
441
|
*/
|
|
442
|
+
/** MLA renders editors first-name-first after `edited by`. */
|
|
443
|
+
function formatEditorsMla(editors) {
|
|
444
|
+
const names = editors
|
|
445
|
+
.map((editor) => editor.collectiveName
|
|
446
|
+
? editor.collectiveName
|
|
447
|
+
: [editor.firstName, editor.lastName].filter(Boolean).join(' '))
|
|
448
|
+
.filter(Boolean);
|
|
449
|
+
if (names.length === 0)
|
|
450
|
+
return '';
|
|
451
|
+
if (names.length === 1)
|
|
452
|
+
return names[0] ?? '';
|
|
453
|
+
if (names.length === 2)
|
|
454
|
+
return `${names[0]} and ${names[1]}`;
|
|
455
|
+
return `${names[0]}, et al.`;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Format a Bookshelf record as an MLA 9th edition citation.
|
|
459
|
+
*
|
|
460
|
+
* ```
|
|
461
|
+
* Author. "Chapter Title." *Book Title*, edited by E. Editor, Publisher, Year.
|
|
462
|
+
* ```
|
|
463
|
+
* A whole-book record italicizes its own title in place of the quoted chapter.
|
|
464
|
+
*/
|
|
465
|
+
function formatMlaBook(article) {
|
|
466
|
+
const { book } = article;
|
|
467
|
+
const parts = [];
|
|
468
|
+
const authorStr = article.authors?.length ? formatAuthorsMla(article.authors) : '';
|
|
469
|
+
if (authorStr)
|
|
470
|
+
parts.push(terminate(authorStr));
|
|
471
|
+
if (article.recordType === 'book-chapter' && article.title) {
|
|
472
|
+
parts.push(`"${stripTrailingPeriod(article.title)}."`);
|
|
473
|
+
}
|
|
474
|
+
const detailParts = [];
|
|
475
|
+
if (book.title)
|
|
476
|
+
detailParts.push(`*${stripTrailingPeriod(book.title)}*`);
|
|
477
|
+
const editorStr = book.editors?.length ? formatEditorsMla(book.editors) : '';
|
|
478
|
+
if (editorStr)
|
|
479
|
+
detailParts.push(`edited by ${editorStr}`);
|
|
480
|
+
if (book.edition)
|
|
481
|
+
detailParts.push(book.edition);
|
|
482
|
+
if (book.publisher)
|
|
483
|
+
detailParts.push(book.publisher);
|
|
484
|
+
const year = getYear(article);
|
|
485
|
+
if (year !== 'n.d.')
|
|
486
|
+
detailParts.push(year);
|
|
487
|
+
if (detailParts.length)
|
|
488
|
+
parts.push(`${detailParts.join(', ')}.`);
|
|
489
|
+
return parts.join(' ');
|
|
490
|
+
}
|
|
256
491
|
export function formatMla(article) {
|
|
492
|
+
if (isBookRecord(article))
|
|
493
|
+
return formatMlaBook(article);
|
|
257
494
|
const parts = [];
|
|
258
495
|
// Authors
|
|
259
496
|
const authorStr = article.authors?.length ? formatAuthorsMla(article.authors) : '';
|
|
260
497
|
if (authorStr) {
|
|
261
498
|
// Ensure author string ends with period
|
|
262
|
-
parts.push(
|
|
499
|
+
parts.push(terminate(authorStr));
|
|
263
500
|
}
|
|
264
501
|
// Title in quotes
|
|
265
502
|
if (article.title) {
|
|
266
|
-
const title = article.title
|
|
503
|
+
const title = stripTrailingPeriod(article.title);
|
|
267
504
|
parts.push(`"${title}."`);
|
|
268
505
|
}
|
|
269
506
|
// Journal and publication details
|
|
@@ -286,6 +523,13 @@ export function formatMla(article) {
|
|
|
286
523
|
const isRange = /[-\u2013\u2014]/.test(journal.pages);
|
|
287
524
|
detailParts.push(`${isRange ? 'pp.' : 'p.'} ${journal.pages}`);
|
|
288
525
|
}
|
|
526
|
+
else {
|
|
527
|
+
// MLA 9 codifies no article-number form; citation guides converge on
|
|
528
|
+
// "art. <value>" in the page position.
|
|
529
|
+
const locator = articleLocator(journal);
|
|
530
|
+
if (locator)
|
|
531
|
+
detailParts.push(`art. ${locator}`);
|
|
532
|
+
}
|
|
289
533
|
parts.push(`${detailParts.join(', ')}.`);
|
|
290
534
|
}
|
|
291
535
|
// DOI
|
|
@@ -312,7 +556,15 @@ export function formatMla(article) {
|
|
|
312
556
|
*/
|
|
313
557
|
export function formatBibtex(article) {
|
|
314
558
|
const key = `pmid${article.pmid}`;
|
|
315
|
-
const
|
|
559
|
+
const book = isBookRecord(article) ? article.book : undefined;
|
|
560
|
+
// `@incollection` is a chapter in a book gathered from several contributors;
|
|
561
|
+
// `@inbook` is a part attributed to the book's own author, which a Bookshelf
|
|
562
|
+
// chapter is not. A whole-book record is plainly `@book`.
|
|
563
|
+
const entryType = book
|
|
564
|
+
? article.recordType === 'book'
|
|
565
|
+
? 'book'
|
|
566
|
+
: 'incollection'
|
|
567
|
+
: firstMappedType(article.publicationTypes, BIBTEX_ENTRY_TYPE, 'article');
|
|
316
568
|
const fields = [];
|
|
317
569
|
// Authors
|
|
318
570
|
if (article.authors?.length) {
|
|
@@ -320,16 +572,34 @@ export function formatBibtex(article) {
|
|
|
320
572
|
if (authorStr)
|
|
321
573
|
fields.push(['author', authorStr]);
|
|
322
574
|
}
|
|
323
|
-
// Title — strip trailing period; biblatex styles append their own
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
575
|
+
// Title — strip trailing period; biblatex styles append their own.
|
|
576
|
+
// A whole-book record's own title is the book title, so it is not repeated
|
|
577
|
+
// as a `booktitle` below.
|
|
578
|
+
const title = book && article.recordType === 'book' ? book.title : article.title;
|
|
579
|
+
if (title) {
|
|
580
|
+
fields.push(['title', `{${escapeBibtex(stripTrailingPeriod(title))}}`]);
|
|
327
581
|
}
|
|
328
|
-
//
|
|
582
|
+
// Container — the journal, or the book a chapter sits in
|
|
329
583
|
const journal = article.journalInfo;
|
|
330
584
|
if (journal?.title) {
|
|
331
585
|
fields.push(['journal', escapeBibtex(journal.title)]);
|
|
332
586
|
}
|
|
587
|
+
if (book && article.recordType === 'book-chapter' && book.title) {
|
|
588
|
+
fields.push(['booktitle', escapeBibtex(book.title)]);
|
|
589
|
+
}
|
|
590
|
+
if (book?.editors?.length) {
|
|
591
|
+
const editorStr = book.editors.map(formatAuthorBibtex).filter(Boolean).join(' and ');
|
|
592
|
+
if (editorStr)
|
|
593
|
+
fields.push(['editor', editorStr]);
|
|
594
|
+
}
|
|
595
|
+
if (book?.publisher)
|
|
596
|
+
fields.push(['publisher', escapeBibtex(book.publisher)]);
|
|
597
|
+
if (book?.publisherLocation)
|
|
598
|
+
fields.push(['address', escapeBibtex(book.publisherLocation)]);
|
|
599
|
+
if (book?.edition)
|
|
600
|
+
fields.push(['edition', escapeBibtex(book.edition)]);
|
|
601
|
+
if (book?.collectionTitle)
|
|
602
|
+
fields.push(['series', escapeBibtex(book.collectionTitle)]);
|
|
333
603
|
// Year
|
|
334
604
|
const year = getYear(article);
|
|
335
605
|
if (year !== 'n.d.') {
|
|
@@ -343,18 +613,30 @@ export function formatBibtex(article) {
|
|
|
343
613
|
if (journal?.issue) {
|
|
344
614
|
fields.push(['number', escapeBibtex(journal.issue)]);
|
|
345
615
|
}
|
|
346
|
-
// Pages
|
|
616
|
+
// Pages — or, with no pagination, biblatex's `eid` for the article number.
|
|
617
|
+
// Classic BibTeX has no article-number field and overloading `pages` is
|
|
618
|
+
// imprecise; `eid` is broadly supported (acmart included).
|
|
347
619
|
if (journal?.pages) {
|
|
348
620
|
fields.push(['pages', escapeBibtex(journal.pages)]);
|
|
349
621
|
}
|
|
350
|
-
|
|
622
|
+
else if (!book) {
|
|
623
|
+
const locator = articleLocator(journal);
|
|
624
|
+
if (locator)
|
|
625
|
+
fields.push(['eid', escapeBibtex(locator)]);
|
|
626
|
+
}
|
|
627
|
+
// ISSN, or a book's ISBNs — a book commonly carries a print and an
|
|
628
|
+
// electronic one, and dropping either loses a real identifier
|
|
351
629
|
const issn = journal?.issn ?? journal?.eIssn;
|
|
352
630
|
if (issn) {
|
|
353
631
|
fields.push(['issn', escapeBibtex(issn)]);
|
|
354
632
|
}
|
|
633
|
+
if (book?.isbns?.length) {
|
|
634
|
+
fields.push(['isbn', book.isbns.map(escapeBibtex).join(', ')]);
|
|
635
|
+
}
|
|
355
636
|
// DOI
|
|
356
|
-
|
|
357
|
-
|
|
637
|
+
const doi = citableDoi(article);
|
|
638
|
+
if (doi) {
|
|
639
|
+
fields.push(['doi', doi]);
|
|
358
640
|
}
|
|
359
641
|
// PMID
|
|
360
642
|
fields.push(['pmid', article.pmid]);
|
|
@@ -362,6 +644,12 @@ export function formatBibtex(article) {
|
|
|
362
644
|
if (article.pmcId) {
|
|
363
645
|
fields.push(['pmcid', article.pmcId]);
|
|
364
646
|
}
|
|
647
|
+
// Bookshelf permalink — where the book is actually readable
|
|
648
|
+
if (book) {
|
|
649
|
+
const url = bookshelfUrl(book);
|
|
650
|
+
if (url)
|
|
651
|
+
fields.push(['url', url]);
|
|
652
|
+
}
|
|
365
653
|
// Keywords — merge article keywords with MeSH descriptor names
|
|
366
654
|
const keywordSet = new Set();
|
|
367
655
|
for (const k of article.keywords ?? [])
|
|
@@ -397,8 +685,13 @@ export function formatRis(article) {
|
|
|
397
685
|
if (value)
|
|
398
686
|
lines.push(`${code} - ${value}`);
|
|
399
687
|
};
|
|
400
|
-
|
|
401
|
-
|
|
688
|
+
const book = isBookRecord(article) ? article.book : undefined;
|
|
689
|
+
// Type of reference — the record type for a book, else the publication types
|
|
690
|
+
const refType = book
|
|
691
|
+
? article.recordType === 'book'
|
|
692
|
+
? 'BOOK'
|
|
693
|
+
: 'CHAP'
|
|
694
|
+
: firstMappedType(article.publicationTypes, RIS_REFERENCE_TYPE, 'JOUR');
|
|
402
695
|
lines.push(`TY - ${refType}`);
|
|
403
696
|
// Authors — one AU tag per author
|
|
404
697
|
if (article.authors?.length) {
|
|
@@ -417,7 +710,8 @@ export function formatRis(article) {
|
|
|
417
710
|
}
|
|
418
711
|
// Title
|
|
419
712
|
tag('TI', article.title);
|
|
420
|
-
//
|
|
713
|
+
// Container — the journal, or the book a chapter sits in. `BT` is the book
|
|
714
|
+
// title; on a whole-book record it would only repeat `TI`, so it is omitted.
|
|
421
715
|
const journal = article.journalInfo;
|
|
422
716
|
if (journal?.title) {
|
|
423
717
|
tag('JF', journal.title);
|
|
@@ -425,6 +719,22 @@ export function formatRis(article) {
|
|
|
425
719
|
if (journal?.isoAbbreviation) {
|
|
426
720
|
tag('JO', journal.isoAbbreviation);
|
|
427
721
|
}
|
|
722
|
+
if (book) {
|
|
723
|
+
if (article.recordType === 'book-chapter')
|
|
724
|
+
tag('BT', book.title);
|
|
725
|
+
for (const editor of book.editors ?? []) {
|
|
726
|
+
const last = editor.lastName ?? '';
|
|
727
|
+
const first = editor.firstName ?? '';
|
|
728
|
+
if (editor.collectiveName)
|
|
729
|
+
tag('A2', editor.collectiveName);
|
|
730
|
+
else if (last || first)
|
|
731
|
+
tag('A2', first ? `${last}, ${first}` : last);
|
|
732
|
+
}
|
|
733
|
+
tag('PB', book.publisher);
|
|
734
|
+
tag('CY', book.publisherLocation);
|
|
735
|
+
tag('ET', book.edition);
|
|
736
|
+
tag('T3', book.collectionTitle);
|
|
737
|
+
}
|
|
428
738
|
// Year
|
|
429
739
|
const year = getYear(article);
|
|
430
740
|
if (year !== 'n.d.') {
|
|
@@ -433,16 +743,24 @@ export function formatRis(article) {
|
|
|
433
743
|
// Volume & Issue
|
|
434
744
|
tag('VL', journal?.volume);
|
|
435
745
|
tag('IS', journal?.issue);
|
|
436
|
-
// Pages — split into start/end, expanding PubMed's truncated-end convention
|
|
746
|
+
// Pages — split into start/end, expanding PubMed's truncated-end convention.
|
|
747
|
+
// With no pagination, the article number goes on `C7` (the attested RIS
|
|
748
|
+
// convention for Article Number), never on SP/EP, which hold absolute pages.
|
|
437
749
|
if (journal?.pages) {
|
|
438
750
|
const { start, end } = splitPages(journal.pages);
|
|
439
751
|
tag('SP', start);
|
|
440
752
|
tag('EP', end);
|
|
441
753
|
}
|
|
442
|
-
|
|
754
|
+
else if (!book) {
|
|
755
|
+
tag('C7', articleLocator(journal));
|
|
756
|
+
}
|
|
757
|
+
// SN carries the ISSN for a serial and the ISBN for a book; a book with both
|
|
758
|
+
// a print and an electronic ISBN gets one line each.
|
|
443
759
|
tag('SN', journal?.issn ?? journal?.eIssn);
|
|
760
|
+
for (const isbn of book?.isbns ?? [])
|
|
761
|
+
tag('SN', isbn);
|
|
444
762
|
// DOI (without URL prefix — RIS DO tag holds the bare DOI)
|
|
445
|
-
tag('DO', article
|
|
763
|
+
tag('DO', citableDoi(article));
|
|
446
764
|
// Accession number (PMID)
|
|
447
765
|
tag('AN', article.pmid);
|
|
448
766
|
// PubMed URL
|
|
@@ -451,6 +769,12 @@ export function formatRis(article) {
|
|
|
451
769
|
if (article.pmcId) {
|
|
452
770
|
lines.push(`UR - https://pmc.ncbi.nlm.nih.gov/articles/${article.pmcId}/`);
|
|
453
771
|
}
|
|
772
|
+
// Bookshelf URL — where a book record is actually readable
|
|
773
|
+
if (book) {
|
|
774
|
+
const url = bookshelfUrl(book);
|
|
775
|
+
if (url)
|
|
776
|
+
lines.push(`UR - ${url}`);
|
|
777
|
+
}
|
|
454
778
|
// Keywords — merge article keywords with MeSH descriptor names
|
|
455
779
|
const keywordSet = new Set();
|
|
456
780
|
for (const k of article.keywords ?? [])
|
|
@@ -525,24 +849,67 @@ function formatAuthorsVancouver(authors) {
|
|
|
525
849
|
* ```
|
|
526
850
|
* Journal name uses the NLM/ISO abbreviation when available; pages are used as
|
|
527
851
|
* PubMed supplies them (often elided, e.g. "583-9"); the DOI carries no trailing
|
|
528
|
-
* period so it stays copy-pasteable.
|
|
852
|
+
* period so it stays copy-pasteable. An article number replaces nothing — with
|
|
853
|
+
* no pagination it trails the source as an NLM note (`. pii: 2400512.`).
|
|
529
854
|
*/
|
|
855
|
+
/**
|
|
856
|
+
* Format a Bookshelf record as a Vancouver (NLM) reference, following *Citing
|
|
857
|
+
* Medicine* 2e Ch. 22 §C, Contributions to Books on the Internet:
|
|
858
|
+
* ```
|
|
859
|
+
* Authors. Chapter title. In: Editors, editors. Book title [Internet].
|
|
860
|
+
* Place: Publisher; date. Available from: URL
|
|
861
|
+
* ```
|
|
862
|
+
* A whole-book record drops the contribution and the `In:`, taking the book
|
|
863
|
+
* title as its own. `[cited …]` and the extent (`[about 41 p.]`) are part of the
|
|
864
|
+
* NLM pattern but neither is derivable from an EFetch record, so both are
|
|
865
|
+
* omitted rather than invented. Editors stand in for absent authors on a whole
|
|
866
|
+
* book, which is the NLM form for an edited work.
|
|
867
|
+
*/
|
|
868
|
+
function formatVancouverBook(article) {
|
|
869
|
+
const { book } = article;
|
|
870
|
+
const segments = [];
|
|
871
|
+
const authorStr = article.authors?.length ? formatAuthorsVancouver(article.authors) : '';
|
|
872
|
+
const editorStr = book.editors?.length ? formatAuthorsVancouver(book.editors) : '';
|
|
873
|
+
if (authorStr)
|
|
874
|
+
segments.push(terminate(authorStr));
|
|
875
|
+
if (article.recordType === 'book-chapter') {
|
|
876
|
+
if (article.title)
|
|
877
|
+
segments.push(`${stripTrailingPeriod(article.title)}.`);
|
|
878
|
+
segments.push(editorStr ? `In: ${editorStr}, editors.` : 'In:');
|
|
879
|
+
}
|
|
880
|
+
else if (!authorStr && editorStr) {
|
|
881
|
+
segments.push(`${editorStr}, editors.`);
|
|
882
|
+
}
|
|
883
|
+
const container = bookTitleWithMedium(book);
|
|
884
|
+
if (container)
|
|
885
|
+
segments.push(`${stripTrailingPeriod(container)}.`);
|
|
886
|
+
const source = [bookImprint(book), bookDateSpan(book)].filter(Boolean).join('; ');
|
|
887
|
+
if (source)
|
|
888
|
+
segments.push(`${source}.`);
|
|
889
|
+
// No trailing period — it would be read as part of the URL
|
|
890
|
+
const url = bookshelfUrl(book);
|
|
891
|
+
if (url)
|
|
892
|
+
segments.push(`Available from: ${url}`);
|
|
893
|
+
return segments.join(' ');
|
|
894
|
+
}
|
|
530
895
|
export function formatVancouver(article) {
|
|
896
|
+
if (isBookRecord(article))
|
|
897
|
+
return formatVancouverBook(article);
|
|
531
898
|
const segments = [];
|
|
532
899
|
// Authors — terminate with a period unless the list already ends in "et al."
|
|
533
900
|
const authorStr = article.authors?.length ? formatAuthorsVancouver(article.authors) : '';
|
|
534
901
|
if (authorStr) {
|
|
535
|
-
segments.push(
|
|
902
|
+
segments.push(terminate(authorStr));
|
|
536
903
|
}
|
|
537
904
|
// Title — sentence case as supplied, single terminating period
|
|
538
905
|
if (article.title) {
|
|
539
|
-
segments.push(`${article.title
|
|
906
|
+
segments.push(`${stripTrailingPeriod(article.title)}.`);
|
|
540
907
|
}
|
|
541
908
|
// Journal — NLM/ISO abbreviation preferred, full title as fallback
|
|
542
909
|
const journal = article.journalInfo;
|
|
543
910
|
const journalName = journal?.isoAbbreviation ?? journal?.title;
|
|
544
911
|
if (journalName) {
|
|
545
|
-
segments.push(`${journalName
|
|
912
|
+
segments.push(`${stripTrailingPeriod(journalName)}.`);
|
|
546
913
|
}
|
|
547
914
|
// Source — "Year;Volume(Issue):Pages."
|
|
548
915
|
const year = getYear(article);
|
|
@@ -559,6 +926,14 @@ export function formatVancouver(article) {
|
|
|
559
926
|
}
|
|
560
927
|
if (source)
|
|
561
928
|
segments.push(`${source}.`);
|
|
929
|
+
// Article number — NLM's note form for a publisher locator that is not
|
|
930
|
+
// pagination ("Euro Surveill. 2008 May 8;13(19). pii: 18863."): a trailing
|
|
931
|
+
// note after Year;Volume(Issue), never inside the colon slot.
|
|
932
|
+
const locator = articleLocator(journal);
|
|
933
|
+
if (locator) {
|
|
934
|
+
const label = journal?.elocationIdType;
|
|
935
|
+
segments.push(label ? `${label}: ${locator}.` : `${locator}.`);
|
|
936
|
+
}
|
|
562
937
|
// DOI — NLM "doi: <doi>" form; no trailing period (would corrupt the DOI)
|
|
563
938
|
if (article.doi) {
|
|
564
939
|
segments.push(`doi: ${article.doi}`);
|