@cyanheads/pubmed-mcp-server 2.10.10 → 2.10.11

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