@cyanheads/pubmed-mcp-server 2.10.20 → 2.10.22
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 +3 -3
- package/CLAUDE.md +3 -3
- package/Dockerfile +3 -3
- package/README.md +14 -11
- package/changelog/2.10.x/2.10.21.md +42 -0
- package/changelog/2.10.x/2.10.22.md +44 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_budget.d.ts +35 -6
- package/dist/mcp-server/tools/definitions/_budget.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_budget.js +26 -3
- package/dist/mcp-server/tools/definitions/_budget.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_notices.d.ts +33 -0
- package/dist/mcp-server/tools/definitions/_notices.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/_notices.js +35 -0
- package/dist/mcp-server/tools/definitions/_notices.js.map +1 -0
- package/dist/mcp-server/tools/definitions/_schemas.d.ts +14 -0
- package/dist/mcp-server/tools/definitions/_schemas.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_schemas.js +9 -0
- package/dist/mcp-server/tools/definitions/_schemas.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.d.ts +11 -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 -19
- package/dist/mcp-server/tools/definitions/fetch-articles.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +22 -4
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +386 -167
- package/dist/mcp-server/tools/definitions/fetch-fulltext.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 +41 -8
- 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 +8 -6
- package/dist/mcp-server/tools/definitions/lookup-citation.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/lookup-mesh.tool.d.ts +1 -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 +66 -76
- 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 +6 -5
- 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 +11 -18
- 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 -4
- 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 +9 -8
- package/dist/mcp-server/tools/definitions/pubmed-europepmc-search.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts +4 -4
- package/dist/mcp-server/tools/definitions/spell-check.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/spell-check.tool.js +12 -8
- 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/europe-pmc/europe-pmc-service.d.ts +31 -11
- package/dist/services/europe-pmc/europe-pmc-service.d.ts.map +1 -1
- package/dist/services/europe-pmc/europe-pmc-service.js +122 -47
- package/dist/services/europe-pmc/europe-pmc-service.js.map +1 -1
- package/dist/services/europe-pmc/types.d.ts +5 -1
- package/dist/services/europe-pmc/types.d.ts.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 +19 -3
- package/dist/services/ncbi/formatting/citation-formatter.d.ts.map +1 -1
- package/dist/services/ncbi/formatting/citation-formatter.js +410 -138
- package/dist/services/ncbi/formatting/citation-formatter.js.map +1 -1
- package/dist/services/ncbi/ncbi-service.d.ts +22 -0
- package/dist/services/ncbi/ncbi-service.d.ts.map +1 -1
- package/dist/services/ncbi/ncbi-service.js +115 -18
- 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 +89 -36
- package/dist/services/ncbi/parsing/article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts +13 -8
- package/dist/services/ncbi/parsing/pmc-article-parser.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-article-parser.js +326 -72
- package/dist/services/ncbi/parsing/pmc-article-parser.js.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts +42 -0
- package/dist/services/ncbi/parsing/pmc-xml-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js +275 -12
- package/dist/services/ncbi/parsing/pmc-xml-helpers.js.map +1 -1
- package/dist/services/ncbi/parsing/text-helpers.d.ts +10 -0
- package/dist/services/ncbi/parsing/text-helpers.d.ts.map +1 -1
- package/dist/services/ncbi/parsing/text-helpers.js +19 -0
- package/dist/services/ncbi/parsing/text-helpers.js.map +1 -1
- package/dist/services/ncbi/response-handler.d.ts +0 -11
- package/dist/services/ncbi/response-handler.d.ts.map +1 -1
- package/dist/services/ncbi/response-handler.js +24 -33
- package/dist/services/ncbi/response-handler.js.map +1 -1
- package/dist/services/ncbi/types.d.ts +72 -32
- 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/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 +7 -7
- package/server.json +3 -3
|
@@ -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
|
|
@@ -171,30 +192,13 @@ function terminate(text) {
|
|
|
171
192
|
return text.endsWith('.') ? text : `${text}.`;
|
|
172
193
|
}
|
|
173
194
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* convention (e.g., "737-8" → { start: "737", end: "738" }, "1639-41" →
|
|
177
|
-
* "1639"/"1641") so downstream RIS/BibTeX consumers see absolute page numbers.
|
|
178
|
-
*/
|
|
179
|
-
function splitPages(pages) {
|
|
180
|
-
if (!pages)
|
|
181
|
-
return {};
|
|
182
|
-
const parts = pages.split(/[-\u2013\u2014]/).map((p) => p.trim());
|
|
183
|
-
let [start, end] = parts;
|
|
184
|
-
if (start && end && end.length < start.length) {
|
|
185
|
-
end = start.slice(0, start.length - end.length) + end;
|
|
186
|
-
}
|
|
187
|
-
if (start && end)
|
|
188
|
-
return { start, end };
|
|
189
|
-
return start ? { start } : {};
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* The electronic article locator that stands in for a page range, or undefined.
|
|
195
|
+
* The electronic article locator that Vancouver, BibTeX, and RIS print in place
|
|
196
|
+
* of a page range, or undefined.
|
|
193
197
|
*
|
|
194
198
|
* Returns a value only when the record carries no pagination. Publishers that
|
|
195
199
|
* report the article number as both `Pagination` and `ELocationID` (PLoS ONE,
|
|
196
200
|
* Scientific Reports) are already covered by the `pages` rendering, and printing
|
|
197
|
-
* both would duplicate the number
|
|
201
|
+
* both would duplicate the number. APA and MLA use {@link articleNumber}.
|
|
198
202
|
*/
|
|
199
203
|
function articleLocator(journal) {
|
|
200
204
|
if (journal?.pages)
|
|
@@ -202,9 +206,256 @@ function articleLocator(journal) {
|
|
|
202
206
|
return journal?.elocationId || undefined;
|
|
203
207
|
}
|
|
204
208
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
209
|
+
* The article number APA and MLA cite in place of a page range: the record's
|
|
210
|
+
* non-DOI locator when it has no pagination, or when its pagination is that
|
|
211
|
+
* same value — NCBI copies an article number into `MedlinePgn` (Frontiers
|
|
212
|
+
* `662953`, Springer `42`). A page that matches no locator stays a page, though
|
|
213
|
+
* some are article numbers the record cannot identify (an Elsevier `105471`
|
|
214
|
+
* beside an `S…` pii looks exactly like a Lancet page `2413`). (#217)
|
|
215
|
+
*
|
|
216
|
+
* A dashless `MedlinePgn` also matches the locator with its hyphens removed:
|
|
217
|
+
* American Society for Microbiology journals deposit `e0364621` beside pii
|
|
218
|
+
* `e03646-21`. The locator's form is the one cited, as the publisher numbers
|
|
219
|
+
* the article. Pagination holding a dash is a range and never matches. (#231)
|
|
220
|
+
*/
|
|
221
|
+
function articleNumber(journal) {
|
|
222
|
+
const locator = journal?.elocationId;
|
|
223
|
+
if (!locator)
|
|
224
|
+
return;
|
|
225
|
+
const { pages } = journal;
|
|
226
|
+
if (!pages || pages === locator)
|
|
227
|
+
return locator;
|
|
228
|
+
return !PAGE_DASH.test(pages) && pages === locator.replaceAll('-', '') ? locator : undefined;
|
|
229
|
+
}
|
|
230
|
+
/** A dash NLM's pagination puts between the ends of a range. */
|
|
231
|
+
const PAGE_DASH = /[-\u{2013}\u{2014}]/u;
|
|
232
|
+
/**
|
|
233
|
+
* One page or range inside NLM's pagination: a start, then optionally a dash
|
|
234
|
+
* and an end. An item starts only after a separator, so a long run without a
|
|
235
|
+
* dash is read once rather than retried from each of its characters.
|
|
236
|
+
*/
|
|
237
|
+
const PAGE_ITEM = /(?<![^\s,;\u{2013}\u{2014}-])([^\s,;\u{2013}\u{2014}-]+)(?:\s*[-\u{2013}\u{2014}]\s*([^\s,;\u{2013}\u{2014}-]+))?/gu;
|
|
238
|
+
/** The digits a page ends on, matched from the start of their run only. */
|
|
239
|
+
const TRAILING_DIGITS = /(?<!\d)\d+$/;
|
|
240
|
+
/**
|
|
241
|
+
* The end of a range as NLM's elision means it. An end made of digits is
|
|
242
|
+
* written against the start's digits and takes the start's prefix (`637-41` →
|
|
243
|
+
* `641`, `S5-15` → `S15`, `1255.e1-5` → `1255.e5`); any other end is already
|
|
244
|
+
* whole (`878.e8`, `e65`, `xv`).
|
|
245
|
+
*/
|
|
246
|
+
function fullPageEnd(start, end) {
|
|
247
|
+
const digits = /^\d+$/.test(end) ? TRAILING_DIGITS.exec(start) : null;
|
|
248
|
+
if (!digits)
|
|
249
|
+
return end;
|
|
250
|
+
const [run] = digits;
|
|
251
|
+
const elided = end.length < run.length;
|
|
252
|
+
return `${start.slice(0, digits.index)}${elided ? run.slice(0, run.length - end.length) : ''}${end}`;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* NLM's pagination with each range written in full (`637-41` → `637-641`,
|
|
256
|
+
* `S5-15` → `S5-S15`), joined by `dash` or by NLM's own when none is given; the
|
|
257
|
+
* rest of NLM's text stays as given — a list's separators and labels too
|
|
258
|
+
* (`1407-8; author reply 1409-10` → `1407-1408; author reply 1409-1410`). APA
|
|
259
|
+
* joins with an en dash (`637–641`); BibTeX keeps NLM's. (#217, #228)
|
|
260
|
+
*/
|
|
261
|
+
function expandPageRanges(pages, dash) {
|
|
262
|
+
return pages.replace(PAGE_ITEM, (item, start, end) => {
|
|
263
|
+
if (end === undefined)
|
|
264
|
+
return item;
|
|
265
|
+
const join = dash ?? item.slice(start.length, item.length - end.length);
|
|
266
|
+
return `${start}${join}${fullPageEnd(start, end)}`;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/** A token that can be a page: it carries a digit or is a Roman numeral, unlike a label (`author reply`). */
|
|
270
|
+
const PAGE_TOKEN = /\d|^[ivxlcdm]+$/i;
|
|
271
|
+
/**
|
|
272
|
+
* The first and last page of NLM's pagination, for RIS `SP` and `EP`: the start
|
|
273
|
+
* of the first page or range, and the end of the last range written in full or
|
|
274
|
+
* the last listed page (`1308, 1310-4` → `1308`/`1314`, `xii-xv` → `xii`/`xv`,
|
|
275
|
+
* `S5-15` → `S5`/`S15`). A single page or article number has no end, and a
|
|
276
|
+
* label in a list (`author reply`) is not a page. NCBI's own `StartPage` and
|
|
277
|
+
* `EndPage` agree on every shape. (#228)
|
|
278
|
+
*/
|
|
279
|
+
function splitPages(pages) {
|
|
280
|
+
const items = [...pages.matchAll(PAGE_ITEM)];
|
|
281
|
+
const [, start] = items[0] ?? [];
|
|
282
|
+
const pageItems = items.filter(([, first = '', last]) => last !== undefined || PAGE_TOKEN.test(first));
|
|
283
|
+
const final = pageItems.at(-1);
|
|
284
|
+
if (!final || (pageItems.length === 1 && final[2] === undefined)) {
|
|
285
|
+
return { start, end: undefined };
|
|
286
|
+
}
|
|
287
|
+
const [, first = '', last] = final;
|
|
288
|
+
return { start, end: last === undefined ? first : fullPageEnd(first, last) };
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Words APA 7 keeps lowercase in a title unless one opens it or its subtitle:
|
|
292
|
+
* articles, coordinating conjunctions, and prepositions of three letters or
|
|
293
|
+
* fewer — plus the articles, conjunctions, and prepositions of the non-English
|
|
294
|
+
* journal names NLM catalogs, in NLM's unaccented spelling: Romance (`Annales
|
|
295
|
+
* de dermatologie et de venereologie`), Germanic and Scandinavian (`Beitrage
|
|
296
|
+
* zur gerichtlichen Medizin`), Slavic (`Akusherstvo i ginekologiia`), and the
|
|
297
|
+
* Hungarian, Turkish, Finnish, Japanese, and Latin ones NLM's titles use. A
|
|
298
|
+
* word that is also a romanized Chinese syllable stays out (`za` of `za zhi`,
|
|
299
|
+
* `ai`, `nei`), as does one NLM uses for an abbreviation (`ed.`, `med.`).
|
|
300
|
+
*/
|
|
301
|
+
const APA_MINOR_WORDS = new Set([
|
|
302
|
+
...['a', 'an', 'the', 'and', 'but', 'for', 'nor', 'or', 'so', 'yet'],
|
|
303
|
+
...['as', 'at', 'by', 'in', 'of', 'off', 'on', 'per', 'to', 'up', 'via'],
|
|
304
|
+
...['de', 'des', 'du', 'la', 'le', 'les', 'et', 'sur', 'au', 'aux', 'pour', 'par', 'dans'],
|
|
305
|
+
...['avec', 'un', 'une', 'el', 'los', 'las', 'y', 'e', 'al', 'para', 'por', 'o', 'os', 'em'],
|
|
306
|
+
...['no', 'na', 'ao', 'da', 'das', 'do', 'dos', 'di', 'del', 'della', 'dello', 'delle', 'dei'],
|
|
307
|
+
...['degli', 'alla', 'alle', 'fra', 'si', 'din', 'pentru', 'der', 'die', 'dem', 'den', 'und'],
|
|
308
|
+
...['für', 'fur', 'zu', 'zur', 'im', 'am', 'auf', 'aus', 'mit', 'uber', 'van', 'von', 'voor'],
|
|
309
|
+
...['en', 'het', 'tot', 'op', 'uit', 'te', 'og', 'och', 'af', 'til', 'fran', 'mot', 'vir'],
|
|
310
|
+
...['i', 'v', 'w', 'z', 'u', 'po', 'pro', 'nad', 've', 'es', 'az', 'ja', 'ad'],
|
|
311
|
+
]);
|
|
312
|
+
/** MLA 9 also keeps a preposition of any length lowercase. */
|
|
313
|
+
const MLA_MINOR_WORDS = new Set([
|
|
314
|
+
...APA_MINOR_WORDS,
|
|
315
|
+
...['about', 'above', 'across', 'against', 'along', 'among', 'around', 'behind', 'below'],
|
|
316
|
+
...['beneath', 'beside', 'between', 'beyond', 'during', 'from', 'into', 'onto', 'over'],
|
|
317
|
+
...['through', 'throughout', 'toward', 'towards', 'under', 'upon', 'with', 'within'],
|
|
318
|
+
...['without'],
|
|
319
|
+
]);
|
|
320
|
+
/** One hyphen-delimited part of a word: surrounding punctuation, then lowercase letters. */
|
|
321
|
+
const LOWERCASE_WORD = /^([^\p{L}\p{N}]*)(\p{Ll}+(?:['\u{2019}]\p{Ll}+)*)([^\p{L}\p{N}]*)$/u;
|
|
322
|
+
/** A French or Italian elided particle and the word it joins (`d'ophtalmologie`, `dell'apparato`). */
|
|
323
|
+
const ELISION = /^(d|l|qu|dell|dall|nell|all|sull)(['\u{2019}])(.+)$/u;
|
|
324
|
+
/** Brands NLM writes in lowercase, which keep it (`npj quantum materials`). */
|
|
325
|
+
const LOWERCASE_BRANDS = new Set(['npj']);
|
|
326
|
+
const capitalize = (word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`;
|
|
327
|
+
/**
|
|
328
|
+
* Title-case a journal name, which NLM catalogs in sentence case (`Frontiers in
|
|
329
|
+
* pediatrics`), for APA and MLA, which cite it in title case. Only a word part
|
|
330
|
+
* written entirely in lowercase letters gains a capital — each part of a
|
|
331
|
+
* hyphenated word on its own — so a name NLM already cases (`eLife`, `mBio`,
|
|
332
|
+
* `JAMA`, `PloS`) and any part carrying a digit stay as written, as do a
|
|
333
|
+
* lowercase brand and a single-letter prefix (`npj`, `i-Perception`, `e-SPEN`).
|
|
334
|
+
* An elision capitalizes the word, not its particle (`d'Ophtalmologie`). A minor
|
|
335
|
+
* word stays lowercase unless it opens the name, or a subtitle or parallel title
|
|
336
|
+
* after a colon, semicolon, or ` = ` (`CA: A Cancer Journal`). (#217)
|
|
337
|
+
*/
|
|
338
|
+
function titleCaseJournal(title, minorWords) {
|
|
339
|
+
let opensPhrase = true;
|
|
340
|
+
const caseWord = (word, opens) => {
|
|
341
|
+
if (!opens && minorWords.has(word))
|
|
342
|
+
return word;
|
|
343
|
+
const elision = ELISION.exec(word);
|
|
344
|
+
if (!elision)
|
|
345
|
+
return capitalize(word);
|
|
346
|
+
const [, particle = '', apostrophe = '', rest = ''] = elision;
|
|
347
|
+
return `${opens ? capitalize(particle) : particle}${apostrophe}${caseWord(rest, false)}`;
|
|
348
|
+
};
|
|
349
|
+
return title
|
|
350
|
+
.split(/(\s+)/)
|
|
351
|
+
.map((token) => {
|
|
352
|
+
if (!/\S/.test(token))
|
|
353
|
+
return token;
|
|
354
|
+
if (token === ':' || token === ';' || token === '=') {
|
|
355
|
+
opensPhrase = true;
|
|
356
|
+
return token;
|
|
357
|
+
}
|
|
358
|
+
const parts = token.split('-');
|
|
359
|
+
const cased = parts
|
|
360
|
+
.map((part, index) => {
|
|
361
|
+
const match = LOWERCASE_WORD.exec(part);
|
|
362
|
+
if (!match)
|
|
363
|
+
return part;
|
|
364
|
+
const [, lead = '', word = '', trail = ''] = match;
|
|
365
|
+
const isPrefix = index === 0 && parts.length > 1 && word.length === 1;
|
|
366
|
+
if (isPrefix || LOWERCASE_BRANDS.has(word))
|
|
367
|
+
return part;
|
|
368
|
+
return `${lead}${caseWord(word, opensPhrase && index === 0)}${trail}`;
|
|
369
|
+
})
|
|
370
|
+
.join('-');
|
|
371
|
+
opensPhrase = /[:;]$/.test(token);
|
|
372
|
+
return cased;
|
|
373
|
+
})
|
|
374
|
+
.join('');
|
|
375
|
+
}
|
|
376
|
+
/** NLM's year qualifier: `Journal of women's health (2002)`. */
|
|
377
|
+
const YEAR_QUALIFIER = /^\d{4}$/;
|
|
378
|
+
/** An edition or format qualifier: `(Clinical research ed.)`, `(Landmark edition)`, `(Print)`. */
|
|
379
|
+
const EDITION_QUALIFIER = /(?:\bed\.|\bedition)$|(?:^|: )(?:Online|Print)$/i;
|
|
380
|
+
/** An abbreviation naming an edition: `Med Clin (Engl Ed)`, `Angew Chem Int Ed Engl`. */
|
|
381
|
+
const EDITION_ABBREVIATION = /\b(?:Ed|Edn|Edition)\b/i;
|
|
382
|
+
/**
|
|
383
|
+
* A place qualifier, `Place, Region` with an optional year: `London, England`,
|
|
384
|
+
* `New York, N.Y.`, `Chicago, Ill. : 1978`. Neither part holds a parenthesis,
|
|
385
|
+
* semicolon, or digit, and the region absorbs any space before the year's
|
|
386
|
+
* colon, so no two parts compete for a character and a near-miss fails in
|
|
387
|
+
* linear time.
|
|
388
|
+
*/
|
|
389
|
+
const PLACE_QUALIFIER = /^[^,():;\d]+,[^():;\d]+(?::\s*\d{4})?$/;
|
|
390
|
+
/**
|
|
391
|
+
* The parenthetical a title ends on, a nested one read as one unit
|
|
392
|
+
* (`Bulletin (Hospital for Joint Diseases (New York, N.Y.))`), with the text
|
|
393
|
+
* before it; undefined when the title does not end on a balanced one.
|
|
394
|
+
*/
|
|
395
|
+
function trailingParenthetical(title) {
|
|
396
|
+
if (!title.endsWith(')'))
|
|
397
|
+
return;
|
|
398
|
+
let depth = 0;
|
|
399
|
+
for (let i = title.length - 1; i >= 0; i--) {
|
|
400
|
+
if (title[i] === ')')
|
|
401
|
+
depth++;
|
|
402
|
+
else if (title[i] === '(' && --depth === 0) {
|
|
403
|
+
return { base: title.slice(0, i).trimEnd(), inner: title.slice(i + 1, -1) };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The journal name APA and MLA cite: NLM's title less the place, year, or
|
|
410
|
+
* edition NLM appends to tell same-named journals apart (`Lancet (London,
|
|
411
|
+
* England)` → `Lancet`, `Journal of women's health (2002)`, `BMJ (Clinical
|
|
412
|
+
* research ed.)`). The journal prints no such text. An edition stays when the
|
|
413
|
+
* ISO abbreviation names it too, as it is then a journal of its own (`Medicina
|
|
414
|
+
* clinica (English ed.)`, `Med Clin (Engl Ed)`). Any other parenthetical names
|
|
415
|
+
* the journal and stays: an issuing body (`Proceedings (Baylor University.
|
|
416
|
+
* Medical Center)`), a subject, a one-word place. Over NLM's journal list the
|
|
417
|
+
* rule is wrong on 18 of the 2,005 titles NLM's catalog settles — a place-shaped
|
|
418
|
+
* issuing body (`Collegian (Royal College of Nursing, Australia)`) is dropped,
|
|
419
|
+
* and a place with no region (`Medical record (New York : 1934)`) is kept. (#227)
|
|
420
|
+
*/
|
|
421
|
+
function citedJournalName(title, isoAbbreviation) {
|
|
422
|
+
const group = trailingParenthetical(title);
|
|
423
|
+
if (!group?.base)
|
|
424
|
+
return title;
|
|
425
|
+
const { base, inner } = group;
|
|
426
|
+
if (YEAR_QUALIFIER.test(inner))
|
|
427
|
+
return base;
|
|
428
|
+
if (EDITION_QUALIFIER.test(inner)) {
|
|
429
|
+
return EDITION_ABBREVIATION.test(isoAbbreviation ?? '') ? title : base;
|
|
430
|
+
}
|
|
431
|
+
return PLACE_QUALIFIER.test(inner) ? base : title;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Author keywords, then MeSH descriptor names, each term once: a term equal to
|
|
435
|
+
* an earlier one ignoring case is dropped, so the author's spelling and position
|
|
436
|
+
* win (`Deep learning` over MeSH `Deep Learning`). (#217)
|
|
437
|
+
*/
|
|
438
|
+
function mergedKeywords(article) {
|
|
439
|
+
const terms = new Map();
|
|
440
|
+
const candidates = [
|
|
441
|
+
...(article.keywords ?? []),
|
|
442
|
+
...(article.meshTerms ?? []).map((m) => m.descriptorName),
|
|
443
|
+
];
|
|
444
|
+
for (const term of candidates) {
|
|
445
|
+
if (!term)
|
|
446
|
+
continue;
|
|
447
|
+
const key = term.toLowerCase();
|
|
448
|
+
if (!terms.has(key))
|
|
449
|
+
terms.set(key, term);
|
|
450
|
+
}
|
|
451
|
+
return [...terms.values()];
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Collapse each whitespace run to one space and trim the ends. The parser trims
|
|
455
|
+
* only a field's ends, so a line break inside a title, name, or keyword — or
|
|
456
|
+
* between a structured abstract's paragraphs — would otherwise reach a
|
|
457
|
+
* citation: an untagged RIS line, or a blank one that strict parsers read as
|
|
458
|
+
* the end of the record. Every style writes record text through this. (#233)
|
|
208
459
|
*/
|
|
209
460
|
function collapseWhitespace(text) {
|
|
210
461
|
return text.replace(/\s+/g, ' ').trim();
|
|
@@ -254,8 +505,23 @@ function escapeBibtex(text) {
|
|
|
254
505
|
// ---------------------------------------------------------------------------
|
|
255
506
|
// Author formatters
|
|
256
507
|
// ---------------------------------------------------------------------------
|
|
508
|
+
/*
|
|
509
|
+
* A generational suffix (`Jr`, `2nd`) renders verbatim as NCBI writes it, as
|
|
510
|
+
* NCBI's own citation export prints it. It qualifies a surname, so a name with
|
|
511
|
+
* no surname renders without it. Inverted name forms set it off with a comma
|
|
512
|
+
* (`Frenck, R. W., Jr`); natural-order and NLM forms append it after a space
|
|
513
|
+
* (`R. W. Frenck Jr`, `Frenck RW Jr`). (#226)
|
|
514
|
+
*/
|
|
515
|
+
/** `, Jr` for an inverted name form; `''` without a suffix or a surname. */
|
|
516
|
+
function invertedSuffix(author) {
|
|
517
|
+
return author.lastName && author.suffix ? `, ${author.suffix}` : '';
|
|
518
|
+
}
|
|
519
|
+
/** ` Jr` for a natural-order or NLM name form; `''` without a suffix or a surname. */
|
|
520
|
+
function trailingSuffix(author) {
|
|
521
|
+
return author.lastName && author.suffix ? ` ${author.suffix}` : '';
|
|
522
|
+
}
|
|
257
523
|
/**
|
|
258
|
-
* Format a single author in APA style: `Last, F. M
|
|
524
|
+
* Format a single author in APA style: `Last, F. M.`, or `Last, F. M., Jr`.
|
|
259
525
|
* Collective/group authors return the group name directly.
|
|
260
526
|
*/
|
|
261
527
|
function formatAuthorApa(author) {
|
|
@@ -270,7 +536,7 @@ function formatAuthorApa(author) {
|
|
|
270
536
|
.map((part) => `${part[0]}.`)
|
|
271
537
|
.join(' ');
|
|
272
538
|
if (!initials)
|
|
273
|
-
return last
|
|
539
|
+
return `${last}${invertedSuffix(author)}`;
|
|
274
540
|
// Extract only letter characters (Unicode-aware: preserve Á, Ö, É, etc.),
|
|
275
541
|
// format each as "X." separated by spaces.
|
|
276
542
|
const formatted = Array.from(initials.replace(/[^\p{L}]/gu, ''))
|
|
@@ -278,7 +544,7 @@ function formatAuthorApa(author) {
|
|
|
278
544
|
.join(' ');
|
|
279
545
|
if (!last)
|
|
280
546
|
return formatted;
|
|
281
|
-
return `${last}, ${formatted}`;
|
|
547
|
+
return `${last}, ${formatted}${invertedSuffix(author)}`;
|
|
282
548
|
}
|
|
283
549
|
/**
|
|
284
550
|
* Format the full author list for APA 7th edition.
|
|
@@ -305,21 +571,19 @@ function formatAuthorsApa(authors) {
|
|
|
305
571
|
}
|
|
306
572
|
/**
|
|
307
573
|
* Format a single author in MLA style.
|
|
308
|
-
* First listed author: `Last, First Middle
|
|
309
|
-
* Subsequent authors: `First Middle Last`
|
|
574
|
+
* First listed author: `Last, First Middle`, or `Last, First Middle, Jr`.
|
|
575
|
+
* Subsequent authors: `First Middle Last`, or `First Middle Last Jr`.
|
|
310
576
|
*/
|
|
311
577
|
function formatAuthorMla(author, isFirst) {
|
|
312
578
|
if (author.collectiveName)
|
|
313
579
|
return author.collectiveName;
|
|
314
580
|
const last = author.lastName ?? '';
|
|
315
581
|
const first = author.firstName ?? '';
|
|
316
|
-
if (!last && !first)
|
|
317
|
-
return '';
|
|
318
|
-
if (!first)
|
|
319
|
-
return last;
|
|
320
582
|
if (!last)
|
|
321
583
|
return first;
|
|
322
|
-
|
|
584
|
+
if (isFirst)
|
|
585
|
+
return `${first ? `${last}, ${first}` : last}${invertedSuffix(author)}`;
|
|
586
|
+
return `${first ? `${first} ${last}` : last}${trailingSuffix(author)}`;
|
|
323
587
|
}
|
|
324
588
|
/**
|
|
325
589
|
* Format the full author list for MLA 9th edition.
|
|
@@ -342,27 +606,28 @@ function formatAuthorsMla(authors) {
|
|
|
342
606
|
return `${formatAuthorMla(first, true)}, et al.`;
|
|
343
607
|
}
|
|
344
608
|
/**
|
|
345
|
-
* Format a single author in BibTeX style: `{Last},
|
|
609
|
+
* Format a single author in BibTeX style: `{Last}, First`, or the three-part
|
|
610
|
+
* `{Last}, Jr, First` when the name carries a suffix — the comma after `Jr`
|
|
611
|
+
* stays even with no first name, so the suffix is never read as one.
|
|
346
612
|
*/
|
|
347
613
|
function formatAuthorBibtex(author) {
|
|
348
614
|
if (author.collectiveName)
|
|
349
615
|
return `{${escapeBibtex(author.collectiveName)}}`;
|
|
350
616
|
const last = author.lastName ? escapeBibtex(author.lastName) : '';
|
|
351
617
|
const first = author.firstName ? escapeBibtex(author.firstName) : '';
|
|
352
|
-
if (!last && !first)
|
|
353
|
-
return '';
|
|
354
|
-
if (!first)
|
|
355
|
-
return `{${last}}`;
|
|
356
618
|
if (!last)
|
|
357
619
|
return first;
|
|
358
|
-
|
|
620
|
+
if (author.suffix)
|
|
621
|
+
return `{${last}}, ${escapeBibtex(author.suffix)},${first && ` ${first}`}`;
|
|
622
|
+
return first ? `{${last}}, ${first}` : `{${last}}`;
|
|
359
623
|
}
|
|
360
624
|
// ---------------------------------------------------------------------------
|
|
361
625
|
// APA 7th Edition
|
|
362
626
|
// ---------------------------------------------------------------------------
|
|
363
627
|
/**
|
|
364
628
|
* Format an editor in APA's "In E. E. Editor (Ed.)," position: initials first,
|
|
365
|
-
* surname last — the inverse of the author position
|
|
629
|
+
* surname last — the inverse of the author position — and a suffix after the
|
|
630
|
+
* surname (`M. P. Adam Jr`).
|
|
366
631
|
*/
|
|
367
632
|
function formatEditorApa(editor) {
|
|
368
633
|
if (editor.collectiveName)
|
|
@@ -378,7 +643,7 @@ function formatEditorApa(editor) {
|
|
|
378
643
|
.map((c) => `${c}.`)
|
|
379
644
|
.join(' ')
|
|
380
645
|
: '';
|
|
381
|
-
return [initials, editor.lastName].filter(Boolean).join(' ');
|
|
646
|
+
return [initials, editor.lastName].filter(Boolean).join(' ') + trailingSuffix(editor);
|
|
382
647
|
}
|
|
383
648
|
/** APA editor list: comma-separated, `& ` before the last name. */
|
|
384
649
|
function formatEditorsApa(editors) {
|
|
@@ -443,7 +708,7 @@ function formatApaBook(article) {
|
|
|
443
708
|
const url = bookAccessUrl(article);
|
|
444
709
|
if (url)
|
|
445
710
|
parts.push(url);
|
|
446
|
-
return parts.join(' ');
|
|
711
|
+
return collapseWhitespace(parts.join(' '));
|
|
447
712
|
}
|
|
448
713
|
/**
|
|
449
714
|
* Format a PubMed article as an APA 7th edition citation.
|
|
@@ -456,7 +721,8 @@ function formatApaBook(article) {
|
|
|
456
721
|
* ```
|
|
457
722
|
* Title. (Year). *Journal*, *Volume*(Issue), Pages. https://doi.org/DOI
|
|
458
723
|
* ```
|
|
459
|
-
* A
|
|
724
|
+
* A retracted article ends on `(Retraction published <RefSource>)`. A Bookshelf
|
|
725
|
+
* record routes to {@link formatApaBook}.
|
|
460
726
|
*/
|
|
461
727
|
export function formatApa(article) {
|
|
462
728
|
if (isBookRecord(article))
|
|
@@ -487,21 +753,21 @@ export function formatApa(article) {
|
|
|
487
753
|
// Journal, volume, issue, pages
|
|
488
754
|
const journal = article.journalInfo;
|
|
489
755
|
if (journal?.title) {
|
|
490
|
-
|
|
756
|
+
const name = citedJournalName(journal.title, journal.isoAbbreviation);
|
|
757
|
+
let journalPart = `*${titleCaseJournal(name, APA_MINOR_WORDS)}*`;
|
|
491
758
|
if (journal.volume) {
|
|
492
759
|
journalPart += `, *${journal.volume}*`;
|
|
493
760
|
if (journal.issue) {
|
|
494
761
|
journalPart += `(${journal.issue})`;
|
|
495
762
|
}
|
|
496
763
|
}
|
|
497
|
-
|
|
498
|
-
|
|
764
|
+
// APA 7 p. 294-295: an article number takes the page range's place
|
|
765
|
+
const number = articleNumber(journal);
|
|
766
|
+
if (number) {
|
|
767
|
+
journalPart += `, Article ${number}`;
|
|
499
768
|
}
|
|
500
|
-
else {
|
|
501
|
-
|
|
502
|
-
const locator = articleLocator(journal);
|
|
503
|
-
if (locator)
|
|
504
|
-
journalPart += `, Article ${locator}`;
|
|
769
|
+
else if (journal.pages) {
|
|
770
|
+
journalPart += `, ${expandPageRanges(journal.pages, '\u{2013}')}`;
|
|
505
771
|
}
|
|
506
772
|
journalPart += '.';
|
|
507
773
|
parts.push(journalPart);
|
|
@@ -510,18 +776,23 @@ export function formatApa(article) {
|
|
|
510
776
|
if (article.doi) {
|
|
511
777
|
parts.push(`https://doi.org/${article.doi}`);
|
|
512
778
|
}
|
|
513
|
-
|
|
779
|
+
// APA 7 notes a retraction after the DOI. Its own form names the notice's
|
|
780
|
+
// year, full journal title, and location, which `RefSource` does not carry
|
|
781
|
+
// apart; NCBI's exporter fills the parenthetical with `RefSource` instead.
|
|
782
|
+
const retractions = citationNotices(article)
|
|
783
|
+
.filter((notice) => notice.refType === 'RetractionIn')
|
|
784
|
+
.map((notice) => stripTrailingPeriod(notice.refSource));
|
|
785
|
+
if (retractions.length > 0) {
|
|
786
|
+
parts.push(`(Retraction published ${retractions.join('; ')})`);
|
|
787
|
+
}
|
|
788
|
+
return collapseWhitespace(parts.join(' '));
|
|
514
789
|
}
|
|
515
790
|
// ---------------------------------------------------------------------------
|
|
516
791
|
// MLA 9th Edition
|
|
517
792
|
// ---------------------------------------------------------------------------
|
|
518
|
-
/** MLA renders editors first-name-first after `edited by
|
|
793
|
+
/** MLA renders editors first-name-first after `edited by`, as it does a non-first author. */
|
|
519
794
|
function formatEditorsMla(editors) {
|
|
520
|
-
const names = editors
|
|
521
|
-
.map((editor) => editor.collectiveName
|
|
522
|
-
? editor.collectiveName
|
|
523
|
-
: [editor.firstName, editor.lastName].filter(Boolean).join(' '))
|
|
524
|
-
.filter(Boolean);
|
|
795
|
+
const names = editors.map((editor) => formatAuthorMla(editor, false)).filter(Boolean);
|
|
525
796
|
if (names.length === 0)
|
|
526
797
|
return '';
|
|
527
798
|
if (names.length === 1)
|
|
@@ -565,7 +836,7 @@ function formatMlaBook(article) {
|
|
|
565
836
|
detailParts.push(date);
|
|
566
837
|
if (detailParts.length)
|
|
567
838
|
parts.push(terminate(detailParts.join(', ')));
|
|
568
|
-
return parts.join(' ');
|
|
839
|
+
return collapseWhitespace(parts.join(' '));
|
|
569
840
|
}
|
|
570
841
|
/**
|
|
571
842
|
* Format a PubMed article as an MLA 9th edition citation.
|
|
@@ -595,7 +866,8 @@ export function formatMla(article) {
|
|
|
595
866
|
const journal = article.journalInfo;
|
|
596
867
|
if (journal?.title) {
|
|
597
868
|
const detailParts = [];
|
|
598
|
-
|
|
869
|
+
const name = citedJournalName(journal.title, journal.isoAbbreviation);
|
|
870
|
+
detailParts.push(`*${titleCaseJournal(name, MLA_MINOR_WORDS)}*`);
|
|
599
871
|
if (journal.volume) {
|
|
600
872
|
detailParts.push(`vol. ${journal.volume}`);
|
|
601
873
|
}
|
|
@@ -606,25 +878,24 @@ export function formatMla(article) {
|
|
|
606
878
|
if (year !== 'n.d.') {
|
|
607
879
|
detailParts.push(year);
|
|
608
880
|
}
|
|
609
|
-
|
|
881
|
+
// MLA 9 codifies no article-number form; citation guides converge on
|
|
882
|
+
// "art. <value>" in the page position.
|
|
883
|
+
const number = articleNumber(journal);
|
|
884
|
+
if (number) {
|
|
885
|
+
detailParts.push(`art. ${number}`);
|
|
886
|
+
}
|
|
887
|
+
else if (journal.pages) {
|
|
610
888
|
// MLA 9 §6.56: "p." for a single page, "pp." for a range
|
|
611
889
|
const isRange = /[-\u2013\u2014]/.test(journal.pages);
|
|
612
890
|
detailParts.push(`${isRange ? 'pp.' : 'p.'} ${journal.pages}`);
|
|
613
891
|
}
|
|
614
|
-
else {
|
|
615
|
-
// MLA 9 codifies no article-number form; citation guides converge on
|
|
616
|
-
// "art. <value>" in the page position.
|
|
617
|
-
const locator = articleLocator(journal);
|
|
618
|
-
if (locator)
|
|
619
|
-
detailParts.push(`art. ${locator}`);
|
|
620
|
-
}
|
|
621
892
|
parts.push(`${detailParts.join(', ')}.`);
|
|
622
893
|
}
|
|
623
894
|
// DOI
|
|
624
895
|
if (article.doi) {
|
|
625
896
|
parts.push(`https://doi.org/${article.doi}.`);
|
|
626
897
|
}
|
|
627
|
-
return parts.join(' ');
|
|
898
|
+
return collapseWhitespace(parts.join(' '));
|
|
628
899
|
}
|
|
629
900
|
// ---------------------------------------------------------------------------
|
|
630
901
|
// BibTeX
|
|
@@ -701,11 +972,12 @@ export function formatBibtex(article) {
|
|
|
701
972
|
if (journal?.issue) {
|
|
702
973
|
fields.push(['number', escapeBibtex(journal.issue)]);
|
|
703
974
|
}
|
|
704
|
-
// Pages — or, with no pagination,
|
|
705
|
-
//
|
|
706
|
-
// imprecise; `eid` is broadly
|
|
975
|
+
// Pages, each range written in full (#228) — or, with no pagination,
|
|
976
|
+
// biblatex's `eid` for the article number. Classic BibTeX has no
|
|
977
|
+
// article-number field and overloading `pages` is imprecise; `eid` is broadly
|
|
978
|
+
// supported (acmart included).
|
|
707
979
|
if (journal?.pages) {
|
|
708
|
-
fields.push(['pages', escapeBibtex(journal.pages)]);
|
|
980
|
+
fields.push(['pages', escapeBibtex(expandPageRanges(journal.pages))]);
|
|
709
981
|
}
|
|
710
982
|
else if (!book) {
|
|
711
983
|
const locator = articleLocator(journal);
|
|
@@ -738,29 +1010,38 @@ export function formatBibtex(article) {
|
|
|
738
1010
|
if (url)
|
|
739
1011
|
fields.push(['url', url]);
|
|
740
1012
|
}
|
|
741
|
-
// Keywords —
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1013
|
+
// Keywords — article keywords merged with MeSH descriptor names. Brace-wrap
|
|
1014
|
+
// each term so MeSH descriptors that carry internal commas in their inverted
|
|
1015
|
+
// form (e.g. "Databases, Protein") stay a single keyword — biblatex's
|
|
1016
|
+
// comma-separated list parser reads a braced item as one element.
|
|
1017
|
+
const keywords = mergedKeywords(article);
|
|
1018
|
+
if (keywords.length > 0) {
|
|
1019
|
+
fields.push(['keywords', keywords.map((k) => `{${escapeBibtex(k)}}`).join(', ')]);
|
|
748
1020
|
}
|
|
749
|
-
//
|
|
750
|
-
// their inverted form (e.g. "Databases, Protein") stay a single keyword —
|
|
751
|
-
// biblatex's comma-separated list parser reads a braced item as one element.
|
|
752
|
-
if (keywordSet.size > 0) {
|
|
753
|
-
const keywords = [...keywordSet].map((k) => `{${escapeBibtex(k)}}`).join(', ');
|
|
754
|
-
fields.push(['keywords', keywords]);
|
|
755
|
-
}
|
|
756
|
-
// Build entry
|
|
1021
|
+
// Build entry — each value on its own line, whitespace runs collapsed (#233)
|
|
757
1022
|
const maxKeyLen = Math.max(...fields.map(([k]) => k.length));
|
|
758
|
-
const fieldLines = fields
|
|
1023
|
+
const fieldLines = fields
|
|
1024
|
+
.map(([k, v]) => ` ${k.padEnd(maxKeyLen)} = {${collapseWhitespace(v)}}`)
|
|
1025
|
+
.join(',\n');
|
|
759
1026
|
return `@${entryType}{${key},\n${fieldLines}\n}`;
|
|
760
1027
|
}
|
|
761
1028
|
// ---------------------------------------------------------------------------
|
|
762
1029
|
// RIS
|
|
763
1030
|
// ---------------------------------------------------------------------------
|
|
1031
|
+
/**
|
|
1032
|
+
* An `AU` or `A2` value: `Lastname, Firstname`, or `Lastname, Firstname, Suffix`.
|
|
1033
|
+
* Positional, so a suffixed name with no first name keeps the empty slot
|
|
1034
|
+
* (`Frenck, , Jr`) rather than reading the suffix as a first name.
|
|
1035
|
+
*/
|
|
1036
|
+
function formatNameRis(person) {
|
|
1037
|
+
if (person.collectiveName)
|
|
1038
|
+
return person.collectiveName;
|
|
1039
|
+
const last = person.lastName ?? '';
|
|
1040
|
+
const first = person.firstName ?? '';
|
|
1041
|
+
if (last && person.suffix)
|
|
1042
|
+
return `${last}, ${first}, ${person.suffix}`;
|
|
1043
|
+
return first ? `${last}, ${first}` : last;
|
|
1044
|
+
}
|
|
764
1045
|
/**
|
|
765
1046
|
* Format a PubMed article as a RIS record.
|
|
766
1047
|
*
|
|
@@ -769,9 +1050,12 @@ export function formatBibtex(article) {
|
|
|
769
1050
|
*/
|
|
770
1051
|
export function formatRis(article) {
|
|
771
1052
|
const lines = [];
|
|
1053
|
+
// Every value on its own tag line: a line break inside one would leave an
|
|
1054
|
+
// untagged line, or a blank one that ends the record for strict parsers (#233)
|
|
772
1055
|
const tag = (code, value) => {
|
|
773
|
-
|
|
774
|
-
|
|
1056
|
+
const text = value && collapseWhitespace(value);
|
|
1057
|
+
if (text)
|
|
1058
|
+
lines.push(`${code} - ${text}`);
|
|
775
1059
|
};
|
|
776
1060
|
const book = isBookRecord(article) ? article.book : undefined;
|
|
777
1061
|
// Type of reference — the record type for a book, else the publication types
|
|
@@ -782,20 +1066,8 @@ export function formatRis(article) {
|
|
|
782
1066
|
: firstMappedType(article.publicationTypes, RIS_REFERENCE_TYPE, 'JOUR');
|
|
783
1067
|
lines.push(`TY - ${refType}`);
|
|
784
1068
|
// Authors — one AU tag per author
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
if (author.collectiveName) {
|
|
788
|
-
tag('AU', author.collectiveName);
|
|
789
|
-
}
|
|
790
|
-
else {
|
|
791
|
-
const last = author.lastName ?? '';
|
|
792
|
-
const first = author.firstName ?? '';
|
|
793
|
-
if (last || first) {
|
|
794
|
-
tag('AU', first ? `${last}, ${first}` : last);
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
}
|
|
1069
|
+
for (const author of article.authors ?? [])
|
|
1070
|
+
tag('AU', formatNameRis(author));
|
|
799
1071
|
// Title
|
|
800
1072
|
tag('TI', article.title);
|
|
801
1073
|
// Container — the journal, or the book a chapter sits in. `BT` is the book
|
|
@@ -810,14 +1082,8 @@ export function formatRis(article) {
|
|
|
810
1082
|
if (book) {
|
|
811
1083
|
if (article.recordType === 'book-chapter')
|
|
812
1084
|
tag('BT', book.title);
|
|
813
|
-
for (const editor of book.editors ?? [])
|
|
814
|
-
|
|
815
|
-
const first = editor.firstName ?? '';
|
|
816
|
-
if (editor.collectiveName)
|
|
817
|
-
tag('A2', editor.collectiveName);
|
|
818
|
-
else if (last || first)
|
|
819
|
-
tag('A2', first ? `${last}, ${first}` : last);
|
|
820
|
-
}
|
|
1085
|
+
for (const editor of book.editors ?? [])
|
|
1086
|
+
tag('A2', formatNameRis(editor));
|
|
821
1087
|
tag('PB', book.publisher);
|
|
822
1088
|
tag('CY', book.publisherLocation);
|
|
823
1089
|
tag('ET', book.edition);
|
|
@@ -834,7 +1100,7 @@ export function formatRis(article) {
|
|
|
834
1100
|
// Volume & Issue
|
|
835
1101
|
tag('VL', journal?.volume);
|
|
836
1102
|
tag('IS', journal?.issue);
|
|
837
|
-
// Pages —
|
|
1103
|
+
// Pages — the first page and the last, written in full ({@link splitPages}).
|
|
838
1104
|
// With no pagination, the article number goes on `C7` (the attested RIS
|
|
839
1105
|
// convention for Article Number), never on SP/EP, which hold absolute pages.
|
|
840
1106
|
if (journal?.pages) {
|
|
@@ -866,22 +1132,12 @@ export function formatRis(article) {
|
|
|
866
1132
|
if (url)
|
|
867
1133
|
lines.push(`UR - ${url}`);
|
|
868
1134
|
}
|
|
869
|
-
// Keywords —
|
|
870
|
-
const
|
|
871
|
-
for (const k of article.keywords ?? [])
|
|
872
|
-
keywordSet.add(k);
|
|
873
|
-
for (const m of article.meshTerms ?? []) {
|
|
874
|
-
if (m.descriptorName)
|
|
875
|
-
keywordSet.add(m.descriptorName);
|
|
876
|
-
}
|
|
877
|
-
for (const kw of keywordSet) {
|
|
1135
|
+
// Keywords — article keywords merged with MeSH descriptor names
|
|
1136
|
+
for (const kw of mergedKeywords(article)) {
|
|
878
1137
|
tag('KW', kw);
|
|
879
1138
|
}
|
|
880
|
-
// Abstract —
|
|
881
|
-
|
|
882
|
-
if (article.abstractText) {
|
|
883
|
-
tag('AB', collapseWhitespace(article.abstractText));
|
|
884
|
-
}
|
|
1139
|
+
// Abstract — a structured abstract's paragraphs flatten onto the one line
|
|
1140
|
+
tag('AB', article.abstractText);
|
|
885
1141
|
// End of record (trailing space per RIS spec)
|
|
886
1142
|
lines.push('ER - ');
|
|
887
1143
|
return lines.join('\n');
|
|
@@ -891,8 +1147,8 @@ export function formatRis(article) {
|
|
|
891
1147
|
// ---------------------------------------------------------------------------
|
|
892
1148
|
/**
|
|
893
1149
|
* Format a single author in Vancouver style: `Surname AB` — surname, a space,
|
|
894
|
-
* then initials with no periods or internal spaces
|
|
895
|
-
* return their name directly.
|
|
1150
|
+
* then initials with no periods or internal spaces, then any suffix after a
|
|
1151
|
+
* space (`Frenck RW Jr`). Collective/group authors return their name directly.
|
|
896
1152
|
*/
|
|
897
1153
|
function formatAuthorVancouver(author) {
|
|
898
1154
|
if (author.collectiveName)
|
|
@@ -913,9 +1169,7 @@ function formatAuthorVancouver(author) {
|
|
|
913
1169
|
: '';
|
|
914
1170
|
if (!last)
|
|
915
1171
|
return initials;
|
|
916
|
-
|
|
917
|
-
return last;
|
|
918
|
-
return `${last} ${initials}`;
|
|
1172
|
+
return `${initials ? `${last} ${initials}` : last}${trailingSuffix(author)}`;
|
|
919
1173
|
}
|
|
920
1174
|
/**
|
|
921
1175
|
* Format the full author list for Vancouver: list every author for six or
|
|
@@ -976,8 +1230,16 @@ function formatVancouverBook(article) {
|
|
|
976
1230
|
const url = bookshelfUrl(book);
|
|
977
1231
|
if (url)
|
|
978
1232
|
segments.push(`Available from: ${url}`);
|
|
979
|
-
return segments.join(' ');
|
|
1233
|
+
return collapseWhitespace(segments.join(' '));
|
|
980
1234
|
}
|
|
1235
|
+
/**
|
|
1236
|
+
* The notices Vancouver notes after the reference, each with Citing Medicine's
|
|
1237
|
+
* label (Boxes 58–59). NCBI's own NLM export prints these two and no others.
|
|
1238
|
+
*/
|
|
1239
|
+
const VANCOUVER_NOTE_LABELS = {
|
|
1240
|
+
RetractionIn: 'Retraction in',
|
|
1241
|
+
ErratumIn: 'Erratum in',
|
|
1242
|
+
};
|
|
981
1243
|
/**
|
|
982
1244
|
* Format a PubMed article as a Vancouver (ICMJE/NLM) reference — the numbered
|
|
983
1245
|
* style used by NEJM, Lancet, JAMA, BMJ, and most biomedical journals.
|
|
@@ -989,8 +1251,9 @@ function formatVancouverBook(article) {
|
|
|
989
1251
|
* ```
|
|
990
1252
|
* Journal name uses the NLM/ISO abbreviation when available; pages are used as
|
|
991
1253
|
* PubMed supplies them (often elided, e.g. "583-9"); the DOI carries no trailing
|
|
992
|
-
* period so it stays copy-pasteable. An article number replaces nothing — with
|
|
1254
|
+
* period so it stays copy-pasteable, unless a note follows it. An article number replaces nothing — with
|
|
993
1255
|
* no pagination it trails the source as an NLM note (`. pii: 2400512.`). A
|
|
1256
|
+
* retraction or erratum follows as a note (`Retraction in: <RefSource>`). A
|
|
994
1257
|
* Bookshelf record routes to {@link formatVancouverBook}.
|
|
995
1258
|
*/
|
|
996
1259
|
export function formatVancouver(article) {
|
|
@@ -1035,11 +1298,20 @@ export function formatVancouver(article) {
|
|
|
1035
1298
|
const label = journal?.elocationIdType;
|
|
1036
1299
|
segments.push(label ? `${label}: ${locator}.` : `${locator}.`);
|
|
1037
1300
|
}
|
|
1038
|
-
//
|
|
1301
|
+
// Citing Medicine's retraction and erratum notes (Boxes 58–59), one per
|
|
1302
|
+
// notice in NCBI's order with `RefSource` verbatim, as NCBI's NLM export
|
|
1303
|
+
// prints them. Each ends on exactly one period: some `RefSource`s carry none.
|
|
1304
|
+
const notes = citationNotices(article).flatMap((notice) => {
|
|
1305
|
+
const label = VANCOUVER_NOTE_LABELS[notice.refType];
|
|
1306
|
+
return label ? [terminate(`${label}: ${notice.refSource}`)] : [];
|
|
1307
|
+
});
|
|
1308
|
+
// DOI — NLM "doi: <doi>" form with no trailing period, which a reader copying
|
|
1309
|
+
// it would take as part of the DOI; a note after it closes it, as in NLM's export
|
|
1039
1310
|
if (article.doi) {
|
|
1040
|
-
segments.push(`doi: ${article.doi}`);
|
|
1311
|
+
segments.push(notes.length > 0 ? `doi: ${article.doi}.` : `doi: ${article.doi}`);
|
|
1041
1312
|
}
|
|
1042
|
-
|
|
1313
|
+
segments.push(...notes);
|
|
1314
|
+
return collapseWhitespace(segments.join(' '));
|
|
1043
1315
|
}
|
|
1044
1316
|
// ---------------------------------------------------------------------------
|
|
1045
1317
|
// Dispatchers
|