diff --git a/desktop/electron/e2e/app.spec.ts b/desktop/electron/e2e/app.spec.ts index d4fdd527c..1d6ddbafe 100644 --- a/desktop/electron/e2e/app.spec.ts +++ b/desktop/electron/e2e/app.spec.ts @@ -572,6 +572,165 @@ test('read: synced Zotero files open from the default attachment location', asyn } }); +test('read: ratings persist, update in one click, and sort highest first', async () => { + await dismissConnectModal(); + const originalLibrary = await page.evaluate(() => { + const libraryKey = 'termipod.library.v1'; + const original = localStorage.getItem(libraryKey); + const base = { + type: 'article', + authors: ['TermiPod'], + tags: [], + collectionIds: [], + notes: '', + addedAt: Date.now(), + dirty: false, + attachments: [], + }; + localStorage.setItem(libraryKey, JSON.stringify({ + references: [ + { ...base, id: 'ref-rating-two', title: 'Rating Alpha', rating: 2 }, + { ...base, id: 'ref-rating-five', title: 'Rating Beta', rating: 5 }, + { ...base, id: 'ref-rating-none', title: 'Rating Gamma' }, + ], + collections: [], + })); + return original; + }); + + try { + await page.reload({ waitUntil: 'domcontentloaded' }); + await dismissConnectModal(); + await page.locator('[data-job="read"]').click(); + + const table = page.locator('.read-table'); + const scrollMetrics = await page.locator('.read-table-wrap').evaluate((wrapper) => { + const candidates = [wrapper, ...wrapper.querySelectorAll('div')]; + const scroller = candidates.find((candidate) => { + const overflowX = getComputedStyle(candidate).overflowX; + return candidate.scrollWidth > candidate.clientWidth + 1 && (overflowX === 'auto' || overflowX === 'scroll'); + }); + if (scroller === undefined) return null; + scroller.scrollLeft = 120; + return { clientWidth: scroller.clientWidth, scrollWidth: scroller.scrollWidth, scrollLeft: scroller.scrollLeft }; + }); + expect(scrollMetrics).not.toBeNull(); + expect(scrollMetrics!.scrollWidth).toBeGreaterThan(scrollMetrics!.clientWidth); + expect(scrollMetrics!.scrollLeft).toBeGreaterThan(0); + + const rows = table.locator('tbody tr'); + await table.getByRole('button', { name: 'Sort by Rating', exact: true }).click(); + await expect(rows.nth(0)).toContainText('Rating Beta'); + await expect(rows.nth(1)).toContainText('Rating Alpha'); + await expect(rows.nth(2)).toContainText('Rating Gamma'); + + const unrated = rows.filter({ hasText: 'Rating Gamma' }); + await unrated.getByRole('button', { name: 'Rate 4 out of 5', exact: true }).click(); + await expect(rows.nth(0)).toContainText('Rating Beta'); + await expect(rows.nth(1)).toContainText('Rating Gamma'); + await expect(rows.nth(2)).toContainText('Rating Alpha'); + await expect(rows.nth(1).getByRole('button', { name: 'Clear 4-star rating', exact: true })).toBeVisible(); + + const persisted = await page.evaluate(() => { + const library = JSON.parse(localStorage.getItem('termipod.library.v1') ?? '{}') as { + references?: { id: string; rating?: number }[]; + }; + return library.references?.find((reference) => reference.id === 'ref-rating-none')?.rating; + }); + expect(persisted).toBe(4); + + await rows.filter({ hasText: 'Rating Gamma' }).click(); + const metadataRow = page.locator('.ref-rating-type-row'); + await expect(metadataRow).toBeVisible(); + const metadataMetrics = await metadataRow.evaluate((node) => { + const rating = node.querySelector('.ref-rating-field')!.getBoundingClientRect(); + const type = node.querySelector('.ref-type-field')!.getBoundingClientRect(); + return { + ratingTop: Math.round(rating.top), + ratingRight: Math.round(rating.right), + typeTop: Math.round(type.top), + typeLeft: Math.round(type.left), + }; + }); + expect(metadataMetrics.typeTop).toBe(metadataMetrics.ratingTop); + expect(metadataMetrics.typeLeft).toBeGreaterThan(metadataMetrics.ratingRight); + } finally { + await page.evaluate((original) => { + if (original === null) localStorage.removeItem('termipod.library.v1'); + else localStorage.setItem('termipod.library.v1', original); + }, originalLibrary); + await page.reload({ waitUntil: 'domcontentloaded' }); + } +}); + +test('read: Cite keeps Scholar and OpenAlex provenance separate', async () => { + await dismissConnectModal(); + const originalLibrary = await page.evaluate(() => { + const libraryKey = 'termipod.library.v1'; + const original = localStorage.getItem(libraryKey); + localStorage.setItem(libraryKey, JSON.stringify({ + references: [{ + id: 'ref-citation-provenance', + type: 'article', + title: 'Citation provenance fixture', + authors: ['TermiPod'], + year: 2025, + citationCount: 120, + citedByCount: 95, + referenceCount: 14, + source: 'google-scholar', + externalId: 'scholar-fixture', + openAlexId: 'https://openalex.org/W123', + scholar: { + resultId: 'scholar-fixture', + citedByCount: 120, + citesId: 'fixture-cites', + citedByUrl: 'https://scholar.google.com/scholar?cites=fixture-cites', + versionsCount: 3, + versionsUrl: 'https://scholar.google.com/scholar?cluster=fixture', + citations: [{ id: 'scholar-citing-1', title: 'Scholar citing work', year: 2026, url: 'https://example.test/scholar' }], + citationsPerYear: [{ year: 2025, citations: 20 }, { year: 2026, citations: 40 }], + citationTotalResults: 120, + citationsLoadedAt: Date.now(), + citationsHasMore: true, + }, + citations: [{ id: 'https://openalex.org/W456', title: 'OpenAlex citing work', year: 2026 }], + tags: [], + collectionIds: [], + notes: '', + addedAt: Date.now(), + dirty: false, + attachments: [], + }], + collections: [], + })); + return original; + }); + + try { + await page.reload({ waitUntil: 'domcontentloaded' }); + await dismissConnectModal(); + await page.locator('[data-job="read"]').click(); + await page.locator('.read-table tbody tr').filter({ hasText: 'Citation provenance fixture' }).click(); + await page.getByRole('tab', { name: 'Cite', exact: true }).click(); + + const cite = page.locator('.ref-cite'); + await expect(cite.locator('.ref-metric').filter({ hasText: 'Google Scholar' })).toContainText('120'); + await expect(cite.locator('.ref-metric').filter({ hasText: 'OpenAlex' })).toContainText('95'); + await expect(cite.locator('.ref-provider-note')).toContainText('different sources'); + await expect(cite.getByText('Scholar citing work', { exact: true })).toBeVisible(); + await expect(cite.getByText('OpenAlex citing work', { exact: true })).toBeVisible(); + await expect(cite.getByRole('button', { name: 'Load more', exact: true })).toBeVisible(); + await expect(cite.locator('.ref-scholar-year-bar')).toHaveCount(2); + } finally { + await page.evaluate((original) => { + if (original === null) localStorage.removeItem('termipod.library.v1'); + else localStorage.setItem('termipod.library.v1', original); + }, originalLibrary); + await page.reload({ waitUntil: 'domcontentloaded' }); + } +}); + test('read: PDF frequent actions stay visible and the outline folds by level', async () => { await dismissConnectModal(); const fixture = await page.evaluate(async ({ bytes }) => { @@ -942,15 +1101,19 @@ test('web tab: a guest loads, isolates the bridge, and cannot reach ap }); const title = wv.getTitle(); const hasBridge = await wv.executeJavaScript('typeof window.__ELECTRON_BRIDGE__'); + const userAgent = await wv.executeJavaScript('navigator.userAgent'); const appFetch = await wv.executeJavaScript( "fetch('app://termipod/index.html').then(r => 'reached:' + r.status).catch(() => 'blocked')", ); wv.remove(); - return { title, hasBridge, appFetch }; + return { title, hasBridge, userAgent, appFetch }; }, guestUrl); expect(result.title).toBe('E2E Webview OK'); // No preload → the bridge (and the whole command allowlist) never exists here. expect(result.hasBridge).toBe('undefined'); + // Do not impersonate stock Chrome. A rewritten UA misrepresents the client + // and is itself a bot-detection signal; use Electron's truthful default. + expect(result.userAgent).toContain('Electron/'); // The app:// scheme handler is installed on defaultSession only — the guest // partition can't resolve it. expect(result.appFetch).toBe('blocked'); @@ -1126,6 +1289,22 @@ test('workbench: primary surface headers share one grid and action height', asyn } }); +test('macOS: empty terminal session-header space remains a window drag region', async () => { + const os = await page.evaluate(() => window.__ELECTRON_BRIDGE__!.invoke('platform_os')); + if (os !== 'macos') return; + + await dismissConnectModal(); + await page.locator('[data-job="terminal"]').click(); + + const actions = page.locator('.term-panel.surface .term-surface-actions'); + const emptySpace = actions.locator(':scope > .spacer'); + const addButton = actions.locator('.term-add-btn'); + await expect(emptySpace).toBeVisible(); + await expect(addButton).toBeVisible(); + await expect.poll(() => emptySpace.evaluate((node) => getComputedStyle(node).getPropertyValue('-webkit-app-region'))).toBe('drag'); + await expect.poll(() => addButton.evaluate((node) => getComputedStyle(node).getPropertyValue('-webkit-app-region'))).toBe('no-drag'); +}); + test('workbench: pane toggles stay pinned to the surface header edges', async () => { await dismissConnectModal(); diff --git a/desktop/electron/src/ipc/discovery.test.ts b/desktop/electron/src/ipc/discovery.test.ts new file mode 100644 index 000000000..72a609f92 --- /dev/null +++ b/desktop/electron/src/ipc/discovery.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { discoveryHandlers } from './discovery.ts'; + +test('SerpAPI transport fixes the Scholar endpoint and returns structured JSON', async () => { + const originalFetch = globalThis.fetch; + let requested = ''; + globalThis.fetch = (async (input) => { + requested = String(input); + return new Response(JSON.stringify({ organic_results: [{ title: 'Paper' }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + try { + const out = await discoveryHandlers.serpapi_search( + { query: 'graph neural networks', apiKey: 'test-secret', limit: 25, proxy: null }, + {} as never, + ); + assert.deepEqual(out, { organic_results: [{ title: 'Paper' }] }); + const url = new URL(requested); + assert.equal(url.origin + url.pathname, 'https://serpapi.com/search.json'); + assert.equal(url.searchParams.get('engine'), 'google_scholar'); + assert.equal(url.searchParams.get('q'), 'graph neural networks'); + assert.equal(url.searchParams.get('api_key'), 'test-secret'); + assert.equal(url.searchParams.get('num'), '20'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('SerpAPI transport does not echo a rejected credential in its error', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response('{"error":"Invalid API key: top-secret"}', { + status: 401, + headers: { 'content-type': 'application/json' }, + })) as typeof fetch; + try { + await assert.rejects( + async () => + discoveryHandlers.serpapi_search( + { query: 'paper', apiKey: 'top-secret', limit: 10, proxy: null }, + {} as never, + ), + (error: unknown) => error instanceof Error && error.message === 'serpapi_search: HTTP 401', + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('SerpAPI citations transport sends a fixed cites query with pagination', async () => { + const originalFetch = globalThis.fetch; + let requested = ''; + globalThis.fetch = (async (input) => { + requested = String(input); + return new Response(JSON.stringify({ organic_results: [{ title: 'Citing paper' }] }), { status: 200 }); + }) as typeof fetch; + try { + await discoveryHandlers.serpapi_citations( + { citesId: 'abc_123', apiKey: 'test-secret', limit: 20, start: 40, proxy: null }, + {} as never, + ); + const url = new URL(requested); + assert.equal(url.origin + url.pathname, 'https://serpapi.com/search.json'); + assert.equal(url.searchParams.get('cites'), 'abc_123'); + assert.equal(url.searchParams.get('start'), '40'); + assert.equal(url.searchParams.get('api_key'), 'test-secret'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('SerpAPI citations transport rejects malformed ids before network access', async () => { + await assert.rejects( + async () => + discoveryHandlers.serpapi_citations( + { citesId: 'https://evil.test/', apiKey: 'secret', limit: 20, start: 0, proxy: null }, + {} as never, + ), + /valid cites id is required/, + ); +}); diff --git a/desktop/electron/src/ipc/discovery.ts b/desktop/electron/src/ipc/discovery.ts new file mode 100644 index 000000000..ebb7ff2c3 --- /dev/null +++ b/desktop/electron/src/ipc/discovery.ts @@ -0,0 +1,63 @@ +/// Main-process transport for keyed discovery providers. SerpAPI deliberately +/// rejects browser-origin requests (CORS), so the renderer cannot call it like +/// the public OpenAlex/Crossref APIs. Keep the endpoint fixed here, enforce a +/// response cap/timeout, and use the app's proxy-aware Node transport. +import { serpApiCitationsUrl, serpApiSearchUrl } from '../../../src/discovery/serpApiCore.ts'; +import type { Handler } from './dispatch'; +import { proxyFetch } from './net.ts'; + +const RESPONSE_CAP = 5 * 1024 * 1024; +const TIMEOUT_MS = 45_000; + +async function fetchSerpApiJson(url: string, proxy: string | null, operation: string): Promise { + const res = await proxyFetch( + url, + { + method: 'GET', + headers: { Accept: 'application/json' }, + redirect: 'follow', + signal: AbortSignal.timeout(TIMEOUT_MS), + }, + proxy, + ); + const declared = Number(res.headers.get('content-length') ?? '0'); + if (Number.isFinite(declared) && declared > RESPONSE_CAP) { + await res.body?.cancel().catch(() => undefined); + throw new Error(`${operation}: response exceeds 5 MB`); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + if (bytes.byteLength > RESPONSE_CAP) throw new Error(`${operation}: response exceeds 5 MB`); + if (!res.ok) throw new Error(`${operation}: HTTP ${res.status}`); + try { + return JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch { + throw new Error(`${operation}: invalid JSON response`); + } +} + +export const discoveryHandlers: Record = { + serpapi_search: async (args): Promise => { + const query = typeof args.query === 'string' ? args.query.trim() : ''; + const apiKey = typeof args.apiKey === 'string' ? args.apiKey.trim() : ''; + const limit = typeof args.limit === 'number' ? args.limit : 20; + const proxy = typeof args.proxy === 'string' && args.proxy !== '' ? args.proxy : null; + if (query === '') throw new Error('serpapi_search: query is required'); + if (apiKey === '') throw new Error('serpapi_search: API key is required'); + + return fetchSerpApiJson(serpApiSearchUrl(query, limit, apiKey), proxy, 'serpapi_search'); + }, + serpapi_citations: async (args): Promise => { + const citesId = typeof args.citesId === 'string' ? args.citesId.trim() : ''; + const apiKey = typeof args.apiKey === 'string' ? args.apiKey.trim() : ''; + const limit = typeof args.limit === 'number' ? args.limit : 20; + const start = typeof args.start === 'number' ? args.start : 0; + const proxy = typeof args.proxy === 'string' && args.proxy !== '' ? args.proxy : null; + if (citesId === '' || !/^[\w-]+$/.test(citesId)) throw new Error('serpapi_citations: valid cites id is required'); + if (apiKey === '') throw new Error('serpapi_citations: API key is required'); + return fetchSerpApiJson( + serpApiCitationsUrl(citesId, limit, start, apiKey), + proxy, + 'serpapi_citations', + ); + }, +}; diff --git a/desktop/electron/src/ipc/dispatch.ts b/desktop/electron/src/ipc/dispatch.ts index 780879aeb..f04b89e5a 100644 --- a/desktop/electron/src/ipc/dispatch.ts +++ b/desktop/electron/src/ipc/dispatch.ts @@ -20,6 +20,7 @@ import { checkpointHandlers } from './checkpointfile'; import { localfsHandlers } from './localfs'; import { workspaceHandlers } from './workspace'; import { forgeHandlers } from './forge'; +import { discoveryHandlers } from './discovery'; import { gitHandlers } from './git'; import { storageHandlers } from './storage'; import { keychainHandlers } from './keychain'; @@ -64,6 +65,7 @@ const handlers: Record = { ...localfsHandlers, ...workspaceHandlers, ...forgeHandlers, + ...discoveryHandlers, ...gitHandlers, ...storageHandlers, ...keychainHandlers, diff --git a/desktop/electron/src/webtab.ts b/desktop/electron/src/webtab.ts index 1ce485215..d0b2dabe1 100644 --- a/desktop/electron/src/webtab.ts +++ b/desktop/electron/src/webtab.ts @@ -51,6 +51,9 @@ function webtabSession(): Electron.Session { return session.fromPartition(WEBTAB_PARTITION); } +let appliedProxy: string | null | undefined; +let proxyApply: Promise = Promise.resolve(); + /// The allowlist policy for a guest webContents, identified by its session /// (`session.fromPartition` is memoized per partition string, so identity /// comparison works). `null` = not an allowlisted guest partition — the @@ -64,22 +67,21 @@ export function policyForGuest(wc: Electron.WebContents): PartitionPolicy | null return null; } -/// The stock-Chrome user agent Electron derives from — with the `Electron/x.y` -/// and app-name tokens stripped. Several sites (Scholar, Cloudflare) degrade or -/// block non-Chrome UAs; the remaining string is a plain Chrome UA. -function stockChromeUA(): string { - return session.defaultSession - .getUserAgent() - .replace(/ Electron\/\S+/i, '') - .replace(new RegExp(` ${app.getName()}\\/\\S+`, 'i'), '') - .trim(); -} - /// Apply the app's proxy to the webtab session — same semantics as the updater: /// an explicit proxy when configured, else Chromium's own system resolution. async function applyProxy(proxy: string | null): Promise { - const ses = webtabSession(); - await ses.setProxy(proxy === null || proxy === '' ? { mode: 'system' } : { proxyRules: proxy }); + // Serialize callers (two browser panes can mount together) and skip identical + // settings. Closing pooled sockets is necessary when the route changes, but + // doing it on every tab switch would needlessly interrupt other web guests. + const task = proxyApply.catch(() => undefined).then(async () => { + if (appliedProxy === proxy) return; + const ses = webtabSession(); + await ses.setProxy(proxy === null || proxy === '' ? { mode: 'system' } : { proxyRules: proxy }); + await ses.closeAllConnections(); + appliedProxy = proxy; + }); + proxyApply = task; + await task; } // ── Guest context menu ─────────────────────────────────────────────────────── @@ -173,10 +175,9 @@ function popupGuestContextMenu(wc: Electron.WebContents, params: Electron.Contex /// catches the main window's own webContents and its `will-attach-webview`). export function setupWebtab(): void { const ses = webtabSession(); - ses.setUserAgent(stockChromeUA()); - // Default to system-proxy resolution; the renderer pushes an explicit override - // via `webtab_set_proxy` when Settings → Network configures one. - void applyProxy(null); + // Do not start an unawaited default setProxy here. BrowserView deliberately + // keeps its guest unmounted while webtab_set_proxy applies either the selected + // override or Electron's system mode, making the first navigation deterministic. // Deny every permission a preview browser has no business granting; allow only // fullscreen (video). Applies to the whole partition. ses.setPermissionRequestHandler((_wc, permission, cb) => cb(permission === 'fullscreen')); diff --git a/desktop/src/discovery/index.ts b/desktop/src/discovery/index.ts index 95cb167c3..29fcb0e2b 100644 --- a/desktop/src/discovery/index.ts +++ b/desktop/src/discovery/index.ts @@ -3,13 +3,23 @@ import { CORE_KEY, searchCore } from './core'; import { searchCrossref } from './crossref'; import { searchOpenAlex } from './openAlex'; import { searchPubmed } from './pubmed'; +import { loadGoogleScholarCitations, searchGoogleScholar } from './serpApi'; import { S2_KEY, searchSemanticScholar } from './semanticScholar'; import type { SearchSource } from './types'; -export type { DiscoveryPaper, SearchSource } from './types'; +export type { + DiscoveryPaper, + DiscoverySourceId, + ScholarCitationPage, + ScholarCitationYear, + ScholarResultMetadata, + SearchSource, +} from './types'; export { lsGet, lsSet } from './http'; export { enrichWithUnpaywall } from './unpaywall'; export { scrapeMetadata, detectIdentifier, type ScrapePatch, type ScrapeSeed } from './scrape'; +export { isLikelySameWork } from './scrapeMatch'; +export { loadGoogleScholarCitations, searchGoogleScholar }; /// The discovery source registry — the single source of truth the Read/Discover /// picker renders. OpenAlex is first (free, keyless, most generous → the default); @@ -24,6 +34,14 @@ export const SOURCES: SearchSource[] = [ keyUrl: 'https://www.semanticscholar.org/product/api#api-key', search: searchSemanticScholar, }, + { + id: 'google-scholar', + label: 'Google Scholar', + note: 'via SerpAPI · broad citation coverage', + keyManagedInVault: true, + keyUrl: 'https://serpapi.com/manage-api-key', + search: searchGoogleScholar, + }, { id: 'crossref', label: 'Crossref', note: 'DOI metadata · 150M+', search: searchCrossref }, { id: 'arxiv', label: 'arXiv', note: 'preprints · CS/physics/math', search: searchArxiv }, { id: 'pubmed', label: 'PubMed', note: 'biomedical / life sci', search: searchPubmed }, diff --git a/desktop/src/discovery/scrape.ts b/desktop/src/discovery/scrape.ts index 7d2030e7d..611bb812e 100644 --- a/desktop/src/discovery/scrape.ts +++ b/desktop/src/discovery/scrape.ts @@ -1,5 +1,6 @@ import { CONTACT, getJson } from './http'; import type { JournalMetrics, ResourceLink, WorkLink } from '../state/library'; +import { isLikelySameWork } from './scrapeMatch'; /// The library **scraper** — given whatever identifiers an item already has /// (DOI / arXiv id / OpenAlex id / title), it pulls the rich metadata the plain @@ -19,6 +20,8 @@ export interface ScrapeSeed { arxivId?: string; openAlexId?: string; title?: string; + year?: number; + authors?: string[]; url?: string; abstract?: string; } @@ -46,6 +49,7 @@ export interface ScrapePatch { detailsAdd?: Record; enrichedAt: number; enrichSource: string; + openAlexId?: string; } export interface ScrapeResult { @@ -184,7 +188,22 @@ async function resolveWork(seed: ScrapeSeed): Promise | `https://api.openalex.org/works?search=${encodeURIComponent(title)}&per-page=1&${mail}`, ); const results = j !== null && Array.isArray(j.results) ? (j.results as unknown[]) : []; - if (results.length > 0) return asObj(results[0]); + if (results.length > 0) { + const candidate = asObj(results[0]); + const authorships = Array.isArray(candidate.authorships) ? candidate.authorships : []; + const authors = authorships + .map((entry) => asStr(asObj(asObj(entry).author).display_name)) + .filter((name): name is string => name !== undefined); + if ( + isLikelySameWork(seed, { + title: asStr(candidate.title) ?? asStr(candidate.display_name), + year: asNum(candidate.publication_year), + authors, + }) + ) { + return candidate; + } + } } return null; } @@ -306,6 +325,7 @@ export async function scrapeMetadata(seed: ScrapeSeed): Promise { detailsAdd: Object.keys(detailsAdd).length > 0 ? detailsAdd : undefined, enrichedAt: Date.now(), enrichSource: 'OpenAlex', + openAlexId: asStr(work.id), }; return { patch, found, identifier: asStr(work.id) }; } diff --git a/desktop/src/discovery/scrapeMatch.test.ts b/desktop/src/discovery/scrapeMatch.test.ts new file mode 100644 index 000000000..1c3525b78 --- /dev/null +++ b/desktop/src/discovery/scrapeMatch.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { isLikelySameWork } from './scrapeMatch.ts'; + +test('accepts punctuation and small subtitle differences for the same work', () => { + assert.equal( + isLikelySameWork( + { title: 'Attention Is All You Need', year: 2017, authors: ['A. Vaswani'] }, + { title: 'Attention is all you need.', year: 2017, authors: ['Ashish Vaswani'] }, + ), + true, + ); +}); + +test('rejects OpenAlex first-hit results with a different title, year, or first author', () => { + assert.equal( + isLikelySameWork({ title: 'Graph learning for molecules', year: 2024 }, { title: 'Graph learning for traffic', year: 2024 }), + false, + ); + assert.equal( + isLikelySameWork({ title: 'A compact research title', year: 2024 }, { title: 'A compact research title', year: 2018 }), + false, + ); + assert.equal( + isLikelySameWork( + { title: 'A compact research title', authors: ['Alice Smith'] }, + { title: 'A compact research title', authors: ['Alice Jones'] }, + ), + false, + ); +}); diff --git a/desktop/src/discovery/scrapeMatch.ts b/desktop/src/discovery/scrapeMatch.ts new file mode 100644 index 000000000..32dca6124 --- /dev/null +++ b/desktop/src/discovery/scrapeMatch.ts @@ -0,0 +1,45 @@ +export interface WorkIdentity { + title?: string; + year?: number; + authors?: string[]; +} + +function words(value: string | undefined): string[] { + return (value ?? '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/&/g, ' and ') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .split(/\s+/) + .filter(Boolean); +} + +function familyName(author: string | undefined): string | undefined { + const tokens = words(author); + return tokens.at(-1); +} + +/// OpenAlex title search is fuzzy and returns a best guess even when it is the +/// wrong paper. Identifier lookups are trusted; this guard is for the title-only +/// fallback and intentionally prefers "not found" over attaching another work's +/// citation graph to the selected library item. +export function isLikelySameWork(seed: WorkIdentity, candidate: WorkIdentity): boolean { + const wanted = words(seed.title); + const found = words(candidate.title); + if (wanted.length === 0 || found.length === 0) return false; + if (seed.year !== undefined && candidate.year !== undefined && Math.abs(seed.year - candidate.year) > 1) return false; + + const wantedSet = new Set(wanted); + const foundSet = new Set(found); + let overlap = 0; + for (const token of wantedSet) if (foundSet.has(token)) overlap += 1; + const coverage = overlap / Math.max(wantedSet.size, foundSet.size); + const titleMatches = wanted.join(' ') === found.join(' ') || (Math.max(wanted.length, found.length) >= 4 && coverage >= 0.85); + if (!titleMatches) return false; + + const wantedAuthor = familyName(seed.authors?.[0]); + const foundAuthor = familyName(candidate.authors?.[0]); + return wantedAuthor === undefined || foundAuthor === undefined || wantedAuthor === foundAuthor; +} diff --git a/desktop/src/discovery/serpApi.ts b/desktop/src/discovery/serpApi.ts new file mode 100644 index 000000000..ad175cebe --- /dev/null +++ b/desktop/src/discovery/serpApi.ts @@ -0,0 +1,52 @@ +import { getSerpApiKey } from '../state/discoverySecrets'; +import { invoke } from '../bridge'; +import { isShell } from '../platform'; +import { proxyForConnection } from '../state/proxy'; +import { normalizeSerpApiCitationPage, normalizeSerpApiPaper } from './serpApiCore'; +import type { DiscoveryPaper, ScholarCitationPage } from './types'; + +export { normalizeSerpApiCitationPage, normalizeSerpApiPaper, serpApiCitationsUrl, serpApiSearchUrl } from './serpApiCore'; + +/// Google Scholar results through SerpAPI. The credential is read on demand +/// from Vault → TermiPod, so replacing it takes effect on the next search. +export async function searchGoogleScholar(query: string, limit: number): Promise { + const key = await getSerpApiKey(); + if (key === '') throw new Error('needs-key'); + // SerpAPI intentionally does not allow browser-origin requests. Route through + // the native shell so the key never appears in a renderer fetch URL and the + // app's Discovery proxy setting is applied. + if (!isShell()) throw new Error('serpapi-shell-required'); + const json = await invoke>('serpapi_search', { + query, + limit, + apiKey: key, + proxy: proxyForConnection('discovery') ?? null, + }); + if (typeof json.error === 'string' && json.error !== '') throw new Error('serpapi-error'); + const results = json.organic_results; + if (!Array.isArray(results)) return []; + return results.map(normalizeSerpApiPaper).filter((paper): paper is DiscoveryPaper => paper !== null); +} + +/// Fetch one page of papers that cite a Scholar result. This intentionally +/// requires an explicit Cite-tab action because every page consumes one SerpAPI +/// query. The key remains in the native request and is never persisted in the +/// reference record or exposed in a renderer URL. +export async function loadGoogleScholarCitations( + citesId: string, + start = 0, + limit = 20, +): Promise { + const key = await getSerpApiKey(); + if (key === '') throw new Error('needs-key'); + if (!isShell()) throw new Error('serpapi-shell-required'); + const json = await invoke>('serpapi_citations', { + citesId, + start, + limit, + apiKey: key, + proxy: proxyForConnection('discovery') ?? null, + }); + if (typeof json.error === 'string' && json.error !== '') throw new Error('serpapi-error'); + return normalizeSerpApiCitationPage(json, limit, start); +} diff --git a/desktop/src/discovery/serpApiCore.test.ts b/desktop/src/discovery/serpApiCore.test.ts new file mode 100644 index 000000000..4bb3ff6f9 --- /dev/null +++ b/desktop/src/discovery/serpApiCore.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + normalizeSerpApiCitationPage, + normalizeSerpApiPaper, + serpApiCitationsUrl, + serpApiSearchUrl, +} from './serpApiCore.ts'; + +test('maps Google Scholar organic metadata into a discovery paper', () => { + const paper = normalizeSerpApiPaper({ + title: 'Attention Is All You Need', + result_id: 'scholar-result-id', + link: 'https://doi.org/10.5555/3295222.3295349', + snippet: 'A transformer architecture.', + publication_info: { + summary: 'A Vaswani, N Shazeer - Advances in neural information processing systems, 2017 - proceedings.neurips.cc', + authors: [{ name: 'A Vaswani' }, { name: 'N Shazeer' }], + }, + inline_links: { + cited_by: { total: 123456, cites_id: 'abc123', link: 'https://scholar.google.com/citations' }, + related_pages_link: 'https://scholar.google.com/related', + versions: { total: 9, link: 'https://scholar.google.com/versions' }, + cached_page_link: 'https://scholar.googleusercontent.com/cache', + }, + resources: [{ file_format: 'PDF', link: 'https://example.test/paper.pdf' }], + }); + + assert.deepEqual(paper, { + paperId: 'scholar-result-id', + title: 'Attention Is All You Need', + authors: ['A Vaswani', 'N Shazeer'], + year: 2017, + venue: 'Advances in neural information processing systems', + abstract: 'A transformer architecture.', + citationCount: 123456, + doi: '10.5555/3295222.3295349', + pdfUrl: 'https://example.test/paper.pdf', + url: 'https://doi.org/10.5555/3295222.3295349', + source: 'google-scholar', + scholar: { + resultId: 'scholar-result-id', + citedByCount: 123456, + citesId: 'abc123', + citedByUrl: 'https://scholar.google.com/citations', + relatedUrl: 'https://scholar.google.com/related', + versionsCount: 9, + versionsUrl: 'https://scholar.google.com/versions', + cachedUrl: 'https://scholar.googleusercontent.com/cache', + }, + }); +}); + +test('rejects title-less rows and tolerates sparse Scholar results', () => { + assert.equal(normalizeSerpApiPaper({ snippet: 'missing title' }), null); + assert.deepEqual(normalizeSerpApiPaper({ title: 'Sparse result' }), { + paperId: 'Sparse result', + title: 'Sparse result', + authors: [], + year: undefined, + venue: undefined, + abstract: undefined, + citationCount: undefined, + doi: undefined, + pdfUrl: undefined, + url: undefined, + source: 'google-scholar', + scholar: { + resultId: undefined, + citedByCount: undefined, + citesId: undefined, + citedByUrl: undefined, + relatedUrl: undefined, + versionsCount: undefined, + versionsUrl: undefined, + cachedUrl: undefined, + }, + }); +}); + +test('normalizes a citing-paper page and accounts for pagination offset', () => { + const page = normalizeSerpApiCitationPage( + { + search_information: { total_results: 41 }, + citations_per_year: [{ year: 2024, citations: 12 }, { year: 'bad', citations: 2 }], + organic_results: [{ title: 'A citing work', result_id: 'cite-1' }], + }, + 20, + 40, + ); + assert.equal(page.papers[0]?.title, 'A citing work'); + assert.deepEqual(page.citationsPerYear, [{ year: 2024, citations: 12 }]); + assert.equal(page.totalResults, 41); + assert.equal(page.hasMore, false); +}); + +test('falls back to the Scholar summary when structured authors are absent', () => { + const paper = normalizeSerpApiPaper({ + title: 'Summary-only authors', + publication_info: { summary: 'A Author, B Researcher - Journal of Tests, 2024 - example.test' }, + }); + assert.deepEqual(paper?.authors, ['A Author', 'B Researcher']); + assert.equal(paper?.year, 2024); + assert.equal(paper?.venue, 'Journal of Tests'); +}); + +test('builds a Google Scholar request, encodes the key, and caps a page at 20', () => { + const url = new URL(serpApiSearchUrl('graph neural networks', 25, 'secret +/=')); + assert.equal(url.origin + url.pathname, 'https://serpapi.com/search.json'); + assert.equal(url.searchParams.get('engine'), 'google_scholar'); + assert.equal(url.searchParams.get('q'), 'graph neural networks'); + assert.equal(url.searchParams.get('api_key'), 'secret +/='); + assert.equal(url.searchParams.get('num'), '20'); +}); + +test('builds a keyed Scholar citations request without accepting an arbitrary URL', () => { + const url = new URL(serpApiCitationsUrl('cites-id_1', 30, 20, 'secret')); + assert.equal(url.origin + url.pathname, 'https://serpapi.com/search.json'); + assert.equal(url.searchParams.get('engine'), 'google_scholar'); + assert.equal(url.searchParams.get('cites'), 'cites-id_1'); + assert.equal(url.searchParams.get('start'), '20'); + assert.equal(url.searchParams.get('num'), '20'); + assert.equal(url.searchParams.get('api_key'), 'secret'); +}); diff --git a/desktop/src/discovery/serpApiCore.ts b/desktop/src/discovery/serpApiCore.ts new file mode 100644 index 000000000..d2909e810 --- /dev/null +++ b/desktop/src/discovery/serpApiCore.ts @@ -0,0 +1,143 @@ +import type { DiscoveryPaper, ScholarCitationPage, ScholarCitationYear } from './types.ts'; + +const ENDPOINT = 'https://serpapi.com/search.json'; + +function object(raw: unknown): Record | null { + return raw !== null && typeof raw === 'object' ? (raw as Record) : null; +} + +function text(raw: unknown): string | undefined { + return typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : undefined; +} + +function publicationYear(summary: string | undefined): number | undefined { + if (summary === undefined) return undefined; + const years = summary.match(/\b(?:18|19|20)\d{2}\b/g); + if (years === null) return undefined; + const year = Number(years.at(-1)); + return Number.isFinite(year) ? year : undefined; +} + +function publicationVenue(summary: string | undefined): string | undefined { + if (summary === undefined) return undefined; + // Scholar summaries normally read "authors - venue, year - publisher". + const middle = summary.split(' - ')[1]?.trim(); + if (middle === undefined || middle === '') return undefined; + const venue = middle.replace(/,?\s*\b(?:18|19|20)\d{2}\b.*$/, '').trim(); + return venue !== '' ? venue : undefined; +} + +function publicationAuthors(summary: string | undefined): string[] { + if (summary === undefined) return []; + const authorText = summary.split(' - ')[0]?.trim() ?? ''; + if (authorText === '') return []; + return authorText.split(',').map((name) => name.trim()).filter((name) => name !== ''); +} + +function doiFromUrl(url: string | undefined): string | undefined { + if (url === undefined) return undefined; + const m = url.match(/(?:doi\.org\/|\/doi\/(?:abs\/|full\/)?)(10\.\d{4,9}\/[\w.()/:;-]+)/i); + return m?.[1]?.replace(/[?#].*$/, ''); +} + +/// Normalize one SerpAPI Google Scholar organic result. Kept in this pure module +/// so the response contract can be pinned without spending API quota in tests. +export function normalizeSerpApiPaper(raw: unknown): DiscoveryPaper | null { + const row = object(raw); + if (row === null) return null; + const title = text(row.title); + if (title === undefined) return null; + const publication = object(row.publication_info); + const summary = text(publication?.summary); + const listedAuthors = Array.isArray(publication?.authors) + ? publication.authors + .map((author) => text(object(author)?.name)) + .filter((name): name is string => name !== undefined) + : []; + const authors = listedAuthors.length > 0 ? listedAuthors : publicationAuthors(summary); + const link = text(row.link); + const resources = Array.isArray(row.resources) ? row.resources : []; + const pdf = resources + .map(object) + .find((resource) => resource !== null && text(resource.file_format)?.toUpperCase() === 'PDF'); + const inlineLinks = object(row.inline_links); + const citedBy = object(inlineLinks?.cited_by ?? row.cited_by); + const versions = object(inlineLinks?.versions); + const resultId = text(row.result_id); + + return { + paperId: resultId ?? link ?? title, + title, + authors, + year: publicationYear(summary), + venue: publicationVenue(summary), + abstract: text(row.snippet), + citationCount: typeof citedBy?.total === 'number' ? citedBy.total : undefined, + doi: doiFromUrl(link), + pdfUrl: text(pdf?.link) ?? (link?.toLowerCase().endsWith('.pdf') === true ? link : undefined), + url: link, + source: 'google-scholar', + scholar: { + resultId, + citedByCount: typeof citedBy?.total === 'number' ? citedBy.total : undefined, + citesId: text(citedBy?.cites_id), + citedByUrl: text(citedBy?.link), + relatedUrl: text(inlineLinks?.related_pages_link), + versionsCount: typeof versions?.total === 'number' ? versions.total : undefined, + versionsUrl: text(versions?.link), + cachedUrl: text(inlineLinks?.cached_page_link), + }, + }; +} + +/// Normalize a `cites=` Scholar response. This is deliberately separate +/// from initial search normalization: loading it costs another SerpAPI query and +/// is therefore invoked only when the user asks from the Cite tab. +export function normalizeSerpApiCitationPage(raw: unknown, requestedLimit: number, start = 0): ScholarCitationPage { + const root = object(raw) ?? {}; + const papers = Array.isArray(root.organic_results) + ? root.organic_results.map(normalizeSerpApiPaper).filter((paper): paper is DiscoveryPaper => paper !== null) + : []; + const citationsPerYear: ScholarCitationYear[] = Array.isArray(root.citations_per_year) + ? root.citations_per_year + .map((entry) => { + const row = object(entry); + const year = row?.year; + const citations = row?.citations; + return typeof year === 'number' && typeof citations === 'number' ? { year, citations } : null; + }) + .filter((entry): entry is ScholarCitationYear => entry !== null) + : []; + const total = object(root.search_information)?.total_results; + const totalResults = typeof total === 'number' ? total : undefined; + const pageSize = Math.max(1, Math.min(20, Math.trunc(requestedLimit))); + return { + papers, + citationsPerYear, + totalResults, + hasMore: totalResults !== undefined ? Math.max(0, Math.trunc(start)) + papers.length < totalResults : papers.length === pageSize, + }; +} + +export function serpApiSearchUrl(query: string, limit: number, apiKey: string): string { + const params = new URLSearchParams({ + engine: 'google_scholar', + q: query, + api_key: apiKey, + hl: 'en', + num: String(Math.max(1, Math.min(20, Math.trunc(limit)))), + }); + return `${ENDPOINT}?${params.toString()}`; +} + +export function serpApiCitationsUrl(citesId: string, limit: number, start: number, apiKey: string): string { + const params = new URLSearchParams({ + engine: 'google_scholar', + cites: citesId, + api_key: apiKey, + hl: 'en', + num: String(Math.max(1, Math.min(20, Math.trunc(limit)))), + start: String(Math.max(0, Math.trunc(start))), + }); + return `${ENDPOINT}?${params.toString()}`; +} diff --git a/desktop/src/discovery/types.ts b/desktop/src/discovery/types.ts index e1a6c03ac..2581381ff 100644 --- a/desktop/src/discovery/types.ts +++ b/desktop/src/discovery/types.ts @@ -1,6 +1,6 @@ /// A normalized paper across every discovery source (Semantic Scholar, OpenAlex, -/// Crossref, arXiv, PubMed, CORE). Each source maps its own response into this -/// shape so the Read/Discover UI is source-agnostic. +/// Google Scholar/SerpAPI, Crossref, arXiv, PubMed, CORE). Each source maps its +/// own response into this shape so the Read/Discover UI is source-agnostic. export interface DiscoveryPaper { paperId: string; // source-native id (S2 paperId / DOI / OpenAlex id / arXiv url / PMID) — dedupes imports title: string; @@ -14,15 +14,53 @@ export interface DiscoveryPaper { arxivId?: string; pdfUrl?: string; // open-access PDF link url?: string; + source?: DiscoverySourceId; + // Google Scholar exposes provider-specific graph/navigation metadata that + // cannot be represented by the generic citationCount alone. Keep it with the + // result so an imported paper can offer detailed citing works in the Cite tab. + scholar?: ScholarResultMetadata; +} + +export type DiscoverySourceId = + | 'openalex' + | 'semanticscholar' + | 'google-scholar' + | 'crossref' + | 'arxiv' + | 'pubmed' + | 'core'; + +export interface ScholarResultMetadata { + resultId?: string; + citedByCount?: number; + citesId?: string; + citedByUrl?: string; + relatedUrl?: string; + versionsCount?: number; + versionsUrl?: string; + cachedUrl?: string; +} + +export interface ScholarCitationYear { + year: number; + citations: number; +} + +export interface ScholarCitationPage { + papers: DiscoveryPaper[]; + citationsPerYear: ScholarCitationYear[]; + totalResults?: number; + hasMore: boolean; } /// One searchable literature source. `keyKey`/`keyUrl` are set when the source /// needs a user-supplied API key (stored device-local under `keyKey`). export interface SearchSource { - id: string; + id: DiscoverySourceId; label: string; note?: string; // short descriptor shown under the picker keyKey?: string; // localStorage key holding the user's API key, if required keyUrl?: string; // where to get a free key + keyManagedInVault?: boolean; // fixed keychain slot in Settings → Vault → TermiPod search: (query: string, limit: number) => Promise; } diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index 8960f9de1..bfcce433c 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -807,6 +807,7 @@ const en: Dict = { 'vault.tpWsWebdav': 'Author workspace · WebDAV', 'vault.tpWsS3': 'Author workspace · S3', 'vault.tpVoice': 'Voice input · DashScope', + 'vault.tpSerpApi': 'Literature discovery · SerpAPI (Google Scholar)', 'vault.tpBackend': 'Active backend', 'vault.tpModel': 'Model', 'vault.tpApiKey': 'API key', @@ -1415,6 +1416,11 @@ const en: Dict = { 'read.tabCite': 'Cite', 'read.fType': 'Type', 'read.fTitle': 'Title', + 'read.fRating': 'Rating', + 'read.rating': 'Literature rating', + 'read.ratingSet': 'Rate {n} out of 5', + 'read.ratingClear': 'Clear {n}-star rating', + 'read.ratingUnrated': 'Unrated', 'read.fAuthors': 'Authors (; separated)', 'read.fYear': 'Year', 'read.fVenue': 'Venue', @@ -1459,6 +1465,10 @@ const en: Dict = { 'read.apiKeyPlaceholder': 'API key (optional)', 'read.getApiKey': 'Get a free key', 'read.needsKey': 'This source needs a free API key — add it below.', + 'read.needsVaultKey': 'Add your SerpAPI key in Settings → Vault → TermiPod, then retry.', + 'read.apiKeyInVault': 'API key: Vault → TermiPod', + 'read.browserProxyFailed': 'The browser proxy could not be configured. Retry before opening this page.', + 'read.serpApiDesktopOnly': 'Google Scholar via SerpAPI is available in the TermiPod desktop app.', 'read.findPdfs': 'Find free PDFs', 'read.resultsShown': '{shown} of {total} results', 'read.filters': 'Filters', @@ -1487,6 +1497,9 @@ const en: Dict = { 'read.idNotFound': 'Couldn’t resolve that identifier.', 'read.enrichedVia': 'Enriched via {src} · {time}', 'read.mCitedBy': 'Cited by', + 'read.mScholarCitedBy': 'Google Scholar', + 'read.mOpenAlexCitedBy': 'OpenAlex', + 'read.citationCoverageNote': 'Counts differ because Scholar and OpenAlex index different sources and refresh on different schedules. Both are preserved.', 'read.mReferences': 'References', 'read.mImpact': 'Impact', 'read.mImpactHint': 'OpenAlex 2-year mean citedness — an open Impact-Factor analog (not Clarivate JCR IF).', @@ -1495,6 +1508,24 @@ const en: Dict = { 'read.mResources': 'Code & data', 'read.mRefList': 'References', 'read.mCiteList': 'Cited by', + 'read.openAlexCitingWorks': 'Citing works · OpenAlex', + 'read.scholarCitedByPage': 'Cited by page', + 'read.scholarFind': 'Find Scholar data', + 'read.scholarFinding': 'Finding…', + 'read.scholarMatchFound': 'Matched this paper on Google Scholar. You can now load its citing papers.', + 'read.scholarMatchNone': 'No confident Google Scholar match was found. Check the title, year, and first author.', + 'read.scholarRelated': 'Related works', + 'read.scholarVersions': 'Versions {n}', + 'read.scholarCached': 'Cached copy', + 'read.scholarLoadCitations': 'Load citing papers', + 'read.scholarLoadMore': 'Load more', + 'read.scholarLoading': 'Loading…', + 'read.scholarLoaded': 'Loaded {n} citing papers from Google Scholar.', + 'read.scholarNoMore': 'No more citing papers were returned.', + 'read.scholarLoadFailed': 'Couldn’t load Google Scholar citations. Check the connection and retry.', + 'read.scholarLoadedAt': 'Scholar details loaded {time} · each additional page uses one SerpAPI query.', + 'read.scholarCitationsPerYear': 'Google Scholar citations per year', + 'read.scholarCitingWorks': 'Citing works · Google Scholar', 'read.workSample': 'showing {n} of {total}', 'read.attHead': 'Attachments', 'read.attFile': 'File', @@ -1626,6 +1657,7 @@ const en: Dict = { 'read.colCreator': 'Creator', 'read.colYear': 'Year', 'read.colVenue': 'Journal', + 'read.colRating': 'Rating', 'read.colType': 'Type', 'read.resizeColumn': 'Resize {col} column', 'read.resizeColumnHint': 'Drag to resize · double-click to reset', @@ -3072,6 +3104,7 @@ const zh: Dict = { 'vault.tpWsWebdav': '写作工作区 · WebDAV', 'vault.tpWsS3': '写作工作区 · S3', 'vault.tpVoice': '语音输入 · DashScope', + 'vault.tpSerpApi': '文献发现 · SerpAPI(Google Scholar)', 'vault.tpBackend': '当前后端', 'vault.tpModel': '模型', 'vault.tpApiKey': 'API 密钥', @@ -3668,6 +3701,11 @@ const zh: Dict = { 'read.tabCite': '引用', 'read.fType': '类型', 'read.fTitle': '标题', + 'read.fRating': '评分', + 'read.rating': '文献评分', + 'read.ratingSet': '评为 {n} 分(满分 5 分)', + 'read.ratingClear': '清除 {n} 星评分', + 'read.ratingUnrated': '未评分', 'read.fAuthors': '作者(分号分隔)', 'read.fYear': '年份', 'read.fVenue': '来源', @@ -3711,6 +3749,10 @@ const zh: Dict = { 'read.apiKeyPlaceholder': 'API key(可选)', 'read.getApiKey': '获取免费 key', 'read.needsKey': '此来源需要免费 API key — 在下方添加。', + 'read.needsVaultKey': '请先在“设置 → 保险库 → TermiPod”中添加 SerpAPI 密钥,然后重试。', + 'read.apiKeyInVault': 'API 密钥:保险库 → TermiPod', + 'read.browserProxyFailed': '浏览器代理配置失败。请重试后再打开此页面。', + 'read.serpApiDesktopOnly': '通过 SerpAPI 使用 Google Scholar 仅在 TermiPod 桌面应用中可用。', 'read.findPdfs': '查找免费 PDF', 'read.resultsShown': '显示 {shown} / 共 {total} 条', 'read.filters': '筛选', @@ -3739,6 +3781,9 @@ const zh: Dict = { 'read.idNotFound': '无法解析该标识符。', 'read.enrichedVia': '经 {src} 抓取 · {time}', 'read.mCitedBy': '被引', + 'read.mScholarCitedBy': 'Google Scholar', + 'read.mOpenAlexCitedBy': 'OpenAlex', + 'read.citationCoverageNote': 'Scholar 与 OpenAlex 的收录范围和更新频率不同,因此计数可能不同;两者都会保留。', 'read.mReferences': '参考文献', 'read.mImpact': '影响力', 'read.mImpactHint': 'OpenAlex 近两年平均被引 — 开放的影响因子近似指标(非 Clarivate JCR IF)。', @@ -3747,6 +3792,24 @@ const zh: Dict = { 'read.mResources': '代码与数据', 'read.mRefList': '参考文献', 'read.mCiteList': '被引文献', + 'read.openAlexCitingWorks': '施引文献 · OpenAlex', + 'read.scholarCitedByPage': '打开被引页面', + 'read.scholarFind': '查找 Scholar 数据', + 'read.scholarFinding': '查找中…', + 'read.scholarMatchFound': '已在 Google Scholar 中匹配该文献,现在可以加载施引文献。', + 'read.scholarMatchNone': '未找到可信的 Google Scholar 匹配,请检查标题、年份和第一作者。', + 'read.scholarRelated': '相关文献', + 'read.scholarVersions': '版本 {n}', + 'read.scholarCached': '缓存副本', + 'read.scholarLoadCitations': '加载施引文献', + 'read.scholarLoadMore': '加载更多', + 'read.scholarLoading': '加载中…', + 'read.scholarLoaded': '已从 Google Scholar 加载 {n} 篇施引文献。', + 'read.scholarNoMore': '没有返回更多施引文献。', + 'read.scholarLoadFailed': '无法加载 Google Scholar 引用数据,请检查连接后重试。', + 'read.scholarLoadedAt': 'Scholar 详情加载于 {time} · 每加载一页会消耗一次 SerpAPI 查询。', + 'read.scholarCitationsPerYear': 'Google Scholar 年度被引', + 'read.scholarCitingWorks': '施引文献 · Google Scholar', 'read.workSample': '显示 {n} / 共 {total}', 'read.attHead': '附件', 'read.attFile': '文件', @@ -3877,6 +3940,7 @@ const zh: Dict = { 'read.colCreator': '作者', 'read.colYear': '年份', 'read.colVenue': '期刊', + 'read.colRating': '评分', 'read.colType': '类型', 'read.resizeColumn': '调整{col}列宽', 'read.resizeColumnHint': '拖动调整列宽 · 双击重置', diff --git a/desktop/src/state/appIntegrations.ts b/desktop/src/state/appIntegrations.ts index 52746b0ff..6df41ab96 100644 --- a/desktop/src/state/appIntegrations.ts +++ b/desktop/src/state/appIntegrations.ts @@ -3,18 +3,20 @@ import { secretGet, secretSetMany } from './persist'; import { loadWebdavConfig, loadZoteroBackend, loadZoteroS3Config } from './webdav'; import { loadS3Config, loadSyncBackend, loadWorkspaceSyncConfig } from './workspaceSync'; import { getVoiceModel } from '../voice/settings'; +import { SERPAPI_KEY } from './discoverySecrets'; /// TermiPod's own integration config + secrets, gathered in one place so they can /// be (a) surfaced/managed in the Vault's "TermiPod" tab and (b) sealed into the /// synced vault bundle — so setting up a new machine restores the WebDAV/S3 sync -/// endpoints and the voice API key along with everything else. +/// endpoints, voice API key, and discovery API key along with everything else. /// -/// The four integrations (each secret already lives in the consolidated keychain +/// These integrations (each secret already lives in the consolidated keychain /// item via `persist`): /// • Read storage — Zotero-compatible WebDAV (webdav.ts) /// • Author workspace — WebDAV (workspaceSync.ts) /// • Author workspace — S3 / S3-compatible (workspaceSync.ts / s3.rs) /// • Voice input — DashScope realtime ASR (voice/settings.ts) +/// • Literature discovery — Google Scholar through SerpAPI /// Non-secret config that seals into / restores from the vault. Snapshotting the /// raw localStorage strings keeps it trivially forward-compatible (e.g. `voice.model` @@ -48,6 +50,7 @@ export const APP_SECRET_KEYS = [ 'termipod.workspacesync.password', 'termipod.workspacesync.s3.secret', 'voice_dashscope_api_key', + SERPAPI_KEY, ] as const; // ── UI descriptors (Vault → TermiPod tab) ─────────────────────────────────── @@ -133,6 +136,13 @@ export function listAppIntegrations(): AppIntegration[] { info: [{ labelKey: 'vault.tpModel', value: model }], secrets: [{ slot: 'voice_dashscope_api_key', labelKey: 'vault.tpApiKey' }], }, + { + id: 'serpapi', + titleKey: 'vault.tpSerpApi', + icon: 'search', + info: [], + secrets: [{ slot: SERPAPI_KEY, labelKey: 'vault.tpApiKey' }], + }, ]; } diff --git a/desktop/src/state/discoverySecrets.ts b/desktop/src/state/discoverySecrets.ts new file mode 100644 index 000000000..6a123f5bb --- /dev/null +++ b/desktop/src/state/discoverySecrets.ts @@ -0,0 +1,10 @@ +import { secretGet } from './persist'; + +/// Fixed consolidated-keychain slot managed by Settings → Vault → TermiPod. +/// Keeping this out of localStorage prevents the SerpAPI credential from being +/// exposed alongside ordinary renderer preferences. +export const SERPAPI_KEY = 'termipod.discovery.serpapi.api_key'; + +export async function getSerpApiKey(): Promise { + return (await secretGet(SERPAPI_KEY))?.trim() ?? ''; +} diff --git a/desktop/src/state/library.ts b/desktop/src/state/library.ts index 51b0b7f23..d9d8edef5 100644 --- a/desktop/src/state/library.ts +++ b/desktop/src/state/library.ts @@ -19,10 +19,36 @@ export const REF_TYPES: RefType[] = ['article', 'preprint', 'book', 'report', 'w /// a work that cites it). Carries just enough to display + open it; the full /// record is fetched on demand, not stored. export interface WorkLink { - id?: string; // OpenAlex work id (URL form) + id?: string; // source-native work id title: string; year?: number; doi?: string; + url?: string; +} + +export interface ScholarCitationYear { + year: number; + citations: number; +} + +/// Google Scholar metadata is kept as its own provenance block instead of +/// being folded into OpenAlex enrichment. Citation databases index different +/// corpora, so their counts are valid side-by-side and must not overwrite one +/// another. +export interface ScholarMetadata { + resultId?: string; + citedByCount?: number; + citesId?: string; + citedByUrl?: string; + relatedUrl?: string; + versionsCount?: number; + versionsUrl?: string; + cachedUrl?: string; + citations?: WorkLink[]; + citationsPerYear?: ScholarCitationYear[]; + citationTotalResults?: number; + citationsLoadedAt?: number; + citationsHasMore?: boolean; } /// A code / data / model resource attached to a paper, detected by the scraper @@ -61,8 +87,22 @@ export interface Reference { abstract?: string; tldr?: string; // Semantic Scholar one-line summary citationCount?: number; - source?: 'semantic-scholar' | 'manual' | 'paste' | 'zotero' | 'scrape'; + rating?: number; // director-curated score, 1..5; undefined means unrated + source?: + | 'openalex' + | 'semanticscholar' + | 'semantic-scholar' // legacy spelling retained for persisted rows + | 'google-scholar' + | 'crossref' + | 'arxiv' + | 'pubmed' + | 'core' + | 'manual' + | 'paste' + | 'zotero' + | 'scrape'; externalId?: string; // e.g. Semantic Scholar paperId / Zotero item key — dedupes imports + scholar?: ScholarMetadata; tags: string[]; collectionIds: string[]; notes: string; // the reader's own notes on this reference @@ -89,6 +129,7 @@ export interface Reference { resourceLinks?: ResourceLink[]; // code / data / model links found in metadata enrichedAt?: number; // when the scraper last ran enrichSource?: string; // provenance, e.g. "OpenAlex" + openAlexId?: string; // resolved OpenAlex work id; separate from source-native externalId // --- Hub sync linkage (state/librarySync.ts) ----------------------------- hubId?: string; // the id of the linked hub reference_items row, once synced syncedAt?: number; // when this row last reconciled with the hub @@ -435,6 +476,13 @@ export const useLibrary = create((set, get) => ({ addedAt: cur.addedAt, notes: cur.notes, bodyMarkdown: cur.bodyMarkdown ?? it.ref.bodyMarkdown, + // Ratings are director curation: a Zotero re-import never clears one. + // A clean hub pull may update it (agent/device edit); a failed local + // push keeps the dirty local value for the next retry. + rating: + it.ref.syncedAt !== undefined && cur.dirty !== true + ? it.ref.rating + : (cur.rating ?? it.ref.rating), tags: [...new Set([...cur.tags, ...it.ref.tags])], collectionIds: [...new Set([...cur.collectionIds, ...collectionIds])], attachments, diff --git a/desktop/src/state/librarySync.ts b/desktop/src/state/librarySync.ts index 4039032df..42df6e514 100644 --- a/desktop/src/state/librarySync.ts +++ b/desktop/src/state/librarySync.ts @@ -39,6 +39,8 @@ function buildEnrichment(r: Reference): Record | undefined { if (r.resourceLinks !== undefined) e.resourceLinks = r.resourceLinks; if (r.enrichedAt !== undefined) e.enrichedAt = r.enrichedAt; if (r.enrichSource !== undefined) e.enrichSource = r.enrichSource; + if (r.openAlexId !== undefined) e.openAlexId = r.openAlexId; + if (r.scholar !== undefined) e.scholar = r.scholar; return Object.keys(e).length > 0 ? e : undefined; } @@ -56,6 +58,8 @@ function applyEnrichment(ref: Partial, enr: Entity): void { if (Array.isArray(enr.resourceLinks)) ref.resourceLinks = enr.resourceLinks as Reference['resourceLinks']; if ('enrichedAt' in enr) ref.enrichedAt = num(enr, 'enrichedAt'); if ('enrichSource' in enr) ref.enrichSource = str(enr, 'enrichSource'); + if ('openAlexId' in enr) ref.openAlexId = str(enr, 'openAlexId'); + if (enr.scholar !== undefined && enr.scholar !== null) ref.scholar = enr.scholar as Reference['scholar']; } // Desktop Reference → hub reference body (snake_case wire shape). collectionIds @@ -76,6 +80,7 @@ function refToHubBody(r: Reference, collName: Map): Record .spacer { + align-self: stretch; + -webkit-app-region: drag; +} .shell-macos button, .shell-macos input, .shell-macos select, diff --git a/desktop/src/styles/partials/03-pdf.css b/desktop/src/styles/partials/03-pdf.css index 09cea6a84..f08376282 100644 --- a/desktop/src/styles/partials/03-pdf.css +++ b/desktop/src/styles/partials/03-pdf.css @@ -1321,6 +1321,7 @@ } .read-table { width: 100%; + min-width: 1040px; table-layout: fixed; border-collapse: collapse; font-size: var(--font-size-13, 0.82rem); @@ -1432,6 +1433,44 @@ width: 22%; color: var(--text-secondary); } +.read-rating { + display: inline-flex; + align-items: center; + gap: 1px; + vertical-align: middle; +} +.read-rating-star { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: var(--radius-xs); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} +.read-rating.compact .read-rating-star { + width: 13px; + height: 18px; +} +.read-rating-star:hover, +.read-rating-star:focus-visible { + color: var(--accent-text); + background: var(--accent-tint); +} +.read-rating-star.filled { + color: var(--accent-text); +} +.read-rating-star.filled .ui-icon { + fill: currentColor; +} +.read-rating-star:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 1px; +} .read-td-type { color: var(--text-muted); } diff --git a/desktop/src/styles/partials/04-library-nav.css b/desktop/src/styles/partials/04-library-nav.css index 3d788f7bc..f2210dfc1 100644 --- a/desktop/src/styles/partials/04-library-nav.css +++ b/desktop/src/styles/partials/04-library-nav.css @@ -339,6 +339,30 @@ .ref-form-row label.grow { flex: 1; } +.ref-rating-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: var(--font-size-label); + color: var(--text-secondary); +} +.ref-rating-type-row { + align-items: flex-start; +} +.ref-rating-type-row .ref-rating-field { + flex: 1 1 auto; + min-width: 0; +} +.ref-rating-type-row .ref-type-field { + flex: 0 1 120px; + min-width: 96px; +} +.ref-rating-value { + display: flex; + align-items: center; + gap: var(--spacing-s8); + min-height: var(--control-sm); +} .ref-col-checks { display: flex; flex-wrap: wrap; @@ -471,6 +495,12 @@ .ref-citation-empty { padding: var(--spacing-s4) 0; } +.ref-provider-note { + padding: var(--spacing-s8) var(--spacing-s12); + border-left: 2px solid var(--accent); + background: var(--accent-tint); + border-radius: var(--radius-sm); +} .ref-metrics { display: flex; flex-wrap: wrap; @@ -573,6 +603,52 @@ .ref-work-year { white-space: nowrap; } +.ref-scholar-data { + gap: var(--spacing-s8); + padding: var(--spacing-s12); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-2, var(--surface)); +} +.ref-scholar-actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-s8); +} +.ref-scholar-actions button { + display: inline-flex; + align-items: center; + gap: var(--spacing-s4); + white-space: nowrap; +} +.ref-scholar-years { + display: flex; + align-items: flex-end; + gap: var(--spacing-s4); + height: 92px; + padding: var(--spacing-s8) var(--spacing-s4) 0; + overflow-x: auto; + border-bottom: 1px solid var(--border); +} +.ref-scholar-year { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + align-items: end; + justify-items: center; + gap: var(--spacing-s2); + width: 22px; + min-width: 22px; + height: 100%; + color: var(--text-muted); + font-size: var(--font-size-label); + font-variant-numeric: tabular-nums; +} +.ref-scholar-year-bar { + width: 12px; + max-height: 66px; + border-radius: 3px 3px 0 0; + background: var(--accent); +} .ref-body-edit { min-height: 180px; border: 1px solid var(--border); diff --git a/desktop/src/surfaces/BrowserView.tsx b/desktop/src/surfaces/BrowserView.tsx index acf9c6c4d..e9a91cff6 100644 --- a/desktop/src/surfaces/BrowserView.tsx +++ b/desktop/src/surfaces/BrowserView.tsx @@ -90,6 +90,16 @@ export function BrowserView({ // A real load failure (DNS / offline / TLS) — the replacement for the old // frame-refused panel, now only for genuine failures. const [loadError, setLoadError] = useState<{ code: number; desc: string; url: string } | null>(null); + // A persistent Electron session can begin loading as soon as a is + // attached. Keep the guest entirely unmounted until setProxy has settled, so + // its first request cannot escape through the previous/system route. + const [proxyReady, setProxyReady] = useState(() => !isShell()); + const [proxyError, setProxyError] = useState(false); + const [proxyAttempt, setProxyAttempt] = useState(0); + // A user can submit the start-page address during the short proxy wait. Keep + // that URL as the guest's mount-time src instead of losing it while no element + // exists yet. + const pendingMountUrl = useRef(null); const bookmarks = useBookmarks((s) => s.bookmarks); const addBookmark = useBookmarks((s) => s.add); @@ -112,6 +122,7 @@ export function BrowserView({ setCurrent(u); const v = viewRef.current; if (v !== null) void v.loadURL(u).catch(() => undefined); + else pendingMountUrl.current = u; }, []); // Reflect the guest's real navigation state after a (best-effort) settle. @@ -177,14 +188,30 @@ export function BrowserView({ v.removeEventListener('did-fail-load', onFail); v.removeEventListener('dom-ready', onReady); }; - }, [onTitle, onNavigate, syncNavState, current]); + }, [onTitle, onNavigate, syncNavState, current, proxyReady]); - // Push the app's effective proxy to the webtab session before the first load - // (the session default is system-proxy; this applies a manual override). + // Push the app's effective proxy before a guest is allowed to mount. Electron + // begins navigating a as part of attachment, so merely + // starting this async command before render still leaves a first-load race. useEffect(() => { - if (!isShell()) return; - void invoke('webtab_set_proxy', { proxy: proxyForConnection('webtab') ?? null }).catch(() => undefined); - }, []); + if (!isShell()) { + setProxyReady(true); + return; + } + let live = true; + setProxyReady(false); + setProxyError(false); + void invoke('webtab_set_proxy', { proxy: proxyForConnection('webtab') ?? null }) + .then(() => { + if (live) setProxyReady(true); + }) + .catch(() => { + if (live) setProxyError(true); + }); + return () => { + live = false; + }; + }, [proxyAttempt]); // Ctrl/Cmd+L focuses the address bar (the one browser shortcut worth stealing; // it collides with nothing in the app's map). @@ -239,15 +266,26 @@ export function BrowserView({
- {/* The guest is always mounted (stable per tab); `src` is the mount-time - URL — later navigation goes through loadURL, never `src`. */} - } - className="browser-webview" - src={initialSrc} - partition="persist:webtab" - allowpopups="true" - /> + {/* Do not even attach the guest until the persistent session's proxy is + configured. `src` is fixed at mount; later navigation uses loadURL. */} + {proxyReady && ( + } + className="browser-webview" + src={pendingMountUrl.current ?? initialSrc} + partition="persist:webtab" + allowpopups="true" + /> + )} + {proxyError && ( +
+ +

{t('read.browserProxyFailed')}

+ +
+ )} {!started && (
diff --git a/desktop/src/surfaces/ReadSurface.tsx b/desktop/src/surfaces/ReadSurface.tsx index eba6fb951..79967fcc6 100644 --- a/desktop/src/surfaces/ReadSurface.tsx +++ b/desktop/src/surfaces/ReadSurface.tsx @@ -40,9 +40,12 @@ import { useSession } from '../state/session'; import { detectIdentifier, enrichWithUnpaywall, + isLikelySameWork, + loadGoogleScholarCitations, lsGet, lsSet, scrapeMetadata, + searchGoogleScholar, SOURCES, sourceById, type DiscoveryPaper, @@ -109,29 +112,32 @@ const ALL = '__all__'; // stores synchronously, so surface-local state could never be published (G1). // Sortable columns for the Zotero-style library table. -type SortKey = 'title' | 'creator' | 'year' | 'venue' | 'type'; +type SortKey = 'title' | 'creator' | 'year' | 'venue' | 'rating' | 'type'; type SortDir = 'asc' | 'desc'; const SORT_COLS: { key: SortKey; labelKey: string }[] = [ { key: 'title', labelKey: 'read.colTitle' }, { key: 'creator', labelKey: 'read.colCreator' }, { key: 'year', labelKey: 'read.colYear' }, { key: 'venue', labelKey: 'read.colVenue' }, + { key: 'rating', labelKey: 'read.colRating' }, { key: 'type', labelKey: 'read.colType' }, ]; type LibraryColumnWidths = Record; const LIB_COLUMN_WIDTHS_KEY = 'termipod.read.libraryColumnWidths'; const DEFAULT_LIB_COLUMN_WIDTHS: LibraryColumnWidths = { - title: 40, + title: 37, creator: 22, - year: 9, - venue: 19, - type: 10, + year: 7, + venue: 18, + rating: 8, + type: 8, }; const MIN_LIB_COLUMN_WIDTHS: LibraryColumnWidths = { - title: 20, - creator: 14, - year: 7, - venue: 12, + title: 18, + creator: 13, + year: 6, + venue: 11, + rating: 8, type: 8, }; @@ -173,11 +179,64 @@ function sortVal(r: Reference, key: SortKey): string | number { return r.year ?? 0; case 'venue': return (r.venue ?? '').toLowerCase(); + case 'rating': + return r.rating ?? 0; case 'type': return r.type; } } +function RatingControl({ + value, + onChange, + compact = false, +}: { + value?: number; + onChange: (rating: number | undefined) => void; + compact?: boolean; +}): JSX.Element { + const t = useT(); + const [preview, setPreview] = useState(null); + const shown = preview ?? value ?? 0; + return ( + setPreview(null)} + > + {[1, 2, 3, 4, 5].map((score) => { + const current = value === score; + const title = current + ? t('read.ratingClear').replace('{n}', String(score)) + : t('read.ratingSet').replace('{n}', String(score)); + return ( + + ); + })} + + ); +} + function splitList(s: string, sep: string): string[] { return s .split(sep) @@ -196,6 +255,7 @@ interface LibRowCtx { onOpen: (id: string) => void; // open the reader (double-click / Enter with a viewable attachment) onMenu: (id: string, x: number, y: number) => void; hasPdf: (r: Reference) => boolean; + onRate: (id: string, rating: number | undefined) => void; } // Custom table wrapper keeps the class stable. Header widths control the fixed @@ -295,8 +355,21 @@ function paperToRef(p: DiscoveryPaper): Omit { abstract: p.abstract, tldr: p.tldr, citationCount: p.citationCount, - source: 'semantic-scholar', + source: p.source ?? 'manual', externalId: p.paperId, + scholar: + p.scholar !== undefined + ? { + resultId: p.scholar.resultId, + citedByCount: p.scholar.citedByCount, + citesId: p.scholar.citesId, + citedByUrl: p.scholar.citedByUrl, + relatedUrl: p.scholar.relatedUrl, + versionsCount: p.scholar.versionsCount, + versionsUrl: p.scholar.versionsUrl, + cachedUrl: p.scholar.cachedUrl, + } + : undefined, tags: [], collectionIds: [], notes: '', @@ -306,9 +379,18 @@ function paperToRef(p: DiscoveryPaper): Omit { // Resolve an existing library item using its strongest identifiers. Enrichment // lives with Citation data, but its patch still preserves hand-edited core fields. function refToSeed(r: Reference): ScrapeSeed { - const openAlexId = - r.externalId !== undefined && /^https?:\/\/openalex\.org\/W\d+$/i.test(r.externalId) ? r.externalId : undefined; - return { doi: r.doi, arxivId: r.arxivId, openAlexId, title: r.title, url: r.url, abstract: r.abstract }; + const openAlexId = r.openAlexId ?? + (r.externalId !== undefined && /^https?:\/\/openalex\.org\/W\d+$/i.test(r.externalId) ? r.externalId : undefined); + return { + doi: r.doi, + arxivId: r.arxivId, + openAlexId, + title: r.title, + year: r.year, + authors: r.authors, + url: r.url, + abstract: r.abstract, + }; } function patchToRefFields(patch: ScrapePatch, cur: Reference): Partial { @@ -325,6 +407,7 @@ function patchToRefFields(patch: ScrapePatch, cur: Reference): Partial (w.doi !== undefined ? `https://doi.org/${w.doi}` : (w.id ?? '')); + const href = (w: WorkLink): string => + w.doi !== undefined ? `https://doi.org/${w.doi}` : (w.url ?? w.id ?? ''); const count = total !== undefined && total > works.length ? t('read.workSample').replace('{n}', String(works.length)).replace('{total}', total.toLocaleString()) @@ -889,18 +973,37 @@ function CitationData({ scraping, msg, onScrape, + scholarBusy, + scholarMsg, + onLoadScholar, + onFindScholar, }: { reference: Reference; scraping: boolean; msg: string | null; onScrape: () => void; + scholarBusy: boolean; + scholarMsg: string | null; + onLoadScholar: () => void; + onFindScholar: () => void; }): JSX.Element { const t = useT(); const openLink = useOpenLink(); const j = ref.journal; - const cited = ref.citedByCount ?? ref.citationCount; + const scholar = ref.scholar; + const scholarCount = scholar?.citedByCount ?? (ref.source === 'google-scholar' ? ref.citationCount : undefined); + const openAlexCount = ref.citedByCount ?? (ref.source === 'openalex' ? ref.citationCount : undefined); + const otherCount = + ref.source !== 'google-scholar' && ref.source !== 'openalex' && ref.citationCount !== undefined + ? ref.citationCount + : undefined; const enriched = ref.enrichedAt !== undefined; - const hasMetrics = cited !== undefined || ref.referenceCount !== undefined || j?.twoYearMeanCitedness !== undefined; + const hasMetrics = + scholarCount !== undefined || + openAlexCount !== undefined || + otherCount !== undefined || + ref.referenceCount !== undefined || + j?.twoYearMeanCitedness !== undefined; const hasData = hasMetrics || j?.name !== undefined || @@ -931,9 +1034,21 @@ function CitationData({ {hasMetrics && (
- {cited !== undefined && ( + {scholarCount !== undefined && (
- {cited} + {scholarCount.toLocaleString()} + {t('read.mScholarCitedBy')} +
+ )} + {openAlexCount !== undefined && ( +
+ {openAlexCount.toLocaleString()} + {t('read.mOpenAlexCitedBy')} +
+ )} + {otherCount !== undefined && ( +
+ {otherCount.toLocaleString()} {t('read.mCitedBy')}
)} @@ -957,6 +1072,9 @@ function CitationData({ )}
)} + {scholarCount !== undefined && openAlexCount !== undefined && scholarCount !== openAlexCount && ( +
{t('read.citationCoverageNote')}
+ )} {j?.name !== undefined && (
{t('read.mJournal').replace('{name}', j.name)} @@ -987,8 +1105,80 @@ function CitationData({
)} +
+
Google Scholar
+
+ {scholar === undefined && ( + + )} + {scholar !== undefined && ( + <> + {scholar.citedByUrl !== undefined && ( + + )} + {scholar.relatedUrl !== undefined && ( + + )} + {scholar.versionsUrl !== undefined && ( + + )} + {scholar.cachedUrl !== undefined && ( + + )} + {scholar.citesId !== undefined && + (scholar.citationsHasMore !== false || scholar.citationsLoadedAt === undefined) && ( + + )} + + )} +
+ {scholarMsg !== null &&
{scholarMsg}
} + {scholar?.citationsLoadedAt !== undefined && ( +
+ {t('read.scholarLoadedAt').replace('{time}', new Date(scholar.citationsLoadedAt).toLocaleDateString())} +
+ )} + {scholar?.citationsPerYear !== undefined && scholar.citationsPerYear.length > 0 && ( +
+ {scholar.citationsPerYear.map((point) => { + const max = Math.max(...(scholar.citationsPerYear ?? []).map((entry) => entry.citations), 1); + return ( +
+ + {String(point.year).slice(-2)} +
+ ); + })} +
+ )} + +
+ - + ); } @@ -1053,11 +1243,14 @@ function Inspector({ const [confirming, setConfirming] = useState(false); const [scraping, setScraping] = useState(false); const [scrapeMsg, setScrapeMsg] = useState(null); + const [scholarBusy, setScholarBusy] = useState(false); + const [scholarMsg, setScholarMsg] = useState(null); useEffect(() => { const b = useLibrary.getState().references.find((r) => r.id === refId)?.bodyMarkdown ?? ''; setEditingBody(b === ''); setConfirming(false); setScrapeMsg(null); + setScholarMsg(null); }, [refId]); async function runScrape(): Promise { @@ -1084,6 +1277,87 @@ function Inspector({ } } + async function runScholarCitations(): Promise { + const current = useLibrary.getState().references.find((r) => r.id === refId); + const scholar = current?.scholar; + if (current === undefined || scholar?.citesId === undefined) return; + const existing = scholar.citations ?? []; + setScholarBusy(true); + setScholarMsg(null); + try { + const page = await loadGoogleScholarCitations(scholar.citesId, existing.length, 20); + const byKey = new Map(existing.map((work) => [work.id ?? work.doi ?? work.url ?? work.title, work] as const)); + for (const paper of page.papers) { + const work: WorkLink = { + id: paper.paperId, + title: paper.title, + year: paper.year, + doi: paper.doi, + url: paper.url, + }; + byKey.set(work.id ?? work.doi ?? work.url ?? work.title, work); + } + update(current.id, { + scholar: { + ...scholar, + citations: [...byKey.values()], + citationsPerYear: page.citationsPerYear.length > 0 ? page.citationsPerYear : scholar.citationsPerYear, + citationTotalResults: page.totalResults ?? scholar.citationTotalResults, + citationsLoadedAt: Date.now(), + citationsHasMore: page.hasMore, + }, + }); + setScholarMsg( + page.papers.length === 0 + ? t('read.scholarNoMore') + : t('read.scholarLoaded').replace('{n}', String(page.papers.length)), + ); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + setScholarMsg(message === 'needs-key' ? t('read.needsVaultKey') : t('read.scholarLoadFailed')); + } finally { + setScholarBusy(false); + } + } + + async function findScholarMetadata(): Promise { + const current = useLibrary.getState().references.find((r) => r.id === refId); + if (current === undefined || current.title.trim() === '') return; + setScholarBusy(true); + setScholarMsg(null); + try { + const candidates = await searchGoogleScholar(`"${current.title}"`, 10); + const match = candidates.find((paper) => + isLikelySameWork( + { title: current.title, year: current.year, authors: current.authors }, + { title: paper.title, year: paper.year, authors: paper.authors }, + ), + ); + if (match?.scholar === undefined) { + setScholarMsg(t('read.scholarMatchNone')); + return; + } + update(current.id, { + scholar: { + resultId: match.scholar.resultId, + citedByCount: match.scholar.citedByCount, + citesId: match.scholar.citesId, + citedByUrl: match.scholar.citedByUrl, + relatedUrl: match.scholar.relatedUrl, + versionsCount: match.scholar.versionsCount, + versionsUrl: match.scholar.versionsUrl, + cachedUrl: match.scholar.cachedUrl, + }, + }); + setScholarMsg(t('read.scholarMatchFound')); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + setScholarMsg(message === 'needs-key' ? t('read.needsVaultKey') : t('read.scholarLoadFailed')); + } finally { + setScholarBusy(false); + } + } + if (ref === undefined) return
{t('read.pickItem')}
; const atts = ref.attachments ?? []; @@ -1221,20 +1495,31 @@ function Inspector({
{tab === 'info' && (
- +
+
+ {t('read.fRating')} +
+ update(ref.id, { rating })} /> + + {ref.rating === undefined ? t('read.ratingUnrated') : `${ref.rating}/5`} + +
+
+ +
)} @@ -1790,7 +2079,7 @@ function DiscoverPanel({ setBusy(true); setErr(null); try { - let res = await source.search(q, 25); + let res: DiscoveryPaper[] = (await source.search(q, 25)).map((paper) => ({ ...paper, source: source.id })); // Backfill open-access PDF links (Unpaywall) for results with a DOI but no // PDF — more "PDF" badges appear regardless of which source was used. if (findPdfs) res = await enrichWithUnpaywall(res); @@ -1798,8 +2087,10 @@ function DiscoverPanel({ } catch (e) { const msg = e instanceof Error ? e.message : ''; if (msg === 'needs-key') { - setShowKey(true); - setErr(t('read.needsKey')); + setShowKey(source.keyManagedInVault !== true); + setErr(source.keyManagedInVault === true ? t('read.needsVaultKey') : t('read.needsKey')); + } else if (msg === 'serpapi-shell-required') { + setErr(t('read.serpApiDesktopOnly')); } else { setErr(msg === 'rate-limited' ? t('read.rateLimited') : t('read.searchFailed')); } @@ -1867,6 +2158,7 @@ function DiscoverPanel({ details: p.detailsAdd, enrichedAt: p.enrichedAt, enrichSource: p.enrichSource, + openAlexId: p.openAlexId, tags: [], collectionIds: [], notes: '', @@ -1927,6 +2219,7 @@ function DiscoverPanel({ {key !== '' ? t('read.apiKeySet') : t('read.apiKeyAdd')} )} + {source.keyManagedInVault === true && {t('read.apiKeyInVault')}}
{showKey && source.keyKey !== undefined && (
@@ -2433,6 +2726,7 @@ export function ReadSurface(): JSX.Element { setRowMenu({ x, y, id }); }, hasPdf: (r) => hasAnyAttachment(r), + onRate: (id, rating) => useLibrary.getState().updateReference(id, { rating }), }), // openPdfTab is a stable closure over refs/state setters; selection drives re-render. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -2443,7 +2737,7 @@ export function ReadSurface(): JSX.Element { if (key === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); else { setSortKey(key); - setSortDir('asc'); + setSortDir(key === 'rating' ? 'desc' : 'asc'); } } @@ -3164,6 +3458,13 @@ export function ReadSurface(): JSX.Element { {r.year ?? ''} {r.venue ?? ''} + + libRowCtx.onRate(r.id, rating)} + /> + {r.type} ); diff --git a/desktop/src/surfaces/VaultManager.tsx b/desktop/src/surfaces/VaultManager.tsx index 1c2560988..acc7c0338 100644 --- a/desktop/src/surfaces/VaultManager.tsx +++ b/desktop/src/surfaces/VaultManager.tsx @@ -706,7 +706,7 @@ function ItemEditor({ ); } -// ── TermiPod tab: the app's own integrations (WebDAV/S3 sync + voice key) ───── +// ── TermiPod tab: app integrations (sync, voice, discovery credentials) ────── /// A raw keychain-slot secret (not a vault item) with reveal / copy / inline edit. /// Used for the app-integration secrets, which live under fixed keychain keys. diff --git a/docs/decisions/053-hub-reference-library-entity.md b/docs/decisions/053-hub-reference-library-entity.md index 3ebf67d43..e1bc8ba09 100644 --- a/docs/decisions/053-hub-reference-library-entity.md +++ b/docs/decisions/053-hub-reference-library-entity.md @@ -34,7 +34,8 @@ data-ownership law, the correct home for that metadata is the hub. keyword; the entity, REST path, and tools all use "reference"). It is a clean projection of the desktop `Reference` shape: `type` (article | preprint | book | report | webpage | note), `title`, `authors[]`, `year`, `venue`, `doi`, -`arxiv_id`, `url`, `pdf_url`, `abstract`, `tldr`, `citation_count`, `source`, +`arxiv_id`, `url`, `pdf_url`, `abstract`, `tldr`, `citation_count`, `rating` +(the director's optional 1–5 score), `source`, `external_id` (dedupe key, e.g. `zotero:`), `tags[]`, `collections[]` (names), `notes`, `body_markdown`, `details{}` (long-tail source fields), `zotero_storage{key,file}` (attachment coordinates — **not bytes**). Team-scoped diff --git a/hub/internal/server/handlers_references.go b/hub/internal/server/handlers_references.go index d70367977..2fc355ff4 100644 --- a/hub/internal/server/handlers_references.go +++ b/hub/internal/server/handlers_references.go @@ -66,6 +66,7 @@ type referenceBody struct { Abstract string `json:"abstract,omitempty"` TLDR string `json:"tldr,omitempty"` CitationCount *int `json:"citation_count,omitempty"` + Rating *int `json:"rating,omitempty"` Source string `json:"source,omitempty"` ExternalID string `json:"external_id,omitempty"` Tags []string `json:"tags"` @@ -144,17 +145,17 @@ func referenceAttachmentsJSON(v []referenceAttachment) string { // ---- shared store methods (used by REST + MCP) ----------------------------- const referenceCols = `id, team_id, type, title, authors_json, year, venue, doi, arxiv_id, - url, pdf_url, abstract, tldr, citation_count, source, external_id, tags_json, + url, pdf_url, abstract, tldr, citation_count, rating, source, external_id, tags_json, collections_json, notes, body_markdown, details_json, zotero_storage_json, attachments_json, enrichment_json, created_at, updated_at` func scanReference(row interface{ Scan(...any) error }) (referenceOut, error) { var r referenceOut var authors, tags, collections string - var year, citation sql.NullInt64 + var year, citation, rating sql.NullInt64 var venue, doi, arxiv, url, pdfURL, abstract, tldr, source, extID, bodyMD, details, zotero, attachments, enrichment sql.NullString err := row.Scan(&r.ID, &r.TeamID, &r.Type, &r.Title, &authors, &year, &venue, &doi, &arxiv, - &url, &pdfURL, &abstract, &tldr, &citation, &source, &extID, &tags, + &url, &pdfURL, &abstract, &tldr, &citation, &rating, &source, &extID, &tags, &collections, &r.Notes, &bodyMD, &details, &zotero, &attachments, &enrichment, &r.CreatedAt, &r.UpdatedAt) if err != nil { return r, err @@ -173,6 +174,10 @@ func scanReference(row interface{ Scan(...any) error }) (referenceOut, error) { v := int(citation.Int64) r.CitationCount = &v } + if rating.Valid { + v := int(rating.Int64) + r.Rating = &v + } r.Venue, r.DOI, r.ArxivID, r.URL = venue.String, doi.String, arxiv.String, url.String r.PDFURL, r.Abstract, r.TLDR, r.Source, r.ExternalID = pdfURL.String, abstract.String, tldr.String, source.String, extID.String r.BodyMarkdown = bodyMD.String @@ -222,10 +227,10 @@ func (s *Server) createReference(ctx context.Context, team string, b referenceBo now := NowUTC() _, err := s.writeDB.ExecContext(ctx, ` INSERT INTO reference_items (`+referenceCols+`) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, team, normalizeRefType(b.Type), b.Title, jsonStrArray(b.Authors), nullInt(b.Year), refNullStr(b.Venue), refNullStr(b.DOI), refNullStr(b.ArxivID), refNullStr(b.URL), refNullStr(b.PDFURL), - refNullStr(b.Abstract), refNullStr(b.TLDR), nullInt(b.CitationCount), refNullStr(b.Source), + refNullStr(b.Abstract), refNullStr(b.TLDR), nullInt(b.CitationCount), nullInt(b.Rating), refNullStr(b.Source), refNullStr(b.ExternalID), jsonStrArray(b.Tags), jsonStrArray(b.Collections), b.Notes, refNullStr(b.BodyMarkdown), detailsJSON(b.Details), zoteroJSON(b.ZoteroStorage), referenceAttachmentsJSON(b.Attachments), enrichmentJSON(b.Enrichment), now, now) @@ -308,13 +313,13 @@ func (s *Server) patchReference(ctx context.Context, team, id string, patch json _, err = s.writeDB.ExecContext(ctx, ` UPDATE reference_items SET type = ?, title = ?, authors_json = ?, year = ?, venue = ?, doi = ?, arxiv_id = ?, - url = ?, pdf_url = ?, abstract = ?, tldr = ?, citation_count = ?, source = ?, + url = ?, pdf_url = ?, abstract = ?, tldr = ?, citation_count = ?, rating = ?, source = ?, external_id = ?, tags_json = ?, collections_json = ?, notes = ?, body_markdown = ?, details_json = ?, zotero_storage_json = ?, attachments_json = ?, enrichment_json = ?, updated_at = ? WHERE team_id = ? AND id = ?`, normalizeRefType(b.Type), b.Title, jsonStrArray(b.Authors), nullInt(b.Year), refNullStr(b.Venue), refNullStr(b.DOI), refNullStr(b.ArxivID), refNullStr(b.URL), refNullStr(b.PDFURL), refNullStr(b.Abstract), - refNullStr(b.TLDR), nullInt(b.CitationCount), refNullStr(b.Source), refNullStr(b.ExternalID), + refNullStr(b.TLDR), nullInt(b.CitationCount), nullInt(b.Rating), refNullStr(b.Source), refNullStr(b.ExternalID), jsonStrArray(b.Tags), jsonStrArray(b.Collections), b.Notes, refNullStr(b.BodyMarkdown), detailsJSON(b.Details), zoteroJSON(b.ZoteroStorage), referenceAttachmentsJSON(b.Attachments), enrichmentJSON(b.Enrichment), NowUTC(), team, id) diff --git a/hub/internal/server/handlers_references_test.go b/hub/internal/server/handlers_references_test.go index 5172d1df3..bc5df4823 100644 --- a/hub/internal/server/handlers_references_test.go +++ b/hub/internal/server/handlers_references_test.go @@ -16,11 +16,13 @@ func TestReferenceCRUD(t *testing.T) { team := defaultTeamID yr := 2023 + rating := 5 created, err := s.createReference(ctx, team, referenceBody{ Type: "preprint", Title: "Attention Is All You Need", Authors: []string{"Ashish Vaswani", "Noam Shazeer"}, Year: &yr, + Rating: &rating, ArxivID: "1706.03762", Source: "zotero", ExternalID: "zotero:ABC123", @@ -31,7 +33,7 @@ func TestReferenceCRUD(t *testing.T) { if err != nil { t.Fatalf("create: %v", err) } - if created.ID == "" || created.Type != "preprint" || len(created.Authors) != 2 { + if created.ID == "" || created.Type != "preprint" || len(created.Authors) != 2 || created.Rating == nil || *created.Rating != 5 { t.Fatalf("unexpected created row: %+v", created) } if created.Details["publisher"] != "NeurIPS" { @@ -66,6 +68,19 @@ func TestReferenceCRUD(t *testing.T) { if patched.Notes != "seminal" || patched.Title != "Attention Is All You Need" || *patched.Year != 2023 { t.Fatalf("patch didn't preserve untouched fields: %+v", patched) } + if patched.Rating == nil || *patched.Rating != 5 { + t.Fatalf("patch dropped rating: %+v", patched) + } + + // A nullable rating can be explicitly cleared without disturbing the item. + cleared, err := s.patchReference(ctx, team, created.ID, json.RawMessage(`{"rating":null}`)) + if err != nil || cleared.Rating != nil || cleared.Title != created.Title { + t.Fatalf("clear rating: %v %+v", err, cleared) + } + invalidRating := 6 + if _, err := s.createReference(ctx, team, referenceBody{Title: "Invalid rating", Rating: &invalidRating}); err == nil { + t.Fatal("rating above 5 should violate the reference rating constraint") + } // Delete. ok, err := s.deleteReference(ctx, team, created.ID) diff --git a/hub/internal/server/native_tools.go b/hub/internal/server/native_tools.go index d22341a43..a94db9226 100644 --- a/hub/internal/server/native_tools.go +++ b/hub/internal/server/native_tools.go @@ -221,7 +221,7 @@ func buildNativeTools() []nativeTool { { Name: "reference_create", Short: "Add a reference to the library.", - Description: "Create a reference. Provide at least title or external_id. Fields: type (article|preprint|book|report|webpage|note), title, authors (string array), year, venue, doi, arxiv_id, url, pdf_url, abstract, tldr, source, external_id (dedupe key), tags, collections (name array), notes, body_markdown, attachments (portable key/file descriptors; never absolute paths).", + Description: "Create a reference. Provide at least title or external_id. Fields: type (article|preprint|book|report|webpage|note), title, authors (string array), year, venue, doi, arxiv_id, url, pdf_url, abstract, tldr, rating (integer 1–5), source, external_id (dedupe key), tags, collections (name array), notes, body_markdown, attachments (portable key/file descriptors; never absolute paths).", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ @@ -234,6 +234,7 @@ func buildNativeTools() []nativeTool { "arxiv_id": map[string]any{"type": "string"}, "url": map[string]any{"type": "string"}, "abstract": map[string]any{"type": "string"}, + "rating": map[string]any{"type": "integer", "minimum": 1, "maximum": 5}, "source": map[string]any{"type": "string"}, "external_id": map[string]any{"type": "string"}, "tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, @@ -279,6 +280,7 @@ func buildNativeTools() []nativeTool { "collections": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, "notes": map[string]any{"type": "string"}, "abstract": map[string]any{"type": "string"}, + "rating": map[string]any{"type": "integer", "minimum": 1, "maximum": 5}, "attachments": map[string]any{ "type": "array", "items": map[string]any{ diff --git a/hub/migrations/0076_reference_rating.down.sql b/hub/migrations/0076_reference_rating.down.sql new file mode 100644 index 000000000..fb9b52ccd --- /dev/null +++ b/hub/migrations/0076_reference_rating.down.sql @@ -0,0 +1 @@ +ALTER TABLE reference_items DROP COLUMN rating; diff --git a/hub/migrations/0076_reference_rating.up.sql b/hub/migrations/0076_reference_rating.up.sql new file mode 100644 index 000000000..9136e6cd3 --- /dev/null +++ b/hub/migrations/0076_reference_rating.up.sql @@ -0,0 +1,5 @@ +-- A director-curated 1–5 score for prioritizing literature in the Read library. +-- NULL means the item has not been rated. + +ALTER TABLE reference_items + ADD COLUMN rating INTEGER CHECK (rating IS NULL OR rating BETWEEN 1 AND 5);