From ef16b9d06077a53b4e65a0383959f1a6ef5c2c26 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Fri, 17 Oct 2025 15:18:09 +0200 Subject: [PATCH] feat: add PubMed and software citation format support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends bibliography smart import capabilities with additional citation.js plugins: - @citation-js/plugin-pubmed: Support for PubMed IDs (PMID/PMCID) - @citation-js/plugin-software-formats: Support for software citations (CFF, GitHub, npm, Zenodo) Updates smart-parser.ts to recognize and validate Zenodo DOI format (10.5281/zenodo.xxxxx). Improves input type detection for better user feedback. Includes code formatting improvements to bibliographyStore.ts for consistency. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package-lock.json | 106 ++++++++++++++++++++---- package.json | 2 + src/stores/bibliographyStore.ts | 107 +++++++++++++------------ src/utils/bibliography/smart-parser.ts | 40 ++++----- 4 files changed, 169 insertions(+), 86 deletions(-) diff --git a/package-lock.json b/package-lock.json index f3c0dec..58c9550 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,9 @@ "@citation-js/plugin-bibtex": "^0.7.18", "@citation-js/plugin-csl": "^0.7.18", "@citation-js/plugin-doi": "^0.7.18", + "@citation-js/plugin-pubmed": "^0.3.0", "@citation-js/plugin-ris": "^0.7.18", + "@citation-js/plugin-software-formats": "^0.6.1", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.15.10", @@ -377,6 +379,18 @@ "@citation-js/core": "^0.7.0" } }, + "node_modules/@citation-js/plugin-cff": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@citation-js/plugin-cff/-/plugin-cff-0.6.1.tgz", + "integrity": "sha512-tLjTgsfzNOdQWGn5mNc2NAaydHnlRucSERoyAXLN7u0BQBfp7j5zwdxCmxcQD/N7hH3fpDKMG+qDzbqpJuKyNA==", + "dependencies": { + "@citation-js/date": "^0.5.0", + "@citation-js/plugin-yaml": "^0.6.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@citation-js/plugin-csl": { "version": "0.7.18", "resolved": "https://registry.npmjs.org/@citation-js/plugin-csl/-/plugin-csl-0.7.18.tgz", @@ -406,6 +420,41 @@ "@citation-js/core": "^0.7.0" } }, + "node_modules/@citation-js/plugin-github": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@citation-js/plugin-github/-/plugin-github-0.6.1.tgz", + "integrity": "sha512-1ZeSgQ5AoYsa8n2acVooUeRk76oA8rLszYNBjzj5z6MPa11BZlQJ9O+Gy4tHjlImvsENLbLPx5f8/V1VHXaCfQ==", + "dependencies": { + "@citation-js/date": "^0.5.0", + "@citation-js/name": "^0.4.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@citation-js/plugin-npm": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@citation-js/plugin-npm/-/plugin-npm-0.6.1.tgz", + "integrity": "sha512-rojJA+l/p2KBpDoY+8n0YfNyQO1Aw03fQR5BN+gXD1LNAP1V+8wqvdPsaHnzPsrhrd4ZXDR7ch/Nk0yynPkJ3Q==", + "dependencies": { + "@citation-js/date": "^0.5.0", + "@citation-js/name": "^0.4.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@citation-js/plugin-pubmed": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@citation-js/plugin-pubmed/-/plugin-pubmed-0.3.0.tgz", + "integrity": "sha512-E3l83VP5UnTh6lLJaTaNBIkNgIx3U6IjDNf7j5l4geBOaOwDIhkl9X+Ss33/MMNEIMNDsBoodoMJTZ8Cq+C/ug==", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@citation-js/core": ">=0.5.1 <=0.6" + } + }, "node_modules/@citation-js/plugin-ris": { "version": "0.7.18", "resolved": "https://registry.npmjs.org/@citation-js/plugin-ris/-/plugin-ris-0.7.18.tgz", @@ -421,6 +470,44 @@ "@citation-js/core": "^0.7.0" } }, + "node_modules/@citation-js/plugin-software-formats": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@citation-js/plugin-software-formats/-/plugin-software-formats-0.6.1.tgz", + "integrity": "sha512-BDF9rqi56K0hoTgYTVANCFVRSbWKC9V06Uap7oa8SjqCTgnHJAy8t/F3NxsyYPPG+zmRsLW9VNbcIsJOl0eu/w==", + "dependencies": { + "@citation-js/plugin-cff": "^0.6.1", + "@citation-js/plugin-github": "^0.6.1", + "@citation-js/plugin-npm": "^0.6.1", + "@citation-js/plugin-yaml": "^0.6.1", + "@citation-js/plugin-zenodo": "^0.6.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@citation-js/plugin-yaml": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@citation-js/plugin-yaml/-/plugin-yaml-0.6.1.tgz", + "integrity": "sha512-XEVVks1cJTqRbjy+nmthfw/puR6NwRB3fyJWi1tX13UYXlkhP/h45nsv4zjgLLGekdcMHQvhad9MAYunOftGKA==", + "dependencies": { + "js-yaml": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@citation-js/plugin-zenodo": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@citation-js/plugin-zenodo/-/plugin-zenodo-0.6.1.tgz", + "integrity": "sha512-bUybENHoZqJ6gheUqgkumjI+mu+fA2bg6VoniDmZTb7Qng9iEpi+IWEAR26/vBE0gK0EWrJjczyDW3HCwrhvVw==", + "dependencies": { + "@citation-js/date": "^0.5.0", + "@citation-js/name": "^0.4.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -2103,6 +2190,7 @@ "version": "18.3.26", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "dev": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2436,8 +2524,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-union": { "version": "2.1.0", @@ -3784,7 +3871,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -5402,20 +5488,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 0a7a7a6..5c4434e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "@citation-js/plugin-bibtex": "^0.7.18", "@citation-js/plugin-csl": "^0.7.18", "@citation-js/plugin-doi": "^0.7.18", + "@citation-js/plugin-pubmed": "^0.3.0", "@citation-js/plugin-ris": "^0.7.18", + "@citation-js/plugin-software-formats": "^0.6.1", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.15.10", diff --git a/src/stores/bibliographyStore.ts b/src/stores/bibliographyStore.ts index b7c4733..843414f 100644 --- a/src/stores/bibliographyStore.ts +++ b/src/stores/bibliographyStore.ts @@ -1,29 +1,31 @@ -import { create } from 'zustand'; +import { create } from "zustand"; // @ts-expect-error - citation.js doesn't have TypeScript definitions -import { Cite } from '@citation-js/core'; +import { Cite } from "@citation-js/core"; // Load plugins -import '@citation-js/plugin-csl'; -import '@citation-js/plugin-doi'; -import '@citation-js/plugin-bibtex'; -import '@citation-js/plugin-ris'; +import "@citation-js/plugin-csl"; +import "@citation-js/plugin-doi"; +import "@citation-js/plugin-bibtex"; +import "@citation-js/plugin-ris"; +import "@citation-js/plugin-pubmed"; +import "@citation-js/plugin-software-formats"; import type { BibliographyReference, CSLReference, ReferenceAppMetadata, - BibliographySettings -} from '../types/bibliography'; + BibliographySettings, +} from "../types/bibliography"; interface BibliographyStore { // State - citeInstance: Cite; // The citation.js instance - appMetadata: Record; // App-specific data + citeInstance: Cite; // The citation.js instance + appMetadata: Record; // App-specific data settings: BibliographySettings; // Getters - getReferences: () => BibliographyReference[]; // Merges CSL + app data + getReferences: () => BibliographyReference[]; // Merges CSL + app data getReferenceById: (id: string) => BibliographyReference | undefined; - getCSLData: () => CSLReference[]; // Raw CSL-JSON from Cite + getCSLData: () => CSLReference[]; // Raw CSL-JSON from Cite // CRUD operations (use citation.js methods) addReference: (ref: Partial) => string; @@ -43,18 +45,22 @@ interface BibliographyStore { setSettings: (settings: BibliographySettings) => void; // Citation.js powered operations - formatReference: (id: string, style?: string, format?: 'html' | 'text') => string; - formatBibliography: (style?: string, format?: 'html' | 'text') => string; + formatReference: ( + id: string, + style?: string, + format?: "html" | "text", + ) => string; + formatBibliography: (style?: string, format?: "html" | "text") => string; parseInput: (input: string) => Promise; - exportAs: (format: 'bibtex' | 'ris' | 'json') => string; + exportAs: (format: "bibtex" | "ris" | "json") => string; } export const useBibliographyStore = create((set, get) => ({ citeInstance: new Cite([]), appMetadata: {}, settings: { - defaultStyle: 'apa', - sortOrder: 'author', + defaultStyle: "apa", + sortOrder: "author", }, getReferences: () => { @@ -62,7 +68,7 @@ export const useBibliographyStore = create((set, get) => ({ const metadata = get().appMetadata; // Merge CSL data with app metadata - return csl.map(ref => ({ + return csl.map((ref) => ({ ...ref, _app: metadata[ref.id], })); @@ -70,7 +76,7 @@ export const useBibliographyStore = create((set, get) => ({ getReferenceById: (id) => { const refs = get().getReferences(); - return refs.find(ref => ref.id === id); + return refs.find((ref) => ref.id === id); }, getCSLData: () => { @@ -81,7 +87,8 @@ export const useBibliographyStore = create((set, get) => ({ const { citeInstance } = get(); // Generate ID if not provided - const id = ref.id || `ref-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const id = + ref.id || `ref-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const fullRef = { ...ref, id }; // Use citation.js add() method @@ -89,7 +96,7 @@ export const useBibliographyStore = create((set, get) => ({ // Add app metadata const now = new Date().toISOString(); - set(state => ({ + set((state) => ({ appMetadata: { ...state.appMetadata, [id]: { @@ -109,15 +116,15 @@ export const useBibliographyStore = create((set, get) => ({ const data = citeInstance.data as CSLReference[]; // Find and update the reference - const updatedData = data.map(ref => - ref.id === id ? { ...ref, ...updates } : ref + const updatedData = data.map((ref) => + ref.id === id ? { ...ref, ...updates } : ref, ); // Use citation.js set() method to replace all data citeInstance.set(updatedData); // Update metadata timestamp - set(state => ({ + set((state) => ({ appMetadata: { ...state.appMetadata, [id]: { @@ -133,11 +140,11 @@ export const useBibliographyStore = create((set, get) => ({ const data = citeInstance.data as CSLReference[]; // Remove from citation.js - const filteredData = data.filter(ref => ref.id !== id); + const filteredData = data.filter((ref) => ref.id !== id); citeInstance.set(filteredData); // Remove metadata - set(state => { + set((state) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [id]: _removed, ...rest } = state.appMetadata; return { appMetadata: rest }; @@ -146,7 +153,7 @@ export const useBibliographyStore = create((set, get) => ({ duplicateReference: (id) => { const original = get().getReferenceById(id); - if (!original) return ''; + if (!original) return ""; const newId = `ref-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const duplicate = { @@ -173,7 +180,7 @@ export const useBibliographyStore = create((set, get) => ({ const metadata: Record = {}; const now = new Date().toISOString(); - refs.forEach(ref => { + refs.forEach((ref) => { metadata[ref.id] = { id: ref.id, tags: [], @@ -193,9 +200,9 @@ export const useBibliographyStore = create((set, get) => ({ // Add metadata for new references const now = new Date().toISOString(); - set(state => { + set((state) => { const newMetadata = { ...state.appMetadata }; - refs.forEach(ref => { + refs.forEach((ref) => { if (!newMetadata[ref.id]) { newMetadata[ref.id] = { id: ref.id, @@ -210,12 +217,12 @@ export const useBibliographyStore = create((set, get) => ({ }, clearAll: () => { - get().citeInstance.reset(); // citation.js method to clear all data + get().citeInstance.reset(); // citation.js method to clear all data set({ appMetadata: {} }); }, updateMetadata: (id, updates) => { - set(state => ({ + set((state) => ({ appMetadata: { ...state.appMetadata, [id]: { @@ -229,34 +236,34 @@ export const useBibliographyStore = create((set, get) => ({ setSettings: (settings) => set({ settings }), - formatReference: (id, style, format = 'html') => { + formatReference: (id, style, format = "html") => { const { citeInstance, settings } = get(); const styleToUse = style || settings.defaultStyle; // Find the reference - const ref = (citeInstance.data as CSLReference[]).find(r => r.id === id); - if (!ref) return ''; + const ref = (citeInstance.data as CSLReference[]).find((r) => r.id === id); + if (!ref) return ""; // Create temporary Cite instance for single reference const cite = new Cite(ref); // Use citation.js format() method - return cite.format('bibliography', { + return cite.format("bibliography", { format: format, template: styleToUse, - lang: 'en-US', + lang: "en-US", }); }, - formatBibliography: (style, format = 'html') => { + formatBibliography: (style, format = "html") => { const { citeInstance, settings } = get(); const styleToUse = style || settings.defaultStyle; // Use citation.js format() method on entire instance - return citeInstance.format('bibliography', { + return citeInstance.format("bibliography", { format: format, template: styleToUse, - lang: 'en-US', + lang: "en-US", }); }, @@ -267,8 +274,8 @@ export const useBibliographyStore = create((set, get) => ({ const cite = await Cite.async(input); return cite.data as CSLReference[]; } catch (error) { - console.error('Failed to parse input:', error); - throw new Error('Could not parse citation data'); + console.error("Failed to parse input:", error); + throw new Error("Could not parse citation data"); } }, @@ -277,14 +284,14 @@ export const useBibliographyStore = create((set, get) => ({ // Use citation.js format() method switch (format) { - case 'bibtex': - return citeInstance.format('bibtex'); - case 'ris': - return citeInstance.format('ris'); - case 'json': + case "bibtex": + return citeInstance.format("bibtex"); + case "ris": + return citeInstance.format("ris"); + case "json": return JSON.stringify(citeInstance.data, null, 2); default: - return ''; + return ""; } }, })); @@ -295,8 +302,8 @@ export const clearBibliographyForDocumentSwitch = () => { citeInstance: new Cite([]), appMetadata: {}, settings: { - defaultStyle: 'apa', - sortOrder: 'author', + defaultStyle: "apa", + sortOrder: "author", }, }); }; diff --git a/src/utils/bibliography/smart-parser.ts b/src/utils/bibliography/smart-parser.ts index 20d1a48..688c27f 100644 --- a/src/utils/bibliography/smart-parser.ts +++ b/src/utils/bibliography/smart-parser.ts @@ -1,17 +1,21 @@ // @ts-expect-error - citation.js doesn't have TypeScript definitions -import { Cite } from '@citation-js/core'; -import type { CSLReference } from '../../types/bibliography'; +import { Cite } from "@citation-js/core"; +import type { CSLReference } from "../../types/bibliography"; /** * Parse any citation input using citation.js * Handles: DOI, URL, BibTeX, RIS, ISBN, PubMed ID, etc. */ -export const parseSmartInput = async (input: string): Promise => { +export const parseSmartInput = async ( + input: string, +): Promise => { try { const cite = await Cite.async(input); return cite.data as CSLReference[]; } catch (error) { - throw new Error(`Could not parse input: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new Error( + `Could not parse input: ${error instanceof Error ? error.message : "Unknown error"}`, + ); } }; @@ -46,9 +50,8 @@ export const isValidCitationInput = (input: string): boolean => { // Wikidata ID (Q followed by numbers) if (/^(wikidata:)?Q\d+$/i.test(trimmed)) return true; - // Zenodo ID or URL - if (/^zenodo\.\d+$/i.test(trimmed)) return true; - if (/zenodo\.org\/record\/\d+/i.test(trimmed)) return true; + // Zenodo DOI (10.5281/zenodo.xxxxx) + if (/^10\.5281\/zenodo\.\d+/i.test(trimmed)) return true; // CFF (Citation File Format) - YAML-based if (/^cff-version:/m.test(trimmed)) return true; @@ -62,17 +65,16 @@ export const isValidCitationInput = (input: string): boolean => { export const getInputTypeHint = (input: string): string => { const trimmed = input.trim(); - if (/^10\.\d{4,}\//.test(trimmed)) return 'DOI'; - if (/zenodo\.org\/record\/\d+/i.test(trimmed)) return 'Zenodo URL'; - if (/^zenodo\.\d+$/i.test(trimmed)) return 'Zenodo ID'; - if (/^https?:\/\//.test(trimmed)) return 'URL'; - if (/^@\w+\{/.test(trimmed)) return 'BibTeX'; - if (/^TY\s+-\s+/m.test(trimmed)) return 'RIS Format'; - if (/^PMID:/i.test(trimmed)) return 'PubMed ID (PMID)'; - if (/^PMC\d+/i.test(trimmed)) return 'PubMed Central ID (PMCID)'; - if (/^ISBN/i.test(trimmed)) return 'ISBN'; - if (/^(wikidata:)?Q\d+$/i.test(trimmed)) return 'Wikidata ID'; - if (/^cff-version:/m.test(trimmed)) return 'Citation File Format (CFF)'; + if (/^10\.5281\/zenodo\.\d+/i.test(trimmed)) return "Zenodo DOI"; + if (/^10\.\d{4,}\//.test(trimmed)) return "DOI"; + if (/^https?:\/\//.test(trimmed)) return "URL"; + if (/^@\w+\{/.test(trimmed)) return "BibTeX"; + if (/^TY\s+-\s+/m.test(trimmed)) return "RIS Format"; + if (/^PMID:/i.test(trimmed)) return "PubMed ID (PMID)"; + if (/^PMC\d+/i.test(trimmed)) return "PubMed Central ID (PMCID)"; + if (/^ISBN/i.test(trimmed)) return "ISBN"; + if (/^(wikidata:)?Q\d+$/i.test(trimmed)) return "Wikidata ID"; + if (/^cff-version:/m.test(trimmed)) return "Citation File Format (CFF)"; - return 'Unknown'; + return "Unknown"; };