@cyanheads/pubmed-mcp-server 2.10.19 → 2.10.21
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 +8 -5
- package/CLAUDE.md +8 -5
- package/Dockerfile +12 -3
- package/README.md +12 -11
- package/changelog/2.10.x/2.10.20.md +63 -0
- package/changelog/2.10.x/2.10.21.md +42 -0
- package/dist/mcp-server/tools/definitions/_text.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_text.js +10 -5
- package/dist/mcp-server/tools/definitions/_text.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +8 -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 +64 -14
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +44 -9
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +815 -563
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts +5 -5
- package/dist/mcp-server/tools/definitions/find-related.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/find-related.tool.js +12 -8
- package/dist/mcp-server/tools/definitions/find-related.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts +12 -0
- package/dist/mcp-server/tools/definitions/format-citations.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/format-citations.tool.js +53 -7
- package/dist/mcp-server/tools/definitions/format-citations.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts +11 -7
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js +72 -34
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +2 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js +11 -3
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts +18 -0
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js +96 -13
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-fetch.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts +4 -3
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js +7 -5
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +6 -4
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.js +33 -11
- package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +2 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.js +5 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.js.map +1 -1
- package/dist/services/error-contracts.d.ts +1 -1
- package/dist/services/error-contracts.d.ts.map +1 -1
- package/dist/services/error-contracts.js +1 -1
- package/dist/services/error-contracts.js.map +1 -1
- package/dist/services/europe-pmc/api-client.d.ts.map +1 -1
- package/dist/services/europe-pmc/api-client.js +4 -3
- package/dist/services/europe-pmc/api-client.js.map +1 -1
- package/dist/services/fetch-redaction.d.ts +23 -0
- package/dist/services/fetch-redaction.d.ts.map +1 -0
- package/dist/services/fetch-redaction.js +62 -0
- package/dist/services/fetch-redaction.js.map +1 -0
- package/dist/services/ncbi/api-client.d.ts +3 -0
- package/dist/services/ncbi/api-client.d.ts.map +1 -1
- package/dist/services/ncbi/api-client.js +21 -8
- package/dist/services/ncbi/api-client.js.map +1 -1
- package/dist/services/ncbi/formatting/citation-formatter.d.ts +42 -2
- package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
- package/dist/services/ncbi/formatting/citation-formatter.js +370 -91
- package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
- package/dist/services/ncbi/ncbi-service.d.ts +16 -1
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js +117 -71
- package/dist/services/ncbi/ncbi-service.js.map +1 -1
- package/dist/services/ncbi/parsing/article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/article-parser.js +116 -61
- package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/esummary-parser.d.ts +9 -0
- package/dist/services/ncbi/parsing/esummary-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/esummary-parser.js +26 -2
- package/dist/services/ncbi/parsing/esummary-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +8 -7
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.js +633 -185
- package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +102 -5
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js +389 -28
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
- package/dist/services/ncbi/parsing/text-helpers.d.ts +8 -6
- package/dist/services/ncbi/parsing/text-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/text-helpers.js +95 -19
- package/dist/services/ncbi/parsing/text-helpers.js.map +1 -1
- package/dist/services/ncbi/response-handler.d.ts +20 -12
- package/dist/services/ncbi/response-handler.d.ts.map +1 -1
- package/dist/services/ncbi/response-handler.js +115 -19
- package/dist/services/ncbi/response-handler.js.map +1 -1
- package/dist/services/ncbi/types.d.ts +44 -4
- package/dist/services/ncbi/types.d.ts.map +1 -1
- package/dist/services/openalex/api-client.d.ts.map +1 -1
- package/dist/services/openalex/api-client.js +4 -3
- package/dist/services/openalex/api-client.js.map +1 -1
- package/dist/services/openalex/openalex-service.d.ts +10 -6
- package/dist/services/openalex/openalex-service.d.ts.map +1 -1
- package/dist/services/openalex/openalex-service.js +13 -9
- package/dist/services/openalex/openalex-service.js.map +1 -1
- package/dist/services/unpaywall/unpaywall-service.d.ts +3 -0
- package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -1
- package/dist/services/unpaywall/unpaywall-service.js +18 -8
- package/dist/services/unpaywall/unpaywall-service.js.map +1 -1
- package/package.json +14 -12
- package/server.json +10 -24
|
@@ -4,6 +4,27 @@
|
|
|
4
4
|
* Pure TypeScript, zero dependencies, Workers-compatible.
|
|
5
5
|
* @module src/services/ncbi/formatting/citation-formatter
|
|
6
6
|
*/
|
|
7
|
+
/**
|
|
8
|
+
* NCBI `RefType`s that qualify the cited article itself — it was retracted,
|
|
9
|
+
* corrected, or questioned. A link from a record that is itself a notice
|
|
10
|
+
* (`RetractionOf`, `ErratumFor`) and comment or update links are not among them:
|
|
11
|
+
* citing a retraction notice is not citing a retracted work. (#215)
|
|
12
|
+
*/
|
|
13
|
+
export const CITATION_NOTICE_TYPES = [
|
|
14
|
+
'RetractionIn',
|
|
15
|
+
'RetractedandRepublishedIn',
|
|
16
|
+
'ExpressionOfConcernIn',
|
|
17
|
+
'ErratumIn',
|
|
18
|
+
'CorrectedandRepublishedIn',
|
|
19
|
+
];
|
|
20
|
+
const NOTICE_TYPES = new Set(CITATION_NOTICE_TYPES);
|
|
21
|
+
function isCitationNotice(link) {
|
|
22
|
+
return NOTICE_TYPES.has(link.refType);
|
|
23
|
+
}
|
|
24
|
+
/** The record's linked notices that qualify the cited article, in NCBI's order. */
|
|
25
|
+
export function citationNotices(article) {
|
|
26
|
+
return (article.commentsCorrections ?? []).filter(isCitationNotice);
|
|
27
|
+
}
|
|
7
28
|
/**
|
|
8
29
|
* Whether a record cites a book rather than a journal article. Dispatch is on
|
|
9
30
|
* `recordType`, never on `publicationTypes`: a Bookshelf record's publication
|
|
@@ -12,19 +33,100 @@
|
|
|
12
33
|
function isBookRecord(article) {
|
|
13
34
|
return article.recordType !== 'journal-article' && article.book !== undefined;
|
|
14
35
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
36
|
+
/** Vancouver (NLM) month abbreviations: three letters, no period. */
|
|
37
|
+
const NLM_MONTHS = [
|
|
38
|
+
'Jan',
|
|
39
|
+
'Feb',
|
|
40
|
+
'Mar',
|
|
41
|
+
'Apr',
|
|
42
|
+
'May',
|
|
43
|
+
'Jun',
|
|
44
|
+
'Jul',
|
|
45
|
+
'Aug',
|
|
46
|
+
'Sep',
|
|
47
|
+
'Oct',
|
|
48
|
+
'Nov',
|
|
49
|
+
'Dec',
|
|
50
|
+
];
|
|
51
|
+
/** MLA 9 month forms: names longer than four letters are abbreviated. */
|
|
52
|
+
const MLA_MONTHS = [
|
|
53
|
+
'Jan.',
|
|
54
|
+
'Feb.',
|
|
55
|
+
'Mar.',
|
|
56
|
+
'Apr.',
|
|
57
|
+
'May',
|
|
58
|
+
'June',
|
|
59
|
+
'July',
|
|
60
|
+
'Aug.',
|
|
61
|
+
'Sept.',
|
|
62
|
+
'Oct.',
|
|
63
|
+
'Nov.',
|
|
64
|
+
'Dec.',
|
|
65
|
+
];
|
|
66
|
+
/**
|
|
67
|
+
* Read one of a Bookshelf chapter's own dates. Bookshelf writes months and days
|
|
68
|
+
* as numbers; a month that is not one is dropped together with its day rather
|
|
69
|
+
* than guessed, so the date keeps only the parts that can be stated.
|
|
70
|
+
*/
|
|
71
|
+
function chapterDateOfType(article, dateType) {
|
|
72
|
+
const date = article.articleDates?.find((d) => d.dateType === dateType);
|
|
73
|
+
if (!date?.year)
|
|
74
|
+
return;
|
|
75
|
+
const month = Number(date.month);
|
|
76
|
+
if (!Number.isInteger(month) || month < 1 || month > 12)
|
|
77
|
+
return { year: date.year };
|
|
78
|
+
const day = Number(date.day);
|
|
79
|
+
return Number.isInteger(day) && day >= 1 && day <= 31
|
|
80
|
+
? { year: date.year, month, day }
|
|
81
|
+
: { year: date.year, month };
|
|
82
|
+
}
|
|
83
|
+
/** A chapter's `ContributionDate` and `DateRevised`; both absent on any other record type. */
|
|
84
|
+
function chapterDates(article) {
|
|
85
|
+
if (article.recordType !== 'book-chapter')
|
|
86
|
+
return {};
|
|
87
|
+
return {
|
|
88
|
+
contributed: chapterDateOfType(article, 'ContributionDate'),
|
|
89
|
+
revised: chapterDateOfType(article, 'DateRevised'),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The date a Bookshelf chapter is cited by: its last revision when it has one
|
|
94
|
+
* (APA and MLA both cite a last-updated date), otherwise its contribution date.
|
|
95
|
+
* Never the containing book's date, which records when the book began. (#189)
|
|
96
|
+
*/
|
|
97
|
+
function chapterDate(article) {
|
|
98
|
+
const { contributed, revised } = chapterDates(article);
|
|
99
|
+
return revised ?? contributed;
|
|
100
|
+
}
|
|
101
|
+
/** `20 Dec. 2018`, `July 2025`, or `2025` — MLA 9's day-month-year order. */
|
|
102
|
+
function formatDateMla(date) {
|
|
103
|
+
const month = date.month ? MLA_MONTHS[date.month - 1] : undefined;
|
|
104
|
+
return [date.day, month, date.year].filter(Boolean).join(' ');
|
|
105
|
+
}
|
|
106
|
+
/** `2018 Dec 20`, `2025 Jul`, or `2025` — NLM's year-month-day order. */
|
|
107
|
+
function formatDateNlm(date) {
|
|
108
|
+
const month = date.month ? NLM_MONTHS[date.month - 1] : undefined;
|
|
109
|
+
return [date.year, month, date.day].filter(Boolean).join(' ');
|
|
110
|
+
}
|
|
111
|
+
/** RIS `DA` form, zero-padded: `2018/12/20`, `2025/07`, or `2025`. */
|
|
112
|
+
function formatDateRis(date) {
|
|
113
|
+
const pad = (n) => (n ? String(n).padStart(2, '0') : undefined);
|
|
114
|
+
return [date.year, pad(date.month), pad(date.day)].filter(Boolean).join('/');
|
|
115
|
+
}
|
|
18
116
|
/**
|
|
19
117
|
* Extract the publication year from a ParsedArticle.
|
|
20
|
-
* Prefers `journalInfo.publicationDate.year`, then
|
|
21
|
-
* date, then the
|
|
22
|
-
*
|
|
118
|
+
* Prefers `journalInfo.publicationDate.year`, then a Bookshelf chapter's own
|
|
119
|
+
* date ({@link chapterDate}), then the containing book's date, then the first
|
|
120
|
+
* dated `articleDates` entry before giving up. Returns 'n.d.' (no date) when no
|
|
121
|
+
* year is available.
|
|
23
122
|
*/
|
|
24
123
|
function getYear(article) {
|
|
25
124
|
const journalYear = article.journalInfo?.publicationDate?.year;
|
|
26
125
|
if (journalYear)
|
|
27
126
|
return journalYear;
|
|
127
|
+
const chapterYear = chapterDate(article)?.year;
|
|
128
|
+
if (chapterYear)
|
|
129
|
+
return chapterYear;
|
|
28
130
|
const bookYear = article.book?.pubDate ?? article.book?.beginningDate ?? article.book?.endingDate;
|
|
29
131
|
if (bookYear)
|
|
30
132
|
return bookYear;
|
|
@@ -33,11 +135,12 @@ function getYear(article) {
|
|
|
33
135
|
}
|
|
34
136
|
/**
|
|
35
137
|
* The date NLM prints for a book: a closed range for a book published over
|
|
36
|
-
* several years (`1993-2026`),
|
|
138
|
+
* several years (`1993-2026`), an open one for a book still being updated
|
|
139
|
+
* (`2000-`), otherwise the single publication year.
|
|
37
140
|
*/
|
|
38
141
|
function bookDateSpan(book) {
|
|
39
|
-
if (book.beginningDate && book.
|
|
40
|
-
return `${book.beginningDate}-${book.endingDate}`;
|
|
142
|
+
if (book.beginningDate && book.beginningDate !== book.endingDate) {
|
|
143
|
+
return `${book.beginningDate}-${book.endingDate ?? ''}`;
|
|
41
144
|
}
|
|
42
145
|
return book.pubDate ?? book.beginningDate ?? book.endingDate;
|
|
43
146
|
}
|
|
@@ -107,18 +210,163 @@ function splitPages(pages) {
|
|
|
107
210
|
return start ? { start } : {};
|
|
108
211
|
}
|
|
109
212
|
/**
|
|
110
|
-
* The electronic article locator that
|
|
213
|
+
* The electronic article locator that Vancouver, BibTeX, and RIS print in place
|
|
214
|
+
* of a page range, or undefined.
|
|
111
215
|
*
|
|
112
216
|
* Returns a value only when the record carries no pagination. Publishers that
|
|
113
217
|
* report the article number as both `Pagination` and `ELocationID` (PLoS ONE,
|
|
114
218
|
* Scientific Reports) are already covered by the `pages` rendering, and printing
|
|
115
|
-
* both would duplicate the number
|
|
219
|
+
* both would duplicate the number. APA and MLA use {@link articleNumber}.
|
|
116
220
|
*/
|
|
117
221
|
function articleLocator(journal) {
|
|
118
222
|
if (journal?.pages)
|
|
119
223
|
return;
|
|
120
224
|
return journal?.elocationId || undefined;
|
|
121
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* The article number APA and MLA cite in place of a page range: the record's
|
|
228
|
+
* non-DOI locator when it has no pagination, or when its pagination is that
|
|
229
|
+
* same value — NCBI copies an article number into `MedlinePgn` (Frontiers
|
|
230
|
+
* `662953`, Springer `42`). A page that matches no locator stays a page, though
|
|
231
|
+
* some are article numbers the record cannot identify (an Elsevier `105471`
|
|
232
|
+
* beside an `S…` pii looks exactly like a Lancet page `2413`). (#217)
|
|
233
|
+
*/
|
|
234
|
+
function articleNumber(journal) {
|
|
235
|
+
if (!journal?.elocationId)
|
|
236
|
+
return;
|
|
237
|
+
return !journal.pages || journal.pages === journal.elocationId ? journal.elocationId : undefined;
|
|
238
|
+
}
|
|
239
|
+
/** One range inside NLM's pagination: a start, a dash, an end. */
|
|
240
|
+
const PAGE_RANGE = /([^\s,;\u{2013}\u{2014}-]+)\s*[-\u{2013}\u{2014}]\s*([^\s,;\u{2013}\u{2014}-]+)/gu;
|
|
241
|
+
/**
|
|
242
|
+
* The end of a range as NLM's elision means it. An end made of digits is
|
|
243
|
+
* written against the start's digits and takes the start's prefix (`637-41` →
|
|
244
|
+
* `641`, `S5-15` → `S15`, `1255.e1-5` → `1255.e5`); any other end is already
|
|
245
|
+
* whole (`878.e8`, `e65`, `xv`).
|
|
246
|
+
*/
|
|
247
|
+
function fullPageEnd(start, end) {
|
|
248
|
+
const startParts = /^(.*?)(\d+)$/.exec(start);
|
|
249
|
+
if (!startParts || !/^\d+$/.test(end))
|
|
250
|
+
return end;
|
|
251
|
+
const [, prefix = '', digits = ''] = startParts;
|
|
252
|
+
const elided = end.length < digits.length;
|
|
253
|
+
return `${prefix}${elided ? digits.slice(0, digits.length - end.length) : ''}${end}`;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* APA 7 pagination: each range written in full with an en dash (`637-41` →
|
|
257
|
+
* `637–641`, `S5-15` → `S5–S15`), the rest of NLM's text as given — a list's
|
|
258
|
+
* separators and labels too (`1407-8; author reply 1409-10` → `1407–1408;
|
|
259
|
+
* author reply 1409–1410`).
|
|
260
|
+
*/
|
|
261
|
+
function formatPagesApa(pages) {
|
|
262
|
+
return pages.replace(PAGE_RANGE, (_, start, end) => `${start}\u{2013}${fullPageEnd(start, end)}`);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Words APA 7 keeps lowercase in a title unless one opens it or its subtitle:
|
|
266
|
+
* articles, coordinating conjunctions, and prepositions of three letters or
|
|
267
|
+
* fewer — plus the articles, conjunctions, and prepositions of the non-English
|
|
268
|
+
* journal names NLM catalogs, in NLM's unaccented spelling: Romance (`Annales
|
|
269
|
+
* de dermatologie et de venereologie`), Germanic and Scandinavian (`Beitrage
|
|
270
|
+
* zur gerichtlichen Medizin`), Slavic (`Akusherstvo i ginekologiia`), and the
|
|
271
|
+
* Hungarian, Turkish, Finnish, Japanese, and Latin ones NLM's titles use. A
|
|
272
|
+
* word that is also a romanized Chinese syllable stays out (`za` of `za zhi`,
|
|
273
|
+
* `ai`, `nei`), as does one NLM uses for an abbreviation (`ed.`, `med.`).
|
|
274
|
+
*/
|
|
275
|
+
const APA_MINOR_WORDS = new Set([
|
|
276
|
+
...['a', 'an', 'the', 'and', 'but', 'for', 'nor', 'or', 'so', 'yet'],
|
|
277
|
+
...['as', 'at', 'by', 'in', 'of', 'off', 'on', 'per', 'to', 'up', 'via'],
|
|
278
|
+
...['de', 'des', 'du', 'la', 'le', 'les', 'et', 'sur', 'au', 'aux', 'pour', 'par', 'dans'],
|
|
279
|
+
...['avec', 'un', 'une', 'el', 'los', 'las', 'y', 'e', 'al', 'para', 'por', 'o', 'os', 'em'],
|
|
280
|
+
...['no', 'na', 'ao', 'da', 'das', 'do', 'dos', 'di', 'del', 'della', 'dello', 'delle', 'dei'],
|
|
281
|
+
...['degli', 'alla', 'alle', 'fra', 'si', 'din', 'pentru', 'der', 'die', 'dem', 'den', 'und'],
|
|
282
|
+
...['für', 'fur', 'zu', 'zur', 'im', 'am', 'auf', 'aus', 'mit', 'uber', 'van', 'von', 'voor'],
|
|
283
|
+
...['en', 'het', 'tot', 'op', 'uit', 'te', 'og', 'och', 'af', 'til', 'fran', 'mot', 'vir'],
|
|
284
|
+
...['i', 'v', 'w', 'z', 'u', 'po', 'pro', 'nad', 've', 'es', 'az', 'ja', 'ad'],
|
|
285
|
+
]);
|
|
286
|
+
/** MLA 9 also keeps a preposition of any length lowercase. */
|
|
287
|
+
const MLA_MINOR_WORDS = new Set([
|
|
288
|
+
...APA_MINOR_WORDS,
|
|
289
|
+
...['about', 'above', 'across', 'against', 'along', 'among', 'around', 'behind', 'below'],
|
|
290
|
+
...['beneath', 'beside', 'between', 'beyond', 'during', 'from', 'into', 'onto', 'over'],
|
|
291
|
+
...['through', 'throughout', 'toward', 'towards', 'under', 'upon', 'with', 'within'],
|
|
292
|
+
...['without'],
|
|
293
|
+
]);
|
|
294
|
+
/** One hyphen-delimited part of a word: surrounding punctuation, then lowercase letters. */
|
|
295
|
+
const LOWERCASE_WORD = /^([^\p{L}\p{N}]*)(\p{Ll}+(?:['\u{2019}]\p{Ll}+)*)([^\p{L}\p{N}]*)$/u;
|
|
296
|
+
/** A French or Italian elided particle and the word it joins (`d'ophtalmologie`, `dell'apparato`). */
|
|
297
|
+
const ELISION = /^(d|l|qu|dell|dall|nell|all|sull)(['\u{2019}])(.+)$/u;
|
|
298
|
+
/** Brands NLM writes in lowercase, which keep it (`npj quantum materials`). */
|
|
299
|
+
const LOWERCASE_BRANDS = new Set(['npj']);
|
|
300
|
+
const capitalize = (word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`;
|
|
301
|
+
/**
|
|
302
|
+
* Title-case a journal name, which NLM catalogs in sentence case (`Frontiers in
|
|
303
|
+
* pediatrics`), for APA and MLA, which cite it in title case. Only a word part
|
|
304
|
+
* written entirely in lowercase letters gains a capital — each part of a
|
|
305
|
+
* hyphenated word on its own — so a name NLM already cases (`eLife`, `mBio`,
|
|
306
|
+
* `JAMA`, `PloS`) and any part carrying a digit stay as written, as do a
|
|
307
|
+
* lowercase brand and a single-letter prefix (`npj`, `i-Perception`, `e-SPEN`).
|
|
308
|
+
* An elision capitalizes the word, not its particle (`d'Ophtalmologie`). A minor
|
|
309
|
+
* word stays lowercase unless it opens the name, or a subtitle or parallel title
|
|
310
|
+
* after a colon, semicolon, or ` = ` (`CA: A Cancer Journal`). (#217)
|
|
311
|
+
*/
|
|
312
|
+
function titleCaseJournal(title, minorWords) {
|
|
313
|
+
let opensPhrase = true;
|
|
314
|
+
const caseWord = (word, opens) => {
|
|
315
|
+
if (!opens && minorWords.has(word))
|
|
316
|
+
return word;
|
|
317
|
+
const elision = ELISION.exec(word);
|
|
318
|
+
if (!elision)
|
|
319
|
+
return capitalize(word);
|
|
320
|
+
const [, particle = '', apostrophe = '', rest = ''] = elision;
|
|
321
|
+
return `${opens ? capitalize(particle) : particle}${apostrophe}${caseWord(rest, false)}`;
|
|
322
|
+
};
|
|
323
|
+
return title
|
|
324
|
+
.split(/(\s+)/)
|
|
325
|
+
.map((token) => {
|
|
326
|
+
if (!/\S/.test(token))
|
|
327
|
+
return token;
|
|
328
|
+
if (token === ':' || token === ';' || token === '=') {
|
|
329
|
+
opensPhrase = true;
|
|
330
|
+
return token;
|
|
331
|
+
}
|
|
332
|
+
const parts = token.split('-');
|
|
333
|
+
const cased = parts
|
|
334
|
+
.map((part, index) => {
|
|
335
|
+
const match = LOWERCASE_WORD.exec(part);
|
|
336
|
+
if (!match)
|
|
337
|
+
return part;
|
|
338
|
+
const [, lead = '', word = '', trail = ''] = match;
|
|
339
|
+
const isPrefix = index === 0 && parts.length > 1 && word.length === 1;
|
|
340
|
+
if (isPrefix || LOWERCASE_BRANDS.has(word))
|
|
341
|
+
return part;
|
|
342
|
+
return `${lead}${caseWord(word, opensPhrase && index === 0)}${trail}`;
|
|
343
|
+
})
|
|
344
|
+
.join('-');
|
|
345
|
+
opensPhrase = /[:;]$/.test(token);
|
|
346
|
+
return cased;
|
|
347
|
+
})
|
|
348
|
+
.join('');
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Author keywords, then MeSH descriptor names, each term once: a term equal to
|
|
352
|
+
* an earlier one ignoring case is dropped, so the author's spelling and position
|
|
353
|
+
* win (`Deep learning` over MeSH `Deep Learning`). (#217)
|
|
354
|
+
*/
|
|
355
|
+
function mergedKeywords(article) {
|
|
356
|
+
const terms = new Map();
|
|
357
|
+
const candidates = [
|
|
358
|
+
...(article.keywords ?? []),
|
|
359
|
+
...(article.meshTerms ?? []).map((m) => m.descriptorName),
|
|
360
|
+
];
|
|
361
|
+
for (const term of candidates) {
|
|
362
|
+
if (!term)
|
|
363
|
+
continue;
|
|
364
|
+
const key = term.toLowerCase();
|
|
365
|
+
if (!terms.has(key))
|
|
366
|
+
terms.set(key, term);
|
|
367
|
+
}
|
|
368
|
+
return [...terms.values()];
|
|
369
|
+
}
|
|
122
370
|
/**
|
|
123
371
|
* Collapse internal whitespace (including embedded newlines from structured
|
|
124
372
|
* abstracts) to single spaces. Strict RIS parsers treat blank lines as record
|
|
@@ -314,7 +562,8 @@ function formatEditorsApa(editors) {
|
|
|
314
562
|
* ```
|
|
315
563
|
* Authors (Year). Chapter title. In E. Editor (Ed.), *Book title*. Publisher. URL
|
|
316
564
|
* ```
|
|
317
|
-
* A
|
|
565
|
+
* A chapter's year is its own ({@link chapterDate}), not the book's. A whole-book
|
|
566
|
+
* record drops the `In …` clause and italicizes its own title.
|
|
318
567
|
*
|
|
319
568
|
* A record crediting nobody at all — no authors, and for a whole book no
|
|
320
569
|
* editors either — moves its title into the author position instead (APA 7
|
|
@@ -354,8 +603,9 @@ function formatApaBook(article) {
|
|
|
354
603
|
else if (book.title && !titleLedTheReference) {
|
|
355
604
|
parts.push(`*${stripTrailingPeriod(book.title)}*.`);
|
|
356
605
|
}
|
|
606
|
+
// A publisher already ending in a period (`MDText.com, Inc.`) gains no second one
|
|
357
607
|
if (book.publisher)
|
|
358
|
-
parts.push(
|
|
608
|
+
parts.push(terminate(book.publisher));
|
|
359
609
|
const url = bookAccessUrl(article);
|
|
360
610
|
if (url)
|
|
361
611
|
parts.push(url);
|
|
@@ -372,7 +622,8 @@ function formatApaBook(article) {
|
|
|
372
622
|
* ```
|
|
373
623
|
* Title. (Year). *Journal*, *Volume*(Issue), Pages. https://doi.org/DOI
|
|
374
624
|
* ```
|
|
375
|
-
* A
|
|
625
|
+
* A retracted article ends on `(Retraction published <RefSource>)`. A Bookshelf
|
|
626
|
+
* record routes to {@link formatApaBook}.
|
|
376
627
|
*/
|
|
377
628
|
export function formatApa(article) {
|
|
378
629
|
if (isBookRecord(article))
|
|
@@ -403,21 +654,20 @@ export function formatApa(article) {
|
|
|
403
654
|
// Journal, volume, issue, pages
|
|
404
655
|
const journal = article.journalInfo;
|
|
405
656
|
if (journal?.title) {
|
|
406
|
-
let journalPart = `*${journal.title}*`;
|
|
657
|
+
let journalPart = `*${titleCaseJournal(journal.title, APA_MINOR_WORDS)}*`;
|
|
407
658
|
if (journal.volume) {
|
|
408
659
|
journalPart += `, *${journal.volume}*`;
|
|
409
660
|
if (journal.issue) {
|
|
410
661
|
journalPart += `(${journal.issue})`;
|
|
411
662
|
}
|
|
412
663
|
}
|
|
413
|
-
|
|
414
|
-
|
|
664
|
+
// APA 7 p. 294-295: an article number takes the page range's place
|
|
665
|
+
const number = articleNumber(journal);
|
|
666
|
+
if (number) {
|
|
667
|
+
journalPart += `, Article ${number}`;
|
|
415
668
|
}
|
|
416
|
-
else {
|
|
417
|
-
|
|
418
|
-
const locator = articleLocator(journal);
|
|
419
|
-
if (locator)
|
|
420
|
-
journalPart += `, Article ${locator}`;
|
|
669
|
+
else if (journal.pages) {
|
|
670
|
+
journalPart += `, ${formatPagesApa(journal.pages)}`;
|
|
421
671
|
}
|
|
422
672
|
journalPart += '.';
|
|
423
673
|
parts.push(journalPart);
|
|
@@ -426,19 +676,20 @@ export function formatApa(article) {
|
|
|
426
676
|
if (article.doi) {
|
|
427
677
|
parts.push(`https://doi.org/${article.doi}`);
|
|
428
678
|
}
|
|
679
|
+
// APA 7 notes a retraction after the DOI. Its own form names the notice's
|
|
680
|
+
// year, full journal title, and location, which `RefSource` does not carry
|
|
681
|
+
// apart; NCBI's exporter fills the parenthetical with `RefSource` instead.
|
|
682
|
+
const retractions = citationNotices(article)
|
|
683
|
+
.filter((notice) => notice.refType === 'RetractionIn')
|
|
684
|
+
.map((notice) => stripTrailingPeriod(notice.refSource));
|
|
685
|
+
if (retractions.length > 0) {
|
|
686
|
+
parts.push(`(Retraction published ${retractions.join('; ')})`);
|
|
687
|
+
}
|
|
429
688
|
return parts.join(' ');
|
|
430
689
|
}
|
|
431
690
|
// ---------------------------------------------------------------------------
|
|
432
691
|
// MLA 9th Edition
|
|
433
692
|
// ---------------------------------------------------------------------------
|
|
434
|
-
/**
|
|
435
|
-
* Format a PubMed article as an MLA 9th edition citation.
|
|
436
|
-
*
|
|
437
|
-
* Pattern:
|
|
438
|
-
* ```
|
|
439
|
-
* Last, First, et al. "Title." *Journal*, vol. 12, no. 3, 2024, pp. 45-67. DOI.
|
|
440
|
-
* ```
|
|
441
|
-
*/
|
|
442
693
|
/** MLA renders editors first-name-first after `edited by`. */
|
|
443
694
|
function formatEditorsMla(editors) {
|
|
444
695
|
const names = editors
|
|
@@ -458,9 +709,11 @@ function formatEditorsMla(editors) {
|
|
|
458
709
|
* Format a Bookshelf record as an MLA 9th edition citation.
|
|
459
710
|
*
|
|
460
711
|
* ```
|
|
461
|
-
* Author. "Chapter Title." *Book Title*, edited by E. Editor, Publisher,
|
|
712
|
+
* Author. "Chapter Title." *Book Title*, edited by E. Editor, Publisher, Date.
|
|
462
713
|
* ```
|
|
463
|
-
* A
|
|
714
|
+
* A chapter's date is its own, in full (`20 Dec. 2018`); otherwise the book's
|
|
715
|
+
* year. A whole-book record italicizes its own title in place of the quoted
|
|
716
|
+
* chapter.
|
|
464
717
|
*/
|
|
465
718
|
function formatMlaBook(article) {
|
|
466
719
|
const { book } = article;
|
|
@@ -481,13 +734,23 @@ function formatMlaBook(article) {
|
|
|
481
734
|
detailParts.push(book.edition);
|
|
482
735
|
if (book.publisher)
|
|
483
736
|
detailParts.push(book.publisher);
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
737
|
+
const ownDate = chapterDate(article);
|
|
738
|
+
const date = ownDate ? formatDateMla(ownDate) : getYear(article);
|
|
739
|
+
if (date !== 'n.d.')
|
|
740
|
+
detailParts.push(date);
|
|
487
741
|
if (detailParts.length)
|
|
488
|
-
parts.push(
|
|
742
|
+
parts.push(terminate(detailParts.join(', ')));
|
|
489
743
|
return parts.join(' ');
|
|
490
744
|
}
|
|
745
|
+
/**
|
|
746
|
+
* Format a PubMed article as an MLA 9th edition citation.
|
|
747
|
+
*
|
|
748
|
+
* Pattern:
|
|
749
|
+
* ```
|
|
750
|
+
* Last, First, et al. "Title." *Journal*, vol. 12, no. 3, 2024, pp. 45-67. DOI.
|
|
751
|
+
* ```
|
|
752
|
+
* A Bookshelf record routes to {@link formatMlaBook}.
|
|
753
|
+
*/
|
|
491
754
|
export function formatMla(article) {
|
|
492
755
|
if (isBookRecord(article))
|
|
493
756
|
return formatMlaBook(article);
|
|
@@ -507,7 +770,7 @@ export function formatMla(article) {
|
|
|
507
770
|
const journal = article.journalInfo;
|
|
508
771
|
if (journal?.title) {
|
|
509
772
|
const detailParts = [];
|
|
510
|
-
detailParts.push(`*${journal.title}*`);
|
|
773
|
+
detailParts.push(`*${titleCaseJournal(journal.title, MLA_MINOR_WORDS)}*`);
|
|
511
774
|
if (journal.volume) {
|
|
512
775
|
detailParts.push(`vol. ${journal.volume}`);
|
|
513
776
|
}
|
|
@@ -518,18 +781,17 @@ export function formatMla(article) {
|
|
|
518
781
|
if (year !== 'n.d.') {
|
|
519
782
|
detailParts.push(year);
|
|
520
783
|
}
|
|
521
|
-
|
|
784
|
+
// MLA 9 codifies no article-number form; citation guides converge on
|
|
785
|
+
// "art. <value>" in the page position.
|
|
786
|
+
const number = articleNumber(journal);
|
|
787
|
+
if (number) {
|
|
788
|
+
detailParts.push(`art. ${number}`);
|
|
789
|
+
}
|
|
790
|
+
else if (journal.pages) {
|
|
522
791
|
// MLA 9 §6.56: "p." for a single page, "pp." for a range
|
|
523
792
|
const isRange = /[-\u2013\u2014]/.test(journal.pages);
|
|
524
793
|
detailParts.push(`${isRange ? 'pp.' : 'p.'} ${journal.pages}`);
|
|
525
794
|
}
|
|
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
|
-
}
|
|
533
795
|
parts.push(`${detailParts.join(', ')}.`);
|
|
534
796
|
}
|
|
535
797
|
// DOI
|
|
@@ -650,20 +912,13 @@ export function formatBibtex(article) {
|
|
|
650
912
|
if (url)
|
|
651
913
|
fields.push(['url', url]);
|
|
652
914
|
}
|
|
653
|
-
// Keywords —
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
661
|
-
// Brace-wrap each term so MeSH descriptors that carry internal commas in
|
|
662
|
-
// their inverted form (e.g. "Databases, Protein") stay a single keyword —
|
|
663
|
-
// biblatex's comma-separated list parser reads a braced item as one element.
|
|
664
|
-
if (keywordSet.size > 0) {
|
|
665
|
-
const keywords = [...keywordSet].map((k) => `{${escapeBibtex(k)}}`).join(', ');
|
|
666
|
-
fields.push(['keywords', keywords]);
|
|
915
|
+
// Keywords — article keywords merged with MeSH descriptor names. Brace-wrap
|
|
916
|
+
// each term so MeSH descriptors that carry internal commas in their inverted
|
|
917
|
+
// form (e.g. "Databases, Protein") stay a single keyword — biblatex's
|
|
918
|
+
// comma-separated list parser reads a braced item as one element.
|
|
919
|
+
const keywords = mergedKeywords(article);
|
|
920
|
+
if (keywords.length > 0) {
|
|
921
|
+
fields.push(['keywords', keywords.map((k) => `{${escapeBibtex(k)}}`).join(', ')]);
|
|
667
922
|
}
|
|
668
923
|
// Build entry
|
|
669
924
|
const maxKeyLen = Math.max(...fields.map(([k]) => k.length));
|
|
@@ -735,11 +990,14 @@ export function formatRis(article) {
|
|
|
735
990
|
tag('ET', book.edition);
|
|
736
991
|
tag('T3', book.collectionTitle);
|
|
737
992
|
}
|
|
738
|
-
// Year
|
|
993
|
+
// Year, and a Bookshelf chapter's own date in full as `DA` (YYYY/MM/DD)
|
|
739
994
|
const year = getYear(article);
|
|
740
995
|
if (year !== 'n.d.') {
|
|
741
996
|
tag('PY', year);
|
|
742
997
|
}
|
|
998
|
+
const ownDate = chapterDate(article);
|
|
999
|
+
if (ownDate)
|
|
1000
|
+
tag('DA', formatDateRis(ownDate));
|
|
743
1001
|
// Volume & Issue
|
|
744
1002
|
tag('VL', journal?.volume);
|
|
745
1003
|
tag('IS', journal?.issue);
|
|
@@ -775,15 +1033,8 @@ export function formatRis(article) {
|
|
|
775
1033
|
if (url)
|
|
776
1034
|
lines.push(`UR - ${url}`);
|
|
777
1035
|
}
|
|
778
|
-
// Keywords —
|
|
779
|
-
const
|
|
780
|
-
for (const k of article.keywords ?? [])
|
|
781
|
-
keywordSet.add(k);
|
|
782
|
-
for (const m of article.meshTerms ?? []) {
|
|
783
|
-
if (m.descriptorName)
|
|
784
|
-
keywordSet.add(m.descriptorName);
|
|
785
|
-
}
|
|
786
|
-
for (const kw of keywordSet) {
|
|
1036
|
+
// Keywords — article keywords merged with MeSH descriptor names
|
|
1037
|
+
for (const kw of mergedKeywords(article)) {
|
|
787
1038
|
tag('KW', kw);
|
|
788
1039
|
}
|
|
789
1040
|
// Abstract — collapse internal whitespace so blank lines don't break strict
|
|
@@ -838,30 +1089,18 @@ function formatAuthorsVancouver(authors) {
|
|
|
838
1089
|
return names.join(', ');
|
|
839
1090
|
return `${names.slice(0, 6).join(', ')}, et al.`;
|
|
840
1091
|
}
|
|
841
|
-
/**
|
|
842
|
-
* Format a PubMed article as a Vancouver (ICMJE/NLM) reference — the numbered
|
|
843
|
-
* style used by NEJM, Lancet, JAMA, BMJ, and most biomedical journals.
|
|
844
|
-
*
|
|
845
|
-
* Pattern (the leading list number is the consumer's responsibility — entries
|
|
846
|
-
* are formatted independently, so this returns the unnumbered reference body):
|
|
847
|
-
* ```
|
|
848
|
-
* Surname AB, Surname CD, et al. Article title. Abbrev J Name. Year;Vol(Issue):Pages. doi: 10.x/y
|
|
849
|
-
* ```
|
|
850
|
-
* Journal name uses the NLM/ISO abbreviation when available; pages are used as
|
|
851
|
-
* PubMed supplies them (often elided, e.g. "583-9"); the DOI carries no trailing
|
|
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.`).
|
|
854
|
-
*/
|
|
855
1092
|
/**
|
|
856
1093
|
* Format a Bookshelf record as a Vancouver (NLM) reference, following *Citing
|
|
857
1094
|
* Medicine* 2e Ch. 22 §C, Contributions to Books on the Internet:
|
|
858
1095
|
* ```
|
|
859
|
-
* Authors. Chapter title. In: Editors, editors.
|
|
860
|
-
* Place: Publisher; date. Available from: URL
|
|
1096
|
+
* Authors. Chapter title. Contributed [updated Revised]. In: Editors, editors.
|
|
1097
|
+
* Book title [Internet]. Place: Publisher; date. Available from: URL
|
|
861
1098
|
* ```
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
1099
|
+
* The chapter carries its own dates (`1998 Oct 23 [updated 2018 Dec 20]`); the
|
|
1100
|
+
* container keeps the book's, open-ended as `2000-` while the book is still
|
|
1101
|
+
* updated. A whole-book record drops the contribution and the `In:`, taking the
|
|
1102
|
+
* book title as its own. `[cited …]` and the extent (`[about 41 p.]`) are part of
|
|
1103
|
+
* the NLM pattern but neither is derivable from an EFetch record, so both are
|
|
865
1104
|
* omitted rather than invented. Editors stand in for absent authors on a whole
|
|
866
1105
|
* book, which is the NLM form for an edited work.
|
|
867
1106
|
*/
|
|
@@ -875,6 +1114,13 @@ function formatVancouverBook(article) {
|
|
|
875
1114
|
if (article.recordType === 'book-chapter') {
|
|
876
1115
|
if (article.title)
|
|
877
1116
|
segments.push(`${stripTrailingPeriod(article.title)}.`);
|
|
1117
|
+
const { contributed, revised } = chapterDates(article);
|
|
1118
|
+
const dated = [
|
|
1119
|
+
contributed && formatDateNlm(contributed),
|
|
1120
|
+
revised && `[updated ${formatDateNlm(revised)}]`,
|
|
1121
|
+
].filter(Boolean);
|
|
1122
|
+
if (dated.length)
|
|
1123
|
+
segments.push(`${dated.join(' ')}.`);
|
|
878
1124
|
segments.push(editorStr ? `In: ${editorStr}, editors.` : 'In:');
|
|
879
1125
|
}
|
|
880
1126
|
else if (!authorStr && editorStr) {
|
|
@@ -885,13 +1131,37 @@ function formatVancouverBook(article) {
|
|
|
885
1131
|
segments.push(`${stripTrailingPeriod(container)}.`);
|
|
886
1132
|
const source = [bookImprint(book), bookDateSpan(book)].filter(Boolean).join('; ');
|
|
887
1133
|
if (source)
|
|
888
|
-
segments.push(
|
|
1134
|
+
segments.push(terminate(source));
|
|
889
1135
|
// No trailing period — it would be read as part of the URL
|
|
890
1136
|
const url = bookshelfUrl(book);
|
|
891
1137
|
if (url)
|
|
892
1138
|
segments.push(`Available from: ${url}`);
|
|
893
1139
|
return segments.join(' ');
|
|
894
1140
|
}
|
|
1141
|
+
/**
|
|
1142
|
+
* The notices Vancouver notes after the reference, each with Citing Medicine's
|
|
1143
|
+
* label (Boxes 58–59). NCBI's own NLM export prints these two and no others.
|
|
1144
|
+
*/
|
|
1145
|
+
const VANCOUVER_NOTE_LABELS = {
|
|
1146
|
+
RetractionIn: 'Retraction in',
|
|
1147
|
+
ErratumIn: 'Erratum in',
|
|
1148
|
+
};
|
|
1149
|
+
/**
|
|
1150
|
+
* Format a PubMed article as a Vancouver (ICMJE/NLM) reference — the numbered
|
|
1151
|
+
* style used by NEJM, Lancet, JAMA, BMJ, and most biomedical journals.
|
|
1152
|
+
*
|
|
1153
|
+
* Pattern (the leading list number is the consumer's responsibility — entries
|
|
1154
|
+
* are formatted independently, so this returns the unnumbered reference body):
|
|
1155
|
+
* ```
|
|
1156
|
+
* Surname AB, Surname CD, et al. Article title. Abbrev J Name. Year;Vol(Issue):Pages. doi: 10.x/y
|
|
1157
|
+
* ```
|
|
1158
|
+
* Journal name uses the NLM/ISO abbreviation when available; pages are used as
|
|
1159
|
+
* PubMed supplies them (often elided, e.g. "583-9"); the DOI carries no trailing
|
|
1160
|
+
* period so it stays copy-pasteable, unless a note follows it. An article number replaces nothing — with
|
|
1161
|
+
* no pagination it trails the source as an NLM note (`. pii: 2400512.`). A
|
|
1162
|
+
* retraction or erratum follows as a note (`Retraction in: <RefSource>`). A
|
|
1163
|
+
* Bookshelf record routes to {@link formatVancouverBook}.
|
|
1164
|
+
*/
|
|
895
1165
|
export function formatVancouver(article) {
|
|
896
1166
|
if (isBookRecord(article))
|
|
897
1167
|
return formatVancouverBook(article);
|
|
@@ -934,10 +1204,19 @@ export function formatVancouver(article) {
|
|
|
934
1204
|
const label = journal?.elocationIdType;
|
|
935
1205
|
segments.push(label ? `${label}: ${locator}.` : `${locator}.`);
|
|
936
1206
|
}
|
|
937
|
-
//
|
|
1207
|
+
// Citing Medicine's retraction and erratum notes (Boxes 58–59), one per
|
|
1208
|
+
// notice in NCBI's order with `RefSource` verbatim, as NCBI's NLM export
|
|
1209
|
+
// prints them. Each ends on exactly one period: some `RefSource`s carry none.
|
|
1210
|
+
const notes = citationNotices(article).flatMap((notice) => {
|
|
1211
|
+
const label = VANCOUVER_NOTE_LABELS[notice.refType];
|
|
1212
|
+
return label ? [terminate(`${label}: ${notice.refSource}`)] : [];
|
|
1213
|
+
});
|
|
1214
|
+
// DOI — NLM "doi: <doi>" form with no trailing period, which a reader copying
|
|
1215
|
+
// it would take as part of the DOI; a note after it closes it, as in NLM's export
|
|
938
1216
|
if (article.doi) {
|
|
939
|
-
segments.push(`doi: ${article.doi}`);
|
|
1217
|
+
segments.push(notes.length > 0 ? `doi: ${article.doi}.` : `doi: ${article.doi}`);
|
|
940
1218
|
}
|
|
1219
|
+
segments.push(...notes);
|
|
941
1220
|
return segments.join(' ');
|
|
942
1221
|
}
|
|
943
1222
|
// ---------------------------------------------------------------------------
|