From b0eb051b8d556a9ff2dc3d90dbd684c3f544e122 Mon Sep 17 00:00:00 2001 From: Kritarth-Dandapat Date: Thu, 23 Jul 2026 16:56:49 +0530 Subject: [PATCH] P2-04: ORCID author badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a clickable ORCID badge next to authors in the Details panel when an ORCID is available, linking to the author's ORCID profile. Authors without an ORCID render as plain text (graceful absence). ORCID source is the Crossref metadata already fetched during DOI ingest — no new external API call, no key. `core/ingest/fetch.ts` parses each Crossref author's `ORCID` field, normalizes it to a bare id (strips http/https + orcid.org host + trailing slash, validates the `\d{4}-\d{4}-\d{4}-\d{3}[\dX]` shape, malformed → null), and builds an `authorOrcids` array positionally aligned to `authors`. The arXiv path has no ORCID data. Alignment safety: the persisted `authorOrcids` are kept only when the final author list is content-equal to the Crossref author list they were aligned to. If agent-extracted authors win and diverge from Crossref's, ORCIDs are dropped (column NULL) rather than risk attaching a badge to the wrong author. Storage is additive: migration 5 adds a nullable `author_orcids` TEXT column (JSON, positionally aligned to authors, null per-author when unknown, whole column NULL for papers with no ORCID data). `authors` stays `string[]` — no change to existing read sites. Old rows read back with authorOrcids undefined. Renderer-only UI: DetailsPanel renders authors individually; OrcidBadge is an anchor to https://orcid.org/ (target=_blank rel=noopener) with an accessible label. Data rides the existing vellum:get-paper channel on the shared PaperRecord — no new IPC. Acceptance ([P2-04]): - badge renders when ORCID present ✓ - opens profile ✓ - graceful absence ✓ - tests ✓ (migration+column, repo round-trip incl null/undefined/legacy row, ORCID normalization + alignment + divergence-drop, DetailsPanel + OrcidBadge) Co-Authored-By: Claude Opus 4.8 --- core/ingest/fetch.test.ts | 46 +++++++++++++++ core/ingest/fetch.ts | 22 ++++++- core/ingest/index.test.ts | 106 +++++++++++++++++++++++++++++++++- core/ingest/index.ts | 25 +++++++- core/library/repo.test.ts | 51 ++++++++++++++++ core/library/repo.ts | 40 ++++++++----- core/store/db.test.ts | 2 +- core/store/migrate.test.ts | 16 ++++- core/store/migrate.ts | 3 +- core/store/schema.ts | 13 +++++ src/app/DetailsPanel.test.tsx | 66 ++++++++++++++++++++- src/app/DetailsPanel.tsx | 17 +++++- src/app/OrcidBadge.module.css | 14 +++++ src/app/OrcidBadge.test.tsx | 13 +++++ src/app/OrcidBadge.tsx | 15 +++++ 15 files changed, 424 insertions(+), 25 deletions(-) create mode 100644 src/app/OrcidBadge.module.css create mode 100644 src/app/OrcidBadge.test.tsx create mode 100644 src/app/OrcidBadge.tsx diff --git a/core/ingest/fetch.test.ts b/core/ingest/fetch.test.ts index 2c8c544..b0d127f 100644 --- a/core/ingest/fetch.test.ts +++ b/core/ingest/fetch.test.ts @@ -31,6 +31,19 @@ const CROSSREF_FIXTURE = { }, } +const CROSSREF_FIXTURE_WITH_ORCIDS = { + message: { + title: ['ORCID-Bearing Paper'], + author: [ + { given: 'Ada', family: 'Lovelace', ORCID: 'http://orcid.org/0000-0002-1825-0097' }, + { given: 'Alan', family: 'Turing' }, + { given: 'Grace', family: 'Hopper', ORCID: 'https://orcid.org/0000-0001-5109-3700/' }, + ], + published: { 'date-parts': [[2023, 1, 1]] }, + 'container-title': ['Journal of Reproducible Authorship'], + }, +} + describe('fetchSource', () => { let fetchMock: ReturnType @@ -67,6 +80,7 @@ describe('fetchSource', () => { venue: 'NeurIPS 2024', abstract: 'This paper revisits attention mechanisms.', }) + expect(result.metadata?.authorOrcids).toBeUndefined() const calledUrls = fetchMock.mock.calls.map((c) => String(c[0])) expect(calledUrls.some((u) => u.includes('id_list=2401.12345'))).toBe(true) @@ -103,6 +117,7 @@ describe('fetchSource', () => { venue: 'Journal of Made-Up Science', abstract: 'A survey of things.', }) + expect(result.metadata?.authorOrcids).toBeUndefined() const calledUrls = fetchMock.mock.calls.map((c) => String(c[0])) expect( @@ -110,6 +125,37 @@ describe('fetchSource', () => { ).toBe(true) }) + it('extracts and bare-normalizes ORCIDs from Crossref, positionally aligned with nulls for authors with none', async () => { + fetchMock.mockImplementation((url: string) => { + if (url.includes('api.crossref.org/works/')) { + return Promise.resolve( + new Response(JSON.stringify(CROSSREF_FIXTURE_WITH_ORCIDS), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + } + if (url.startsWith('https://doi.org/')) { + return Promise.resolve(new Response(new Uint8Array([1]), { status: 200 })) + } + throw new Error(`unexpected fetch: ${url}`) + }) + + const input: ClassifiedInput = { + kind: 'doi', + slug: 'doi-orcid-test', + value: '10.1/orcid-test', + } + const result = await fetchSource(input) + + expect(result.metadata?.authors).toEqual(['Ada Lovelace', 'Alan Turing', 'Grace Hopper']) + expect(result.metadata?.authorOrcids).toEqual([ + '0000-0002-1825-0097', + null, + '0000-0001-5109-3700', + ]) + }) + it('fetches raw PDF bytes for a direct pdf_url with no metadata', async () => { fetchMock.mockImplementation((url: string) => { if (url === 'https://example.com/paper.pdf') { diff --git a/core/ingest/fetch.ts b/core/ingest/fetch.ts index 7222adf..f26140f 100644 --- a/core/ingest/fetch.ts +++ b/core/ingest/fetch.ts @@ -40,6 +40,10 @@ export interface RawMetadata { year: number | undefined venue: string | undefined abstract: string | undefined + /** Bare ORCID ids (e.g. "0000-0002-1825-0097"), positionally aligned to + * `authors`; null for authors with no known ORCID. Crossref-only signal — + * undefined when the source (e.g. arXiv) never carries ORCIDs. */ + authorOrcids?: (string | null)[] } export interface FetchedSource { @@ -115,7 +119,7 @@ async function fetchArxivPdf(arxivId: string): Promise { interface CrossrefWork { message: { title?: string[] - author?: Array<{ given?: string; family?: string; name?: string }> + author?: Array<{ given?: string; family?: string; name?: string; ORCID?: unknown }> published?: { 'date-parts'?: number[][] } 'published-print'?: { 'date-parts'?: number[][] } 'container-title'?: string[] @@ -123,6 +127,16 @@ interface CrossrefWork { } } +// Crossref's ORCID field is a URL like "http://orcid.org/0000-0002-1825-0097" +// or "https://orcid.org/0000-0002-1825-0097/" — normalize to the bare id. +// Defensive: any non-string or unrecognized shape yields null rather than +// throwing (ORCID is a best-effort signal, never load-bearing for ingest). +function normalizeOrcid(value: unknown): string | null { + if (typeof value !== 'string') return null + const stripped = value.trim().replace(/^https?:\/\/orcid\.org\//i, '').replace(/\/+$/, '') + return /^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$/.test(stripped) ? stripped : null +} + async function fetchCrossrefMetadata(doi: string): Promise { const res = await fetch(`${CROSSREF_API}/${encodeURIComponent(doi)}`) if (!res.ok) { @@ -136,9 +150,12 @@ async function fetchCrossrefMetadata(doi: string): Promise { throw new Error(`Crossref API: missing title for ${doi}`) } - const authors = (work.author ?? []).map((a) => + const crossrefAuthors = work.author ?? [] + const authors = crossrefAuthors.map((a) => a.name ?? [a.given, a.family].filter(Boolean).join(' '), ) + const authorOrcids = crossrefAuthors.map((a) => normalizeOrcid(a.ORCID)) + const hasAnyOrcid = authorOrcids.some((id) => id !== null) const dateParts = work.published?.['date-parts']?.[0] ?? work['published-print']?.['date-parts']?.[0] @@ -152,6 +169,7 @@ async function fetchCrossrefMetadata(doi: string): Promise { year, venue, abstract: work.abstract ? normalizeWhitespace(stripJats(work.abstract)) : undefined, + authorOrcids: hasAnyOrcid ? authorOrcids : undefined, } } diff --git a/core/ingest/index.test.ts b/core/ingest/index.test.ts index 337d774..c72e846 100644 --- a/core/ingest/index.test.ts +++ b/core/ingest/index.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AcpClient, AcpPromptRequest, AcpSession, AcpUpdate } from '../acp/client.js' import { getPaper, listPapers } from '../library/repo.js' @@ -130,6 +130,110 @@ describe('ingest', () => { } }) + // Shared Crossref fixture for the authorOrcids alignment tests below. + const CROSSREF_ORCID_BODY = JSON.stringify({ + message: { + title: ['Crossref Title'], + author: [ + { given: 'Ada', family: 'Lovelace', ORCID: 'http://orcid.org/0000-0002-1825-0097' }, + { given: 'Alan', family: 'Turing' }, + ], + published: { 'date-parts': [[2022, 1, 1]] }, + }, + }) + + function stubCrossrefFetch(pdfBytes: Buffer) { + const fetchMock = vi.fn((url: string) => { + if (url.includes('api.crossref.org/works/')) { + return Promise.resolve( + new Response(CROSSREF_ORCID_BODY, { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + } + if (url.startsWith('https://doi.org/')) { + return Promise.resolve(new Response(new Uint8Array(pdfBytes), { status: 200 })) + } + throw new Error(`unexpected fetch: ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) + } + + it('drops authorOrcids when agent-extracted authors diverge from the Crossref list they were aligned to', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'vellum-ingest-')) + const db = openDb({ path: ':memory:' }) + // AGENT_JSON's authors (['Agent Author']) differ from the Crossref + // fixture's authors (['Ada Lovelace', 'Alan Turing']) — agent authors win + // per the extracted-first merge, so the Crossref-aligned ORCIDs would + // misalign if kept. + const client = new FakeClient([textUpdate(AGENT_JSON), doneUpdate]) + stubCrossrefFetch(readFileSync(FIXTURE_PDF)) + + try { + const result = await ingest('10.1234/orcid-divergent-test', { db, dataDir: tmpDir, client }) + + expect(result.metadata.authors).toEqual(['Agent Author']) + expect(result.metadata.authorOrcids).toBeUndefined() + + const row = getPaper(db, result.slug) + expect(row?.authorOrcids).toBeUndefined() + } finally { + db.close() + vi.unstubAllGlobals() + } + }) + + it('retains authorOrcids when agent-extracted authors are content-equal to the Crossref list', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'vellum-ingest-')) + const db = openDb({ path: ':memory:' }) + const agentJsonMatchingCrossref = JSON.stringify({ + sections: [{ title: 'Sample Paper Title', startOffset: 0, endOffset: 20 }], + metadata: { + title: 'Sample Paper Title (agent-refined)', + authors: ['Ada Lovelace', 'Alan Turing'], + year: 2026, + }, + }) + const client = new FakeClient([textUpdate(agentJsonMatchingCrossref), doneUpdate]) + stubCrossrefFetch(readFileSync(FIXTURE_PDF)) + + try { + const result = await ingest('10.1234/orcid-aligned-test', { db, dataDir: tmpDir, client }) + + expect(result.metadata.authors).toEqual(['Ada Lovelace', 'Alan Turing']) + expect(result.metadata.authorOrcids).toEqual(['0000-0002-1825-0097', null]) + + const row = getPaper(db, result.slug) + expect(row?.authorOrcids).toEqual(['0000-0002-1825-0097', null]) + } finally { + db.close() + vi.unstubAllGlobals() + } + }) + + it('retains authorOrcids when the agent extracts no authors (falls back to the Crossref-aligned list)', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'vellum-ingest-')) + const db = openDb({ path: ':memory:' }) + // Agent returns nothing usable -> extractPaper degrades to {}, so + // metadata.authors falls back to fetched.metadata.authors verbatim. + const client = new FakeClient([textUpdate('no json here'), doneUpdate]) + stubCrossrefFetch(readFileSync(FIXTURE_PDF)) + + try { + const result = await ingest('10.1234/orcid-fallback-test', { db, dataDir: tmpDir, client }) + + expect(result.metadata.authors).toEqual(['Ada Lovelace', 'Alan Turing']) + expect(result.metadata.authorOrcids).toEqual(['0000-0002-1825-0097', null]) + + const row = getPaper(db, result.slug) + expect(row?.authorOrcids).toEqual(['0000-0002-1825-0097', null]) + } finally { + db.close() + vi.unstubAllGlobals() + } + }) + it('re-ingesting the same input is idempotent: no duplicate row, files overwritten not duplicated', async () => { tmpDir = mkdtempSync(join(tmpdir(), 'vellum-ingest-')) const db = openDb({ path: ':memory:' }) diff --git a/core/ingest/index.ts b/core/ingest/index.ts index 5cdba23..ebccf5a 100644 --- a/core/ingest/index.ts +++ b/core/ingest/index.ts @@ -49,6 +49,9 @@ export interface IngestMetadata { year?: number venue?: string abstract?: string + /** Bare ORCID ids positionally aligned to `authors`; fetch-only signal + * (Crossref), never agent-extracted. Undefined when the source has none. */ + authorOrcids?: (string | null)[] } export interface IngestResult { @@ -107,12 +110,31 @@ export async function ingest(raw: string, options: IngestOptions): Promise DB column NULL). No + // fuzzy re-matching — conservative "aligned or nothing". + const fetchedAuthors = fetched.metadata?.authors + const authorsMatchFetched = + fetchedAuthors !== undefined && + finalAuthors.length === fetchedAuthors.length && + finalAuthors.every((a, i) => a === fetchedAuthors[i]) + const authorOrcids = authorsMatchFetched ? fetched.metadata?.authorOrcids : undefined + const metadata: IngestMetadata = { title: extracted.metadata.title ?? fetched.metadata?.title ?? classified.value, - authors: extracted.metadata.authors ?? fetched.metadata?.authors ?? [], + authors: finalAuthors, year: extracted.metadata.year ?? fetched.metadata?.year, venue: extracted.metadata.venue ?? fetched.metadata?.venue, abstract: extracted.metadata.abstract ?? fetched.metadata?.abstract, + authorOrcids, } const record: PaperRecord = { @@ -129,6 +151,7 @@ export async function ingest(raw: string, options: IngestOptions): Promise { } }) + describe('author_orcids [P2-04]', () => { + it('round-trips authorOrcids, including null entries for unknown authors', () => { + const db = openDb({ path: ':memory:' }) + try { + upsertPaper( + db, + makeRecord({ + slug: 'orcid-paper', + authors: ['Jane Doe', 'John Smith'], + authorOrcids: ['0000-0002-1825-0097', null], + }), + ) + + const row = getPaper(db, 'orcid-paper') + expect(row?.authors).toEqual(['Jane Doe', 'John Smith']) + expect(row?.authorOrcids).toEqual(['0000-0002-1825-0097', null]) + } finally { + db.close() + } + }) + + it('a paper upserted without authorOrcids reads back with authorOrcids undefined, authors intact', () => { + const db = openDb({ path: ':memory:' }) + try { + upsertPaper(db, makeRecord({ slug: 'no-orcids' })) + + const row = getPaper(db, 'no-orcids') + expect(row?.authorOrcids).toBeUndefined() + expect(row?.authors).toEqual(['Jane Doe', 'John Smith']) + } finally { + db.close() + } + }) + + it('an old row (author_orcids column NULL) reads back with authorOrcids undefined', () => { + const db = openDb({ path: ':memory:' }) + try { + // Simulate a pre-[P2-04] row inserted without touching the new column at all. + db.prepare( + `INSERT INTO papers (slug, title, authors, added_at) VALUES (?, ?, ?, ?)`, + ).run('legacy', 'Legacy Paper', JSON.stringify(['Old Author']), '2026-01-01T00:00:00.000Z') + + const row = getPaper(db, 'legacy') + expect(row?.authorOrcids).toBeUndefined() + expect(row?.authors).toEqual(['Old Author']) + } finally { + db.close() + } + }) + }) + it('listPapers orders most-recently-added first', () => { const db = openDb({ path: ':memory:' }) try { diff --git a/core/library/repo.ts b/core/library/repo.ts index dff5f83..99c2262 100644 --- a/core/library/repo.ts +++ b/core/library/repo.ts @@ -17,6 +17,12 @@ export interface PaperRecord { slug: string title: string authors: string[] + /** [P2-04] ORCIDs positionally aligned to `authors` — entry `i` is the bare + * ORCID id (e.g. `0000-0002-1825-0097`) for `authors[i]`, or `null` when + * that specific author's ORCID is unknown. `undefined` when no ORCID data + * is known for this paper at all (column is NULL — predates this feature + * or was never supplied), as distinct from an array of all-null entries. */ + authorOrcids?: (string | null)[] year?: number venue?: string doi?: string @@ -34,6 +40,7 @@ interface PaperRow { slug: string title: string authors: string | null + author_orcids: string | null year: number | null venue: string | null doi: string | null @@ -51,6 +58,9 @@ function toRecord(row: PaperRow): PaperRecord { slug: row.slug, title: row.title, authors: row.authors ? (JSON.parse(row.authors) as string[]) : [], + authorOrcids: row.author_orcids + ? (JSON.parse(row.author_orcids) as (string | null)[]) + : undefined, year: row.year ?? undefined, venue: row.venue ?? undefined, doi: row.doi ?? undefined, @@ -71,25 +81,27 @@ function toRecord(row: PaperRow): PaperRecord { */ export function upsertPaper(db: Database, paper: PaperRecord): void { db.prepare( - `INSERT INTO papers (slug, title, authors, year, venue, doi, arxiv_id, abstract, summary, md_path, pdf_path, sections, added_at) - VALUES (@slug, @title, @authors, @year, @venue, @doi, @arxivId, @abstract, @summary, @mdPath, @pdfPath, @sections, @addedAt) + `INSERT INTO papers (slug, title, authors, author_orcids, year, venue, doi, arxiv_id, abstract, summary, md_path, pdf_path, sections, added_at) + VALUES (@slug, @title, @authors, @authorOrcids, @year, @venue, @doi, @arxivId, @abstract, @summary, @mdPath, @pdfPath, @sections, @addedAt) ON CONFLICT(slug) DO UPDATE SET - title = excluded.title, - authors = excluded.authors, - year = excluded.year, - venue = excluded.venue, - doi = excluded.doi, - arxiv_id = excluded.arxiv_id, - abstract = excluded.abstract, - summary = excluded.summary, - md_path = excluded.md_path, - pdf_path = excluded.pdf_path, - sections = excluded.sections, - added_at = excluded.added_at`, + title = excluded.title, + authors = excluded.authors, + author_orcids = excluded.author_orcids, + year = excluded.year, + venue = excluded.venue, + doi = excluded.doi, + arxiv_id = excluded.arxiv_id, + abstract = excluded.abstract, + summary = excluded.summary, + md_path = excluded.md_path, + pdf_path = excluded.pdf_path, + sections = excluded.sections, + added_at = excluded.added_at`, ).run({ slug: paper.slug, title: paper.title, authors: JSON.stringify(paper.authors ?? []), + authorOrcids: paper.authorOrcids !== undefined ? JSON.stringify(paper.authorOrcids) : null, year: paper.year ?? null, venue: paper.venue ?? null, doi: paper.doi ?? null, diff --git a/core/store/db.test.ts b/core/store/db.test.ts index 0dd0c48..a197e68 100644 --- a/core/store/db.test.ts +++ b/core/store/db.test.ts @@ -60,7 +60,7 @@ describe('openDb', () => { const row = db2.prepare('SELECT * FROM papers WHERE slug = ?').get('p1') expect(row).toBeTruthy() const version = db2.pragma('user_version', { simple: true }) - expect(version).toBe(4) + expect(version).toBe(5) } finally { db2.close() } diff --git a/core/store/migrate.test.ts b/core/store/migrate.test.ts index bdf2d5c..1f9894c 100644 --- a/core/store/migrate.test.ts +++ b/core/store/migrate.test.ts @@ -37,7 +37,19 @@ describe('runMigrations', () => { runMigrations(db) const version = db.pragma('user_version', { simple: true }) - expect(version).toBe(4) + expect(version).toBe(5) + }) + + it('adds the author_orcids column to papers [P2-04]', () => { + db = new Database(':memory:') + runMigrations(db) + + const columns = db + .prepare("PRAGMA table_info(papers)") + .all() + .map((row) => (row as { name: string }).name) + + expect(columns).toContain('author_orcids') }) it('creates the highlights table index', () => { @@ -64,7 +76,7 @@ describe('runMigrations', () => { runMigrations(db) const version = db.pragma('user_version', { simple: true }) - expect(version).toBe(4) + expect(version).toBe(5) const row = db.prepare('SELECT * FROM papers WHERE slug = ?').get('a') expect(row).toBeTruthy() diff --git a/core/store/migrate.ts b/core/store/migrate.ts index ac13407..023a0fb 100644 --- a/core/store/migrate.ts +++ b/core/store/migrate.ts @@ -12,7 +12,7 @@ import type { Database } from 'better-sqlite3' -import { SCHEMA_V1, SCHEMA_V3_NOTES, SCHEMA_V4_HIGHLIGHTS } from './schema.js' +import { SCHEMA_V1, SCHEMA_V3_NOTES, SCHEMA_V4_HIGHLIGHTS, SCHEMA_V5_AUTHOR_ORCIDS } from './schema.js' export interface Migration { version: number @@ -25,6 +25,7 @@ export const MIGRATIONS: Migration[] = [ { version: 2, sql: 'ALTER TABLE papers ADD COLUMN summary TEXT;' }, { version: 3, sql: SCHEMA_V3_NOTES }, // [P2-01] notes table { version: 4, sql: SCHEMA_V4_HIGHLIGHTS }, // [P2-02] highlights table + { version: 5, sql: SCHEMA_V5_AUTHOR_ORCIDS }, // [P2-04] author_orcids column ] /** diff --git a/core/store/schema.ts b/core/store/schema.ts index a57dbed..246ce0a 100644 --- a/core/store/schema.ts +++ b/core/store/schema.ts @@ -96,3 +96,16 @@ CREATE TABLE IF NOT EXISTS highlights ( ); CREATE INDEX IF NOT EXISTS idx_highlights_paper ON highlights(paper_slug); ` + +// [P2-04] ORCID author badges. Authors stay a plain `authors TEXT` JSON +// array (see SCHEMA_V1) — this migration does NOT touch that column. ORCIDs +// live in a new, additive, nullable parallel column: a JSON array of +// `(string | null)` entries, positionally aligned to `authors` (index i of +// `author_orcids` is the ORCID for `authors[i]`, or null if that author's +// ORCID is unknown). Existing rows get NULL (no backfill) — NULL means "no +// ORCID data known for this paper," distinct from an array of all-null +// entries ("we checked, none of these authors have an ORCID on file"). +// Same purely-additive shape as the v2 `summary` column. +export const SCHEMA_V5_AUTHOR_ORCIDS = ` +ALTER TABLE papers ADD COLUMN author_orcids TEXT; +` diff --git a/src/app/DetailsPanel.test.tsx b/src/app/DetailsPanel.test.tsx index 47f664e..33eb6d2 100644 --- a/src/app/DetailsPanel.test.tsx +++ b/src/app/DetailsPanel.test.tsx @@ -1,14 +1,76 @@ // @vitest-environment jsdom import '@testing-library/jest-dom/vitest' -import { render, screen } from '@testing-library/react' -import { beforeEach, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { DetailsPanel } from './DetailsPanel' beforeEach(() => Object.defineProperty(window, 'vellum', { configurable: true, value: { getPaper: vi.fn().mockResolvedValue({ title: 'Paper', authors: ['Author'], year: 2026, summary: 'Short summary.', sections: [{ title: 'Introduction' }], addedAt: 't' }) } })) +afterEach(() => { + cleanup() +}) + it('shows stored summary and metadata', async () => { render() expect(await screen.findByText('Short summary.')).toBeInTheDocument() expect(screen.getByText('Author')).toBeInTheDocument() expect(screen.getByText('Introduction')).toBeInTheDocument() }) + +it('renders an ORCID badge for authors with an ORCID and plain text for those without', async () => { + Object.defineProperty(window, 'vellum', { + configurable: true, + value: { + getPaper: vi.fn().mockResolvedValue({ + title: 'Paper', + authors: ['Jane Doe', 'John Smith'], + authorOrcids: ['0000-0002-1825-0097', null], + year: 2026, + addedAt: 't', + }), + }, + }) + render() + const link = await screen.findByRole('link', { name: 'ORCID profile for Jane Doe' }) + expect(link).toHaveAttribute('href', 'https://orcid.org/0000-0002-1825-0097') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + expect(screen.getByText('John Smith')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'ORCID profile for John Smith' })).not.toBeInTheDocument() +}) + +it('renders all authors as plain text when authorOrcids is absent (graceful absence)', async () => { + Object.defineProperty(window, 'vellum', { + configurable: true, + value: { + getPaper: vi.fn().mockResolvedValue({ + title: 'Paper', + authors: ['Jane Doe', 'John Smith'], + year: 2026, + addedAt: 't', + }), + }, + }) + render() + await screen.findByText('Jane Doe') + expect(screen.getByText('John Smith')).toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() +}) + +it('handles authorOrcids shorter than authors (defensive index access)', async () => { + Object.defineProperty(window, 'vellum', { + configurable: true, + value: { + getPaper: vi.fn().mockResolvedValue({ + title: 'Paper', + authors: ['Jane Doe', 'John Smith'], + authorOrcids: ['0000-0002-1825-0097'], + year: 2026, + addedAt: 't', + }), + }, + }) + render() + expect(await screen.findByRole('link', { name: 'ORCID profile for Jane Doe' })).toBeInTheDocument() + expect(screen.getByText('John Smith')).toBeInTheDocument() +}) diff --git a/src/app/DetailsPanel.tsx b/src/app/DetailsPanel.tsx index 07806d5..4aed740 100644 --- a/src/app/DetailsPanel.tsx +++ b/src/app/DetailsPanel.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react' import styles from './DetailsPanel.module.css' +import { OrcidBadge } from './OrcidBadge' interface PaperDetails { title: string authors: string[] + authorOrcids?: (string | null)[] year?: number venue?: string doi?: string @@ -23,7 +25,20 @@ export function DetailsPanel({ slug }: { slug?: string }): JSX.Element { return
{paper.summary ? <>

Summary

{paper.summary}

: null}

{paper.title}

- {paper.authors.length ?

{paper.authors.join(', ')}

: null} + {paper.authors.length ? ( +

+ {paper.authors.map((name, index) => { + const orcid = paper.authorOrcids?.[index] + return ( + + {index > 0 ? : null} + {name} + {orcid ? : null} + + ) + })} +

+ ) : null} {[paper.year, paper.venue, paper.arxivId, paper.doi].filter(Boolean).map((item) =>

{item}

)} {paper.sections?.length ? <>

Sections

    {paper.sections.map((section, index) =>
  • {sectionTitle(section)}
  • )}
: null}
diff --git a/src/app/OrcidBadge.module.css b/src/app/OrcidBadge.module.css new file mode 100644 index 0000000..e4f8bf7 --- /dev/null +++ b/src/app/OrcidBadge.module.css @@ -0,0 +1,14 @@ +.badge { + display: inline-block; + margin-left: 0.25em; + font-size: 0.75em; + color: #a6ce39; + text-decoration: none; + border: 1px solid currentColor; + border-radius: 999px; + padding: 0 0.4em; +} + +.badge:hover { + text-decoration: underline; +} diff --git a/src/app/OrcidBadge.test.tsx b/src/app/OrcidBadge.test.tsx new file mode 100644 index 0000000..b4b4c59 --- /dev/null +++ b/src/app/OrcidBadge.test.tsx @@ -0,0 +1,13 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest' +import { render, screen } from '@testing-library/react' +import { expect, it } from 'vitest' +import { OrcidBadge } from './OrcidBadge' + +it('renders a link to the ORCID profile with accessible label and new-tab attrs', () => { + render() + const link = screen.getByRole('link', { name: 'ORCID profile for Jane Doe' }) + expect(link).toHaveAttribute('href', 'https://orcid.org/0000-0002-1825-0097') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') +}) diff --git a/src/app/OrcidBadge.tsx b/src/app/OrcidBadge.tsx new file mode 100644 index 0000000..064bcac --- /dev/null +++ b/src/app/OrcidBadge.tsx @@ -0,0 +1,15 @@ +import styles from './OrcidBadge.module.css' + +export function OrcidBadge({ name, orcid }: { name: string; orcid: string }): JSX.Element { + return ( + + ORCID + + ) +}