From 4e42b6787631b40f1962005002ba696c5b715bc2 Mon Sep 17 00:00:00 2001 From: agentfleet Date: Sun, 16 Aug 2026 23:46:55 +0800 Subject: [PATCH 1/4] feat(read): add discovery search workspace Co-Authored-By: Codex --- desktop/electron/e2e/app.spec.ts | 97 ++++++++ desktop/src/i18n/index.ts | 22 ++ desktop/src/state/discoveryHistory.ts | 123 ++++++++++ .../src/state/discoveryHistoryCore.test.ts | 48 ++++ desktop/src/state/discoveryHistoryCore.ts | 82 +++++++ desktop/src/state/discoverySearch.ts | 79 +++++- .../src/styles/partials/04-library-nav.css | 178 ++++++++++++++ desktop/src/surfaces/ReadSurface.tsx | 227 ++++++++++++++++-- 8 files changed, 834 insertions(+), 22 deletions(-) create mode 100644 desktop/src/state/discoveryHistory.ts create mode 100644 desktop/src/state/discoveryHistoryCore.test.ts create mode 100644 desktop/src/state/discoveryHistoryCore.ts diff --git a/desktop/electron/e2e/app.spec.ts b/desktop/electron/e2e/app.spec.ts index 1d6ddbaf..a1cf4d42 100644 --- a/desktop/electron/e2e/app.spec.ts +++ b/desktop/electron/e2e/app.spec.ts @@ -731,6 +731,103 @@ test('read: Cite keeps Scholar and OpenAlex provenance separate', async () => { } }); +test('read: Discovery uses contextual saved and recent search navigation', async () => { + await dismissConnectModal(); + const originals = await page.evaluate(() => { + const libraryKey = 'termipod.library.v1'; + const historyKey = 'termipod.discover.history.v1'; + const originalLibrary = localStorage.getItem(libraryKey); + const originalHistory = localStorage.getItem(historyKey); + localStorage.setItem(libraryKey, JSON.stringify({ + references: [{ + id: 'ref-discovery-rail', + type: 'article', + title: 'Library rail fixture', + authors: ['TermiPod'], + tags: ['library-only-tag'], + collectionIds: ['collection-only'], + notes: '', + addedAt: Date.now(), + dirty: false, + attachments: [], + }], + collections: [{ id: 'collection-only', name: 'Library-only collection' }], + })); + localStorage.setItem(historyKey, JSON.stringify({ + version: 1, + saved: [{ + id: 'saved-e2e', + name: 'Saved core query', + query: 'saved discovery query', + sourceId: 'core', + authorFilter: 'Researcher', + yearFrom: '2024', + yearTo: '', + sort: 'newest', + findPdfs: false, + savedAt: Date.now() - 2000, + }], + recent: [{ + id: 'recent-e2e', + query: 'recent discovery query', + sourceId: 'openalex', + authorFilter: '', + yearFrom: '', + yearTo: '', + sort: 'relevance', + findPdfs: true, + ranAt: Date.now() - 1000, + resultCount: 17, + }], + })); + return { originalLibrary, originalHistory }; + }); + + try { + await page.reload({ waitUntil: 'domcontentloaded' }); + await dismissConnectModal(); + await page.locator('[data-job="read"]').click(); + await page.getByRole('button', { name: 'Discover', exact: true }).click(); + + const rail = page.locator('.discover-nav'); + await expect(rail).toBeVisible(); + await expect(rail.getByText('Saved core query', { exact: true })).toBeVisible(); + await expect(rail.getByText('recent discovery query', { exact: true })).toBeVisible(); + await expect(rail.getByText('Recent searches', { exact: true })).toBeVisible(); + await expect(page.getByText('Library-only collection', { exact: true })).toHaveCount(0); + await expect(page.getByText('library-only-tag', { exact: true })).toHaveCount(0); + + await rail.locator('.discover-nav-row').filter({ hasText: 'Saved core query' }).locator('.discover-nav-main').click(); + await expect(page.locator('.discover-input')).toHaveValue('saved discovery query'); + await expect(page.locator('.discover-src.active')).toHaveText('CORE'); + await expect(page.getByRole('button', { name: 'Remove saved search', exact: true })).toBeVisible(); + + await page.locator('.discover-input').fill('a newly saved query'); + await page.locator('.discover-save-search').click(); + await expect(rail.getByText('a newly saved query', { exact: true })).toBeVisible(); + await expect(page.locator('.discover-save-search')).toHaveAttribute( + 'aria-label', + 'Remove saved search', + ); + await page.locator('.discover-save-search').click(); + await expect(rail.getByText('a newly saved query', { exact: true })).toHaveCount(0); + + await rail.getByRole('button', { name: 'Clear', exact: true }).click(); + await expect(rail.getByText('recent discovery query', { exact: true })).toHaveCount(0); + const persisted = await page.evaluate(() => JSON.parse(localStorage.getItem('termipod.discover.history.v1') ?? '{}')); + expect(persisted.recent).toEqual([]); + expect(persisted.saved).toHaveLength(1); + } finally { + await page.evaluate(({ originalLibrary, originalHistory }) => { + if (originalLibrary === null) localStorage.removeItem('termipod.library.v1'); + else localStorage.setItem('termipod.library.v1', originalLibrary); + if (originalHistory === null) localStorage.removeItem('termipod.discover.history.v1'); + else localStorage.setItem('termipod.discover.history.v1', originalHistory); + }, originals); + 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 }) => { diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index ef2e617d..6c59e819 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -1458,6 +1458,17 @@ const en: Dict = { 'read.copy': 'Copy', // discover 'read.discoverPlaceholder': 'Search papers — e.g. "chain-of-thought reasoning"', + 'read.discoverExplore': 'Explore', + 'read.savedSearches': 'Saved searches', + 'read.savedSearchesEmpty': 'Save a useful query to keep it here.', + 'read.recentSearches': 'Recent searches', + 'read.recentSearchesEmpty': 'Successful searches will appear here.', + 'read.saveSearch': 'Save this search', + 'read.searchSaved': 'Search saved', + 'read.removeSavedSearch': 'Remove saved search', + 'read.removeSearch': 'Remove from this list', + 'read.clearSearchHistory': 'Clear', + 'read.searchHistoryPrivacy': 'Recent searches stay on this device.', 'read.discoverSource': 'Semantic Scholar — 200M+ papers, TLDR summaries', 'read.search': 'Search', 'read.searching': 'Searching…', @@ -3745,6 +3756,17 @@ const zh: Dict = { 'read.scrapeFailed': '数据丰富失败 — 数据源可能繁忙,请重试。', 'read.copy': '复制', 'read.discoverPlaceholder': '搜索论文 — 例如「chain-of-thought reasoning」', + 'read.discoverExplore': '探索', + 'read.savedSearches': '已保存搜索', + 'read.savedSearchesEmpty': '保存有用的查询后会显示在这里。', + 'read.recentSearches': '最近搜索', + 'read.recentSearchesEmpty': '成功的搜索会显示在这里。', + 'read.saveSearch': '保存此搜索', + 'read.searchSaved': '已保存搜索', + 'read.removeSavedSearch': '移除已保存搜索', + 'read.removeSearch': '从列表中移除', + 'read.clearSearchHistory': '清除', + 'read.searchHistoryPrivacy': '最近搜索仅保存在此设备。', 'read.discoverSource': 'Semantic Scholar — 2 亿+ 论文,TLDR 摘要', 'read.search': '搜索', 'read.searching': '搜索中…', diff --git a/desktop/src/state/discoveryHistory.ts b/desktop/src/state/discoveryHistory.ts new file mode 100644 index 00000000..030e564b --- /dev/null +++ b/desktop/src/state/discoveryHistory.ts @@ -0,0 +1,123 @@ +import { create } from 'zustand'; +import type { DiscoverySourceId } from '../discovery/types'; +import type { DiscoverySort } from './discoverySearch'; +import { + discoveryQueryKey, + upsertRecentSearch, + upsertSavedSearch, + type DiscoveryQuerySpec, + type RecentDiscoverySearch, + type SavedDiscoverySearch, +} from './discoveryHistoryCore'; + +const LS_KEY = 'termipod.discover.history.v1'; + +interface PersistedDiscoveryHistory { + version: 1; + recent: RecentDiscoverySearch[]; + saved: SavedDiscoverySearch[]; +} + +interface DiscoveryHistoryState { + recent: RecentDiscoverySearch[]; + saved: SavedDiscoverySearch[]; + record: (spec: DiscoveryQuerySpec, resultCount: number) => void; + save: (spec: DiscoveryQuerySpec, name?: string) => string; + removeRecent: (id: string) => void; + removeSaved: (id: string) => void; + clearRecent: () => void; +} + +const SOURCES = new Set([ + 'openalex', + 'semanticscholar', + 'google-scholar', + 'crossref', + 'arxiv', + 'pubmed', + 'core', +]); +const SORTS = new Set(['relevance', 'newest', 'oldest', 'citations', 'title']); + +function isSpec(value: unknown): value is DiscoveryQuerySpec { + if (value === null || typeof value !== 'object') return false; + const row = value as Record; + return ( + typeof row.query === 'string' && + typeof row.sourceId === 'string' && + SOURCES.has(row.sourceId as DiscoverySourceId) && + typeof row.authorFilter === 'string' && + typeof row.yearFrom === 'string' && + typeof row.yearTo === 'string' && + typeof row.sort === 'string' && + SORTS.has(row.sort as DiscoverySort) && + typeof row.findPdfs === 'boolean' + ); +} + +function load(): Pick { + try { + const parsed = JSON.parse(localStorage.getItem(LS_KEY) ?? '') as Partial; + const recent = Array.isArray(parsed.recent) + ? parsed.recent.filter( + (entry): entry is RecentDiscoverySearch => + isSpec(entry) && typeof entry.id === 'string' && typeof entry.ranAt === 'number' && typeof entry.resultCount === 'number', + ) + : []; + const saved = Array.isArray(parsed.saved) + ? parsed.saved.filter( + (entry): entry is SavedDiscoverySearch => + isSpec(entry) && typeof entry.id === 'string' && typeof entry.name === 'string' && typeof entry.savedAt === 'number', + ) + : []; + return { recent: recent.slice(0, 20), saved }; + } catch { + return { recent: [], saved: [] }; + } +} + +function persist(recent: RecentDiscoverySearch[], saved: SavedDiscoverySearch[]): void { + try { + const payload: PersistedDiscoveryHistory = { version: 1, recent, saved }; + localStorage.setItem(LS_KEY, JSON.stringify(payload)); + } catch (error) { + console.error(`[discovery] failed to persist "${LS_KEY}"`, error); + } +} + +function id(prefix: string): string { + return `${prefix}${crypto.randomUUID()}`; +} + +export { discoveryQueryKey }; +export type { DiscoveryQuerySpec, RecentDiscoverySearch, SavedDiscoverySearch }; + +export const useDiscoveryHistory = create((set, get) => ({ + ...load(), + record: (spec, resultCount) => { + const recent = upsertRecentSearch(get().recent, spec, resultCount, Date.now(), id('search')); + set({ recent }); + persist(recent, get().saved); + }, + save: (spec, name) => { + const before = get().saved; + const saved = upsertSavedSearch(before, spec, Date.now(), id('saved-search'), name); + set({ saved }); + persist(get().recent, saved); + return saved.find((entry) => discoveryQueryKey(entry) === discoveryQueryKey(spec))?.id ?? ''; + }, + removeRecent: (searchId) => { + const recent = get().recent.filter((entry) => entry.id !== searchId); + set({ recent }); + persist(recent, get().saved); + }, + removeSaved: (searchId) => { + const saved = get().saved.filter((entry) => entry.id !== searchId); + set({ saved }); + persist(get().recent, saved); + }, + clearRecent: () => { + set({ recent: [] }); + persist([], get().saved); + }, +})); diff --git a/desktop/src/state/discoveryHistoryCore.test.ts b/desktop/src/state/discoveryHistoryCore.test.ts new file mode 100644 index 00000000..4ce46ad4 --- /dev/null +++ b/desktop/src/state/discoveryHistoryCore.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + discoveryQueryKey, + MAX_RECENT_SEARCHES, + upsertRecentSearch, + upsertSavedSearch, + type DiscoveryQuerySpec, +} from './discoveryHistoryCore.ts'; + +const base: DiscoveryQuerySpec = { + query: 'graph learning', + sourceId: 'openalex', + authorFilter: '', + yearFrom: '', + yearTo: '', + sort: 'relevance', + findPdfs: true, +}; + +test('query identity includes provider and filters but normalizes whitespace and case', () => { + assert.equal(discoveryQueryKey(base), discoveryQueryKey({ ...base, query: ' Graph Learning ' })); + assert.notEqual(discoveryQueryKey(base), discoveryQueryKey({ ...base, sourceId: 'google-scholar' })); + assert.notEqual(discoveryQueryKey(base), discoveryQueryKey({ ...base, yearFrom: '2024' })); +}); + +test('recent searches deduplicate, move to the top, and stay bounded', () => { + let recent = upsertRecentSearch([], base, 4, 10, 'one'); + recent = upsertRecentSearch(recent, { ...base, query: ' Graph Learning ' }, 9, 20, 'two'); + assert.equal(recent.length, 1); + assert.equal(recent[0]?.id, 'one'); + assert.equal(recent[0]?.resultCount, 9); + assert.equal(recent[0]?.ranAt, 20); + for (let i = 0; i < MAX_RECENT_SEARCHES + 5; i += 1) { + recent = upsertRecentSearch(recent, { ...base, query: `query ${i}` }, i, 30 + i, `id-${i}`); + } + assert.equal(recent.length, MAX_RECENT_SEARCHES); + assert.equal(recent[0]?.query, `query ${MAX_RECENT_SEARCHES + 4}`); +}); + +test('saving the same search is idempotent and preserves its chosen name', () => { + let saved = upsertSavedSearch([], base, 10, 'saved-one', 'My topic'); + saved = upsertSavedSearch(saved, { ...base, query: 'GRAPH LEARNING' }, 20, 'saved-two'); + assert.equal(saved.length, 1); + assert.equal(saved[0]?.id, 'saved-one'); + assert.equal(saved[0]?.name, 'My topic'); + assert.equal(saved[0]?.savedAt, 10); +}); diff --git a/desktop/src/state/discoveryHistoryCore.ts b/desktop/src/state/discoveryHistoryCore.ts new file mode 100644 index 00000000..f7823db1 --- /dev/null +++ b/desktop/src/state/discoveryHistoryCore.ts @@ -0,0 +1,82 @@ +import type { DiscoverySourceId } from '../discovery/types.ts'; +import type { DiscoverySort } from './discoverySearch.ts'; + +export interface DiscoveryQuerySpec { + query: string; + sourceId: DiscoverySourceId; + authorFilter: string; + yearFrom: string; + yearTo: string; + sort: DiscoverySort; + findPdfs: boolean; +} + +export interface RecentDiscoverySearch extends DiscoveryQuerySpec { + id: string; + ranAt: number; + resultCount: number; +} + +export interface SavedDiscoverySearch extends DiscoveryQuerySpec { + id: string; + name: string; + savedAt: number; +} + +export const MAX_RECENT_SEARCHES = 20; + +export function normalizeDiscoveryQuery(query: string): string { + return query.trim().replace(/\s+/g, ' '); +} + +export function discoveryQueryKey(spec: DiscoveryQuerySpec): string { + return JSON.stringify([ + normalizeDiscoveryQuery(spec.query).toLocaleLowerCase(), + spec.sourceId, + spec.authorFilter.trim().toLocaleLowerCase(), + spec.yearFrom.trim(), + spec.yearTo.trim(), + spec.sort, + spec.findPdfs, + ]); +} + +export function upsertRecentSearch( + existing: RecentDiscoverySearch[], + spec: DiscoveryQuerySpec, + resultCount: number, + ranAt: number, + id: string, +): RecentDiscoverySearch[] { + const normalized = { ...spec, query: normalizeDiscoveryQuery(spec.query) }; + const key = discoveryQueryKey(normalized); + const previous = existing.find((entry) => discoveryQueryKey(entry) === key); + const entry: RecentDiscoverySearch = { + ...normalized, + id: previous?.id ?? id, + ranAt, + resultCount: Math.max(0, Math.trunc(resultCount)), + }; + return [entry, ...existing.filter((item) => discoveryQueryKey(item) !== key)] + .sort((a, b) => b.ranAt - a.ranAt) + .slice(0, MAX_RECENT_SEARCHES); +} + +export function upsertSavedSearch( + existing: SavedDiscoverySearch[], + spec: DiscoveryQuerySpec, + savedAt: number, + id: string, + name?: string, +): SavedDiscoverySearch[] { + const normalized = { ...spec, query: normalizeDiscoveryQuery(spec.query) }; + const key = discoveryQueryKey(normalized); + const previous = existing.find((entry) => discoveryQueryKey(entry) === key); + const entry: SavedDiscoverySearch = { + ...normalized, + id: previous?.id ?? id, + name: name?.trim() || previous?.name || normalized.query, + savedAt: previous?.savedAt ?? savedAt, + }; + return [entry, ...existing.filter((item) => discoveryQueryKey(item) !== key)].sort((a, b) => b.savedAt - a.savedAt); +} diff --git a/desktop/src/state/discoverySearch.ts b/desktop/src/state/discoverySearch.ts index fcd05df3..43977b52 100644 --- a/desktop/src/state/discoverySearch.ts +++ b/desktop/src/state/discoverySearch.ts @@ -1,13 +1,13 @@ import { create } from 'zustand'; -import type { DiscoveryPaper } from '../discovery'; +import type { DiscoveryPaper, DiscoverySourceId } from '../discovery'; +import type { DiscoveryQuerySpec } from './discoveryHistoryCore'; /// The Read surface's Discover pane state, lifted out of the component so it /// survives an unmount — the pane unmounts when the user switches to the Library /// mode or opens a reader/web tab, and a fresh mount would otherwise drop the -/// last search. In-memory only (results can be large; this is session state, not -/// something to persist to disk): the query + its results come back exactly as -/// left when the pane remounts. `sourceId` keeps its own localStorage -/// persistence in the component; the transient bits (busy/error/showKey) reset. +/// last search. Results remain in-memory because they can be large, while the +/// source and PDF-enrichment preference are small device-local preferences. +/// Busy/error/key-prompt state remains transient in the component. interface DiscoverySearchState { query: string; @@ -16,17 +16,60 @@ interface DiscoverySearchState { yearFrom: string; yearTo: string; sort: DiscoverySort; + sourceId: DiscoverySourceId; + findPdfs: boolean; + runRequest: number; setQuery: (q: string) => void; setResults: (r: DiscoveryPaper[]) => void; setAuthorFilter: (author: string) => void; setYearFrom: (year: string) => void; setYearTo: (year: string) => void; setSort: (sort: DiscoverySort) => void; + setSourceId: (source: DiscoverySourceId) => void; + setFindPdfs: (find: boolean) => void; + restoreAndRun: (spec: DiscoveryQuerySpec) => void; clearFilters: () => void; } export type DiscoverySort = 'relevance' | 'newest' | 'oldest' | 'citations' | 'title'; +const SOURCE_LS = 'termipod.discover.source'; +const FIND_PDFS_LS = 'termipod.discover.findPdfs'; +const SOURCES = new Set([ + 'openalex', + 'semanticscholar', + 'google-scholar', + 'crossref', + 'arxiv', + 'pubmed', + 'core', +]); + +function loadSource(): DiscoverySourceId { + try { + const value = localStorage.getItem(SOURCE_LS); + return value !== null && SOURCES.has(value as DiscoverySourceId) ? (value as DiscoverySourceId) : 'openalex'; + } catch { + return 'openalex'; + } +} + +function loadFindPdfs(): boolean { + try { + return localStorage.getItem(FIND_PDFS_LS) !== 'false'; + } catch { + return true; + } +} + +function persist(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + /* device-local preference; ignore storage denial */ + } +} + export const useDiscoverySearch = create((set) => ({ query: '', results: [], @@ -34,11 +77,37 @@ export const useDiscoverySearch = create((set) => ({ yearFrom: '', yearTo: '', sort: 'relevance', + sourceId: loadSource(), + findPdfs: loadFindPdfs(), + runRequest: 0, setQuery: (q) => set({ query: q }), setResults: (r) => set({ results: r }), setAuthorFilter: (author) => set({ authorFilter: author }), setYearFrom: (year) => set({ yearFrom: year }), setYearTo: (year) => set({ yearTo: year }), setSort: (sort) => set({ sort }), + setSourceId: (sourceId) => { + set({ sourceId }); + persist(SOURCE_LS, sourceId); + }, + setFindPdfs: (findPdfs) => { + set({ findPdfs }); + persist(FIND_PDFS_LS, String(findPdfs)); + }, + restoreAndRun: (spec) => { + set((state) => ({ + query: spec.query, + authorFilter: spec.authorFilter, + yearFrom: spec.yearFrom, + yearTo: spec.yearTo, + sort: spec.sort, + sourceId: spec.sourceId, + findPdfs: spec.findPdfs, + results: [], + runRequest: state.runRequest + 1, + })); + persist(SOURCE_LS, spec.sourceId); + persist(FIND_PDFS_LS, String(spec.findPdfs)); + }, clearFilters: () => set({ authorFilter: '', yearFrom: '', yearTo: '' }), })); diff --git a/desktop/src/styles/partials/04-library-nav.css b/desktop/src/styles/partials/04-library-nav.css index f2210dfc..6bca6e5e 100644 --- a/desktop/src/styles/partials/04-library-nav.css +++ b/desktop/src/styles/partials/04-library-nav.css @@ -158,6 +158,174 @@ border-color: var(--accent-line); } +/* Discovery owns a contextual navigator. Library collections/tags are useful + destinations while browsing the library, but become unrelated chrome around + a literature search; recents and explicitly saved searches replace them. */ +.discover-nav { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; + color: var(--text-secondary); +} +.discover-nav-primary { + flex: 0 0 auto; + padding: var(--spacing-s8); + border-bottom: 1px solid var(--border); +} +.discover-nav-destination { + display: flex; + align-items: center; + gap: var(--spacing-s8); + width: 100%; + padding: var(--spacing-s8) var(--spacing-s10); + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + text-align: left; +} +.discover-nav-destination.active { + background: var(--accent-tint); + color: var(--text); + font-weight: 600; +} +.discover-nav-section { + flex: 0 1 auto; + min-height: 72px; + max-height: 42%; + overflow-y: auto; + padding-bottom: var(--spacing-s8); + border-bottom: 1px solid var(--border); +} +.discover-nav-section.grow { + flex: 1 1 auto; + max-height: none; +} +.discover-nav-heading { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-s8); + min-height: 30px; + padding: var(--spacing-s8) var(--spacing-s12) var(--spacing-s4); + background: var(--glass-pane); + color: var(--text-muted); + font-size: var(--font-size-label); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.discover-nav-clear { + padding: 0; + border: 0; + background: transparent; + color: var(--text-muted); + font-size: 10px; + letter-spacing: normal; + text-transform: none; +} +.discover-nav-clear:hover { + color: var(--text); + background: transparent; +} +.discover-nav-row { + display: flex; + align-items: center; + min-width: 0; + padding: 0 var(--spacing-s4) 0 var(--spacing-s8); + border-left: 2px solid transparent; +} +.discover-nav-row:hover { + background: var(--hover); +} +.discover-nav-row:focus-within { + border-left-color: var(--accent); + background: var(--hover); +} +.discover-nav-main { + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-items: flex-start; + min-width: 0; + padding: var(--spacing-s6) var(--spacing-s4); + border: 0; + background: transparent; + color: var(--text-secondary); + text-align: left; +} +.discover-nav-query { + display: block; + width: 100%; + overflow: hidden; + color: var(--text); + font-size: var(--font-size-body-small); + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} +.discover-nav-meta { + display: block; + width: 100%; + overflow: hidden; + color: var(--text-muted); + font-size: 10px; + font-variant-numeric: tabular-nums; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} +.discover-nav-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + opacity: 0; +} +.discover-nav-row:hover .discover-nav-actions, +.discover-nav-row:focus-within .discover-nav-actions { + opacity: 1; +} +.discover-nav-action { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + background: transparent; + color: var(--text-muted); +} +.discover-nav-action:hover:not(:disabled) { + background: var(--raised); + color: var(--text); +} +.discover-nav-action.active { + color: var(--warning, var(--accent)); +} +.discover-nav-action:disabled { + opacity: 1; +} +.discover-nav-empty { + padding: var(--spacing-s4) var(--spacing-s12) var(--spacing-s8); + color: var(--text-muted); + font-size: var(--font-size-label); + line-height: 1.4; +} +.discover-nav-privacy { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--spacing-s6); + padding: var(--spacing-s8) var(--spacing-s12); + color: var(--text-muted); + font-size: 10px; +} + /* --- items list --- */ .read-list-col { flex: 1; @@ -724,6 +892,16 @@ min-height: var(--control-md); padding-block: 0; } +.discover-save-search { + flex: 0 0 var(--control-md); + width: var(--control-md); + padding: 0; + color: var(--text-muted); +} +.discover-save-search.active { + color: var(--warning, var(--accent)); + background: var(--raised); +} .discover-source { padding: 0 var(--spacing-s8) var(--spacing-s8); border-bottom: 1px solid var(--border); diff --git a/desktop/src/surfaces/ReadSurface.tsx b/desktop/src/surfaces/ReadSurface.tsx index 79967fcc..90924726 100644 --- a/desktop/src/surfaces/ReadSurface.tsx +++ b/desktop/src/surfaces/ReadSurface.tsx @@ -34,6 +34,13 @@ import { } from '../state/attachments'; import { syncLibrary } from '../state/librarySync'; import { useDiscoverySearch, type DiscoverySort } from '../state/discoverySearch'; +import { + discoveryQueryKey, + useDiscoveryHistory, + type DiscoveryQuerySpec, + type RecentDiscoverySearch, + type SavedDiscoverySearch, +} from '../state/discoveryHistory'; import { syncAnnotations } from '../state/annotationSync'; import { useReadTabs } from '../state/readTabs'; import { useSession } from '../state/session'; @@ -49,6 +56,7 @@ import { SOURCES, sourceById, type DiscoveryPaper, + type DiscoverySourceId, type ScrapePatch, type ScrapeSeed, } from '../discovery'; @@ -1979,7 +1987,146 @@ function ReaderView({ // ---- Discover -------------------------------------------------------------- -const SOURCE_LS = 'termipod.discover.source'; +function discoverySourceLabel(id: DiscoverySourceId): string { + return SOURCES.find((source) => source.id === id)?.label ?? id; +} + +function discoverySearchDate(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +function DiscoverySearchRow({ + entry, + saved, + onRun, + onSave, + onRemove, +}: { + entry: RecentDiscoverySearch | SavedDiscoverySearch; + saved: boolean; + onRun: () => void; + onSave?: () => void; + onRemove: () => void; +}): JSX.Element { + const t = useT(); + const isRecent = 'ranAt' in entry; + const label = 'name' in entry ? entry.name : entry.query; + return ( +
+ + + {onSave !== undefined && ( + + )} + + +
+ ); +} + +function DiscoveryRail(): JSX.Element { + const t = useT(); + const recent = useDiscoveryHistory((state) => state.recent); + const saved = useDiscoveryHistory((state) => state.saved); + const save = useDiscoveryHistory((state) => state.save); + const removeRecent = useDiscoveryHistory((state) => state.removeRecent); + const removeSaved = useDiscoveryHistory((state) => state.removeSaved); + const clearRecent = useDiscoveryHistory((state) => state.clearRecent); + const restoreAndRun = useDiscoverySearch((state) => state.restoreAndRun); + const savedKeys = useMemo(() => new Set(saved.map(discoveryQueryKey)), [saved]); + return ( +
+
+ +
+ +
+
+ {t('read.savedSearches')} + {saved.length} +
+ {saved.length === 0 ? ( +
{t('read.savedSearchesEmpty')}
+ ) : ( + saved.map((entry) => ( + restoreAndRun(entry)} + onRemove={() => removeSaved(entry.id)} + /> + )) + )} +
+ +
+
+ {t('read.recentSearches')} + {recent.length > 0 && ( + + )} +
+ {recent.length === 0 ? ( +
{t('read.recentSearchesEmpty')}
+ ) : ( + recent.map((entry) => { + const isSaved = savedKeys.has(discoveryQueryKey(entry)); + return ( + restoreAndRun(entry)} + onSave={() => save(entry)} + onRemove={() => removeRecent(entry.id)} + /> + ); + }) + )} +
+ +
+ + {t('read.searchHistoryPrivacy')} +
+
+ ); +} function DiscoverPanel({ selectedId, @@ -2010,11 +2157,14 @@ function DiscoverPanel({ const setYearTo = useDiscoverySearch((s) => s.setYearTo); const setResultSort = useDiscoverySearch((s) => s.setSort); const clearFilters = useDiscoverySearch((s) => s.clearFilters); + const sourceId = useDiscoverySearch((s) => s.sourceId); + const setSourceId = useDiscoverySearch((s) => s.setSourceId); + const findPdfs = useDiscoverySearch((s) => s.findPdfs); + const setFindPdfs = useDiscoverySearch((s) => s.setFindPdfs); + const runRequest = useDiscoverySearch((s) => s.runRequest); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); - const [sourceId, setSourceId] = useState(() => localStorage.getItem(SOURCE_LS) ?? 'openalex'); const [showKey, setShowKey] = useState(false); - const [findPdfs, setFindPdfs] = useState(true); // When the query is a bare DOI / arXiv id / OpenAlex id, offer a direct // scrape-and-add instead of a keyword search. const idSeed = useMemo(() => detectIdentifier(q), [q]); @@ -2024,9 +2174,20 @@ function DiscoverPanel({ const [pdfErr, setPdfErr] = useState<{ id: string; msg: string } | null>(null); const [filtersOpen, setFiltersOpen] = useState(false); const filtersAnchorRef = useRef(null); + const handledRunRequest = useRef(0); + const savedSearches = useDiscoveryHistory((state) => state.saved); + const saveSearch = useDiscoveryHistory((state) => state.save); + const removeSavedSearch = useDiscoveryHistory((state) => state.removeSaved); + const recordSearch = useDiscoveryHistory((state) => state.record); const source = sourceById(sourceId); const [key, setKey] = useState(() => (source.keyKey !== undefined ? lsGet(source.keyKey) : '')); + useEffect(() => { + const nextSource = sourceById(sourceId); + setKey(nextSource.keyKey !== undefined ? lsGet(nextSource.keyKey) : ''); + setShowKey(false); + setErr(null); + }, [sourceId]); const importedByExternalId = useMemo( () => @@ -2060,35 +2221,43 @@ function DiscoverPanel({ }); }, [authorFilter, resultSort, results, yearFrom, yearTo]); const activeFilterCount = Number(authorFilter.trim() !== '') + Number(yearFrom !== '') + Number(yearTo !== ''); + const currentSpec: DiscoveryQuerySpec = { + query: q, + sourceId, + authorFilter, + yearFrom, + yearTo, + sort: resultSort, + findPdfs, + }; + const currentSaved = + q.trim() === '' ? undefined : savedSearches.find((entry) => discoveryQueryKey(entry) === discoveryQueryKey(currentSpec)); - function pickSource(id: string): void { + function pickSource(id: DiscoverySourceId): void { setSourceId(id); - try { - localStorage.setItem(SOURCE_LS, id); - } catch { - /* ignore */ - } - const s = sourceById(id); - setKey(s.keyKey !== undefined ? lsGet(s.keyKey) : ''); - setShowKey(false); - setErr(null); } async function run(): Promise { if (q.trim() === '') return; + const spec = { ...currentSpec, query: q.trim() }; + const selectedSource = sourceById(spec.sourceId); setBusy(true); setErr(null); try { - let res: DiscoveryPaper[] = (await source.search(q, 25)).map((paper) => ({ ...paper, source: source.id })); + let res: DiscoveryPaper[] = (await selectedSource.search(spec.query, 25)).map((paper) => ({ + ...paper, + source: selectedSource.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); + if (spec.findPdfs) res = await enrichWithUnpaywall(res); setResults(res); + recordSearch(spec, res.length); } catch (e) { const msg = e instanceof Error ? e.message : ''; if (msg === 'needs-key') { - setShowKey(source.keyManagedInVault !== true); - setErr(source.keyManagedInVault === true ? t('read.needsVaultKey') : t('read.needsKey')); + setShowKey(selectedSource.keyManagedInVault !== true); + setErr(selectedSource.keyManagedInVault === true ? t('read.needsVaultKey') : t('read.needsKey')); } else if (msg === 'serpapi-shell-required') { setErr(t('read.serpApiDesktopOnly')); } else { @@ -2100,6 +2269,12 @@ function DiscoverPanel({ } } + useEffect(() => { + if (runRequest === 0 || handledRunRequest.current === runRequest) return; + handledRunRequest.current = runRequest; + void run(); + }, [runRequest]); + function importPaper(p: DiscoveryPaper): void { // Discovery is a batch workflow: save the result in place and let the card // update to “In library” without discarding the search or scroll position. @@ -2186,6 +2361,18 @@ function DiscoverPanel({ placeholder={t('read.discoverPlaceholder')} autoFocus /> + @@ -3206,6 +3393,10 @@ export function ReadSurface(): JSX.Element { {!railCollapsed ? ( <> Date: Mon, 17 Aug 2026 00:15:44 +0800 Subject: [PATCH 2/4] feat(read): add discovery monitoring workspace Co-Authored-By: Codex --- desktop/electron/e2e/app.spec.ts | 75 ++++ desktop/electron/src/ipc/discovery.test.ts | 60 +++ desktop/electron/src/ipc/discovery.ts | 75 ++++ desktop/src/App.tsx | 3 + desktop/src/discovery/index.ts | 3 +- desktop/src/discovery/openAlex.ts | 22 + desktop/src/discovery/rss.ts | 106 +++++ desktop/src/i18n/index.ts | 108 +++++ desktop/src/state/discoveryHistory.ts | 15 +- .../src/state/discoveryHistoryCore.test.ts | 2 + desktop/src/state/discoveryHistoryCore.ts | 3 + desktop/src/state/discoveryMonitor.ts | 332 +++++++++++++++ .../src/state/discoveryMonitorCore.test.ts | 44 ++ desktop/src/state/discoveryMonitorCore.ts | 144 +++++++ .../src/styles/partials/04-library-nav.css | 268 +++++++++++- desktop/src/surfaces/DiscoveryWorkspace.tsx | 398 ++++++++++++++++++ desktop/src/surfaces/ReadSurface.tsx | 70 ++- 17 files changed, 1718 insertions(+), 10 deletions(-) create mode 100644 desktop/src/discovery/rss.ts create mode 100644 desktop/src/state/discoveryMonitor.ts create mode 100644 desktop/src/state/discoveryMonitorCore.test.ts create mode 100644 desktop/src/state/discoveryMonitorCore.ts create mode 100644 desktop/src/surfaces/DiscoveryWorkspace.tsx diff --git a/desktop/electron/e2e/app.spec.ts b/desktop/electron/e2e/app.spec.ts index a1cf4d42..8a00cc93 100644 --- a/desktop/electron/e2e/app.spec.ts +++ b/desktop/electron/e2e/app.spec.ts @@ -828,6 +828,81 @@ test('read: Discovery uses contextual saved and recent search navigation', async } }); +test('read: Discovery monitoring exposes updates, subscriptions, schedules, and collection recommendations', async () => { + await dismissConnectModal(); + const originals = await page.evaluate(() => { + const keys = ['termipod.library.v1', 'termipod.discover.history.v1', 'termipod.discover.monitor.v1']; + const values = Object.fromEntries(keys.map((key) => [key, localStorage.getItem(key)])); + localStorage.setItem('termipod.library.v1', JSON.stringify({ + collections: [{ id: 'collection-monitor', name: 'Graph research' }], + references: [{ + id: 'reference-monitor', type: 'article', title: 'Graph learning for molecules', authors: ['Ada Researcher'], + venue: 'Graph Journal', rating: 5, topics: ['Graph learning'], tags: ['molecules'], + collectionIds: ['collection-monitor'], notes: '', addedAt: Date.now(), dirty: false, attachments: [], + }], + })); + localStorage.setItem('termipod.discover.history.v1', JSON.stringify({ + version: 1, + recent: [], + saved: [{ + id: 'saved-monitor', name: 'Saved graph query', query: 'graph learning', sourceId: 'openalex', + authorFilter: '', yearFrom: '', yearTo: '', sort: 'newest', findPdfs: false, savedAt: Date.now(), + }], + })); + localStorage.setItem('termipod.discover.monitor.v1', JSON.stringify({ + version: 1, + subscriptions: [{ + id: 'subscription-monitor', kind: 'topic', label: 'Graph learning', value: 'graph learning', + sourceId: 'openalex', cadence: 'weekly', createdAt: Date.now(), + }], + updates: [{ + id: 'update-monitor', originType: 'subscription', originId: 'subscription-monitor', + originLabel: 'Graph learning', arrivedAt: Date.now(), + paper: { paperId: 'paper-monitor', title: 'A new graph paper', authors: ['A. Author'], year: 2026, venue: 'Graph Journal' }, + }], + runs: { + 'subscription:subscription-monitor': { lastRunAt: Date.now(), seen: ['id:paper-monitor'] }, + }, + lastRefreshAt: Date.now(), + })); + return values; + }); + + try { + await page.reload({ waitUntil: 'domcontentloaded' }); + await dismissConnectModal(); + await page.locator('[data-job="read"]').click(); + await page.getByRole('button', { name: 'Discover', exact: true }).click(); + + const rail = page.locator('.discover-nav'); + await expect(rail.getByRole('button', { name: /Updates/ })).toContainText('1'); + await rail.getByRole('button', { name: /Updates/ }).click(); + await expect(page.getByRole('heading', { name: 'Updates' })).toBeVisible(); + await expect(page.getByText('A new graph paper', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Mark read', exact: true }).click(); + await expect(rail.locator('.discover-nav-count')).toHaveCount(0); + + await rail.getByRole('button', { name: 'Subscriptions', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Scheduled saved searches' })).toBeVisible(); + await expect(page.getByText('Saved graph query', { exact: true })).toBeVisible(); + await expect(page.getByText('Graph learning', { exact: true })).toBeVisible(); + + await rail.getByRole('button', { name: 'For you', exact: true }).click(); + await page.getByLabel('Seed collection').selectOption('collection-monitor'); + await expect(page.getByText('Seeded by 1 collection items', { exact: true })).toBeVisible(); + await expect(page.locator('.discovery-recommend-explain')).toContainText('graph'); + await expect(page.locator('.discovery-recommend-explain')).toContainText('molecules'); + } finally { + await page.evaluate((values) => { + for (const [key, value] of Object.entries(values)) { + if (value === null) localStorage.removeItem(key); + else localStorage.setItem(key, value); + } + }, originals); + 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 }) => { diff --git a/desktop/electron/src/ipc/discovery.test.ts b/desktop/electron/src/ipc/discovery.test.ts index 72a609f9..28bb07f3 100644 --- a/desktop/electron/src/ipc/discovery.test.ts +++ b/desktop/electron/src/ipc/discovery.test.ts @@ -82,3 +82,63 @@ test('SerpAPI citations transport rejects malformed ids before network access', /valid cites id is required/, ); }); + +test('RSS transport accepts a public HTTP feed and returns bounded text', async () => { + const originalFetch = globalThis.fetch; + let requested = ''; + globalThis.fetch = (async (input) => { + requested = String(input); + return new Response('Research', { status: 200 }); + }) as typeof fetch; + try { + const out = await discoveryHandlers.discovery_fetch_feed( + { url: 'https://93.184.216.34/research.xml', proxy: null }, + {} as never, + ); + assert.equal(requested, 'https://93.184.216.34/research.xml'); + assert.deepEqual(out, { + url: 'https://93.184.216.34/research.xml', + text: 'Research', + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('RSS transport refuses loopback and private-network URLs before fetch', async () => { + let fetched = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + fetched = true; + return new Response('unexpected'); + }) as typeof fetch; + try { + for (const url of ['http://127.0.0.1/feed', 'http://10.0.0.2/rss', 'file:///tmp/feed.xml']) { + await assert.rejects( + async () => discoveryHandlers.discovery_fetch_feed({ url, proxy: null }, {} as never), + /not allowed|only HTTP/, + ); + } + assert.equal(fetched, false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('RSS transport revalidates redirects so a public feed cannot bounce into loopback', async () => { + const originalFetch = globalThis.fetch; + let requests = 0; + globalThis.fetch = (async () => { + requests += 1; + return new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/private-feed' } }); + }) as typeof fetch; + try { + await assert.rejects( + async () => discoveryHandlers.discovery_fetch_feed({ url: 'https://93.184.216.34/feed', proxy: null }, {} as never), + /private hosts are not allowed/, + ); + assert.equal(requests, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/desktop/electron/src/ipc/discovery.ts b/desktop/electron/src/ipc/discovery.ts index ebb7ff2c..90990bb8 100644 --- a/desktop/electron/src/ipc/discovery.ts +++ b/desktop/electron/src/ipc/discovery.ts @@ -5,9 +5,70 @@ import { serpApiCitationsUrl, serpApiSearchUrl } from '../../../src/discovery/serpApiCore.ts'; import type { Handler } from './dispatch'; import { proxyFetch } from './net.ts'; +import { isIP } from 'node:net'; +import { lookup } from 'node:dns/promises'; const RESPONSE_CAP = 5 * 1024 * 1024; const TIMEOUT_MS = 45_000; +const FEED_RESPONSE_CAP = 3 * 1024 * 1024; + +function privateAddress(address: string): boolean { + const normalized = address.toLocaleLowerCase(); + if (normalized === '::1' || normalized === '::' || normalized.startsWith('fe80:') || normalized.startsWith('fc') || normalized.startsWith('fd')) return true; + const parts = normalized.split('.').map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + return parts[0] === 10 || + parts[0] === 127 || + parts[0] === 0 || + (parts[0] === 169 && parts[1] === 254) || + (parts[0] === 172 && (parts[1] ?? 0) >= 16 && (parts[1] ?? 0) <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 100 && (parts[1] ?? 0) >= 64 && (parts[1] ?? 0) <= 127) || + parts[0] >= 224; +} + +async function publicFeedUrl(raw: string): Promise { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error('discovery_fetch_feed: valid URL is required'); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('discovery_fetch_feed: only HTTP(S) is allowed'); + const hostname = url.hostname.toLocaleLowerCase(); + if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local')) { + throw new Error('discovery_fetch_feed: private hosts are not allowed'); + } + if (isIP(hostname) !== 0) { + if (privateAddress(hostname)) throw new Error('discovery_fetch_feed: private hosts are not allowed'); + } else { + const addresses = await lookup(hostname, { all: true }); + if (addresses.length === 0 || addresses.some(({ address }) => privateAddress(address))) { + throw new Error('discovery_fetch_feed: private hosts are not allowed'); + } + } + url.hash = ''; + return url.toString(); +} + +async function fetchPublicFeed(rawUrl: string, proxy: string | null): Promise<{ response: Response; url: string }> { + let url = rawUrl; + for (let redirects = 0; redirects <= 5; redirects += 1) { + url = await publicFeedUrl(url); + const response = await proxyFetch(url, { + method: 'GET', + headers: { Accept: 'application/atom+xml, application/rss+xml, application/xml, text/xml, */*;q=0.2' }, + redirect: 'manual', + signal: AbortSignal.timeout(TIMEOUT_MS), + }, proxy); + if (response.status < 300 || response.status >= 400) return { response, url }; + const location = response.headers.get('location'); + await response.body?.cancel().catch(() => undefined); + if (location === null) throw new Error('discovery_fetch_feed: redirect has no location'); + url = new URL(location, url).toString(); + } + throw new Error('discovery_fetch_feed: too many redirects'); +} async function fetchSerpApiJson(url: string, proxy: string | null, operation: string): Promise { const res = await proxyFetch( @@ -36,6 +97,20 @@ async function fetchSerpApiJson(url: string, proxy: string | null, operation: st } export const discoveryHandlers: Record = { + discovery_fetch_feed: async (args): Promise => { + const rawUrl = typeof args.url === 'string' ? args.url.trim() : ''; + const proxy = typeof args.proxy === 'string' && args.proxy !== '' ? args.proxy : null; + const { response: res, url } = await fetchPublicFeed(rawUrl, proxy); + const declared = Number(res.headers.get('content-length') ?? '0'); + if (Number.isFinite(declared) && declared > FEED_RESPONSE_CAP) { + await res.body?.cancel().catch(() => undefined); + throw new Error('discovery_fetch_feed: response exceeds 3 MB'); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + if (bytes.byteLength > FEED_RESPONSE_CAP) throw new Error('discovery_fetch_feed: response exceeds 3 MB'); + if (!res.ok) throw new Error(`discovery_fetch_feed: HTTP ${res.status}`); + return { url: res.url || url, text: new TextDecoder().decode(bytes) }; + }, serpapi_search: async (args): Promise => { const query = typeof args.query === 'string' ? args.query.trim() : ''; const apiKey = typeof args.apiKey === 'string' ? args.apiKey.trim() : ''; diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index d3c22dd0..36d596c4 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,5 +1,7 @@ +import { useEffect } from 'react'; import { useApplyAppearance } from './state/appearance'; import { useApplyTheme } from './state/theme'; +import { startDiscoveryScheduler } from './state/discoveryMonitor'; import { AppShell } from './ui/AppShell'; /// The shell renders always — even without a hub connection — so the terminal, @@ -9,5 +11,6 @@ import { AppShell } from './ui/AppShell'; export function App(): JSX.Element { useApplyTheme(); useApplyAppearance(); + useEffect(() => startDiscoveryScheduler(), []); return ; } diff --git a/desktop/src/discovery/index.ts b/desktop/src/discovery/index.ts index 29fcb0e2..30666778 100644 --- a/desktop/src/discovery/index.ts +++ b/desktop/src/discovery/index.ts @@ -1,7 +1,7 @@ import { searchArxiv } from './arxiv'; import { CORE_KEY, searchCore } from './core'; import { searchCrossref } from './crossref'; -import { searchOpenAlex } from './openAlex'; +import { loadOpenAlexCitations, searchOpenAlex } from './openAlex'; import { searchPubmed } from './pubmed'; import { loadGoogleScholarCitations, searchGoogleScholar } from './serpApi'; import { S2_KEY, searchSemanticScholar } from './semanticScholar'; @@ -20,6 +20,7 @@ export { enrichWithUnpaywall } from './unpaywall'; export { scrapeMetadata, detectIdentifier, type ScrapePatch, type ScrapeSeed } from './scrape'; export { isLikelySameWork } from './scrapeMatch'; export { loadGoogleScholarCitations, searchGoogleScholar }; +export { loadOpenAlexCitations }; /// The discovery source registry — the single source of truth the Read/Discover /// picker renders. OpenAlex is first (free, keyless, most generous → the default); diff --git a/desktop/src/discovery/openAlex.ts b/desktop/src/discovery/openAlex.ts index 63ddf922..b8344bdf 100644 --- a/desktop/src/discovery/openAlex.ts +++ b/desktop/src/discovery/openAlex.ts @@ -47,6 +47,28 @@ function mapWork(w: Record): DiscoveryPaper { }; } +async function resolveOpenAlexWorkId(seed: string): Promise { + const trimmed = seed.trim(); + const direct = trimmed.match(/(?:https?:\/\/openalex\.org\/)?(W\d+)/i)?.[1]; + if (direct !== undefined) return direct.toLocaleUpperCase(); + const doi = trimmed.replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, ''); + if (doi === '') throw new Error('citation-subscription-needs-id'); + const url = `https://api.openalex.org/works/${encodeURIComponent(`https://doi.org/${doi}`)}?mailto=${CONTACT}`; + const work = (await getJson(url)) as Record; + const id = typeof work.id === 'string' ? work.id.match(/W\d+$/i)?.[0] : undefined; + if (id === undefined) throw new Error('citation-subscription-not-found'); + return id.toLocaleUpperCase(); +} + +export async function loadOpenAlexCitations(seed: string, limit: number): Promise { + const workId = await resolveOpenAlexWorkId(seed); + const filter = encodeURIComponent(`cites:${workId}`); + const url = `https://api.openalex.org/works?filter=${filter}&sort=publication_date:desc&per-page=${limit}&mailto=${CONTACT}`; + const json = (await getJson(url)) as Record; + const results = Array.isArray(json.results) ? json.results : []; + return results.map((work) => mapWork(work as Record)).filter((paper) => paper.title !== ''); +} + export async function searchOpenAlex(query: string, limit: number): Promise { const url = `https://api.openalex.org/works?search=${encodeURIComponent(query)}&per-page=${limit}&mailto=${CONTACT}`; const j = (await getJson(url)) as Record; diff --git a/desktop/src/discovery/rss.ts b/desktop/src/discovery/rss.ts new file mode 100644 index 00000000..4d76d269 --- /dev/null +++ b/desktop/src/discovery/rss.ts @@ -0,0 +1,106 @@ +import { invoke, isShell } from '../bridge'; +import { proxyForConnection } from '../state/proxy'; +import type { DiscoveryPaper } from './types'; + +interface FeedPayload { + url: string; + text: string; +} + +export interface ParsedDiscoveryFeed { + title: string; + url: string; + papers: DiscoveryPaper[]; +} + +function child(element: Element, localName: string): Element | undefined { + return [...element.children].find((candidate) => candidate.localName.toLocaleLowerCase() === localName); +} + +function children(element: Element, localName: string): Element[] { + return [...element.children].filter((candidate) => candidate.localName.toLocaleLowerCase() === localName); +} + +function text(element: Element, ...names: string[]): string | undefined { + for (const name of names) { + const value = child(element, name)?.textContent?.replace(/\s+/g, ' ').trim(); + if (value !== undefined && value !== '') return value; + } + return undefined; +} + +function plainText(value: string | undefined): string | undefined { + if (value === undefined || value === '') return undefined; + const parsed = new DOMParser().parseFromString(value, 'text/html').body.textContent?.replace(/\s+/g, ' ').trim(); + return parsed === undefined || parsed === '' ? undefined : parsed; +} + +function year(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? new Date(timestamp).getUTCFullYear() : undefined; +} + +function atomLink(entry: Element): string | undefined { + const links = children(entry, 'link'); + const preferred = links.find((link) => (link.getAttribute('rel') ?? 'alternate') === 'alternate') ?? links[0]; + return preferred?.getAttribute('href') ?? preferred?.textContent?.trim() ?? undefined; +} + +function rssAuthors(entry: Element): string[] { + const values = [text(entry, 'author'), text(entry, 'creator')].filter((value): value is string => value !== undefined); + return values.flatMap((value) => value.split(/\s*[,;]\s*/)).filter(Boolean); +} + +function atomAuthors(entry: Element): string[] { + return children(entry, 'author') + .map((author) => text(author, 'name') ?? author.textContent?.trim() ?? '') + .filter(Boolean); +} + +export function parseDiscoveryFeed(xml: string, feedUrl: string): ParsedDiscoveryFeed { + const doc = new DOMParser().parseFromString(xml, 'application/xml'); + if (doc.querySelector('parsererror') !== null) throw new Error('Invalid RSS or Atom feed'); + const root = doc.documentElement; + const atom = root.localName.toLocaleLowerCase() === 'feed'; + const container = atom ? root : child(root, 'channel') ?? root; + const feedTitle = text(container, 'title') ?? new URL(feedUrl).hostname; + const entries = atom ? children(container, 'entry') : children(container, 'item'); + const papers = entries.map((entry): DiscoveryPaper | null => { + const title = text(entry, 'title') ?? ''; + if (title === '') return null; + const url = atom ? atomLink(entry) : text(entry, 'link'); + const nativeId = text(entry, 'id', 'guid') ?? url ?? title; + const published = text(entry, 'published', 'updated', 'pubdate', 'date'); + const summary = plainText(text(entry, 'summary', 'description', 'content')); + return { + paperId: `rss:${nativeId}`, + title, + authors: atom ? atomAuthors(entry) : rssAuthors(entry), + year: year(published), + venue: feedTitle, + abstract: summary, + url, + }; + }).filter((paper): paper is DiscoveryPaper => paper !== null); + return { title: feedTitle, url: feedUrl, papers }; +} + +export async function fetchDiscoveryFeed(url: string): Promise { + let payload: FeedPayload; + if (isShell()) { + payload = await invoke('discovery_fetch_feed', { + url, + proxy: proxyForConnection('discovery') ?? null, + }); + } else { + const response = await fetch(url, { + headers: { Accept: 'application/atom+xml, application/rss+xml, application/xml, text/xml' }, + signal: AbortSignal.timeout(45_000), + }); + if (!response.ok) throw new Error(`RSS: HTTP ${response.status}`); + payload = { url: response.url || url, text: await response.text() }; + } + return parseDiscoveryFeed(payload.text, payload.url); +} + diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index 6c59e819..a59eee54 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -1469,6 +1469,60 @@ const en: Dict = { 'read.removeSearch': 'Remove from this list', 'read.clearSearchHistory': 'Clear', 'read.searchHistoryPrivacy': 'Recent searches stay on this device.', + 'read.monitorUpdates': 'Updates', + 'read.monitorPrivacy': 'Monitoring data stays on this device.', + 'read.monitorUpdatesHint': 'New papers from scheduled searches and subscriptions.', + 'read.monitorForYou': 'For you', + 'read.monitorForYouHint': 'Recommendations seeded by a collection you curate.', + 'read.monitorSubscriptions': 'Subscriptions', + 'read.monitorSubscriptionsHint': 'Follow research entities and schedule saved searches.', + 'read.monitorRefresh': 'Check now', + 'read.monitorRefreshing': 'Checking…', + 'read.monitorMarkAllRead': 'Mark all read', + 'read.monitorAll': 'All', + 'read.monitorUnread': 'Unread', + 'read.monitorClearRead': 'Clear read', + 'read.monitorLastChecked': 'Last checked {time}', + 'read.monitorSomeFailed': '{n} sources could not be refreshed. Open Subscriptions for details.', + 'read.monitorNoUnread': 'You are caught up', + 'read.monitorNoUpdates': 'No updates yet', + 'read.monitorNoUpdatesHint': 'Schedule a saved search or add a subscription, then check for updates.', + 'read.monitorMarkRead': 'Mark read', + 'read.monitorOpen': 'Open', + 'read.monitorCitations': 'cited', + 'read.monitorCadence.daily': 'Daily', + 'read.monitorCadence.weekly': 'Weekly', + 'read.monitorCadence.monthly': 'Monthly', + 'read.monitorKind.author': 'Author', + 'read.monitorKind.journal': 'Journal', + 'read.monitorKind.topic': 'Topic', + 'read.monitorKind.citation': 'Citations', + 'read.monitorKind.rss': 'RSS', + 'read.monitorPlaceholder.author': 'Author name', + 'read.monitorPlaceholder.journal': 'Journal or conference', + 'read.monitorPlaceholder.topic': 'Research topic', + 'read.monitorPlaceholder.citation': 'Work', + 'read.monitorPlaceholder.rss': 'Feed URL', + 'read.monitorOptionalName': 'Name (optional)', + 'read.monitorProvider': 'Provider', + 'read.monitorSchedule': 'Schedule', + 'read.monitorSubscribe': 'Subscribe', + 'read.monitorCitationWork': 'Work to monitor', + 'read.monitorChooseWork': 'Choose a library work…', + 'read.monitorScheduledSearches': 'Scheduled saved searches', + 'read.monitorOff': 'Off', + 'read.monitorNoSubscriptions': 'No subscriptions yet.', + 'read.monitorRefreshOne': 'Refresh this subscription', + 'read.monitorUnsubscribe': 'Unsubscribe', + 'read.monitorRecommend': 'Find recommendations', + 'read.monitorSeedCollection': 'Seed collection', + 'read.monitorChooseCollection': 'Choose a collection…', + 'read.monitorChooseCollectionHint': 'For You learns from the papers, ratings, topics, and tags in one collection.', + 'read.monitorSeededBy': 'Seeded by {n} collection items', + 'read.monitorEmptyCollection': 'This collection is empty', + 'read.monitorEmptyCollectionHint': 'Add several relevant papers before asking for recommendations.', + 'read.monitorReadyRecommend': 'Ready to find related work', + 'read.monitorReadyRecommendHint': 'TermiPod uses the collection’s strongest topics, tags, venues, and rated titles as the query.', 'read.discoverSource': 'Semantic Scholar — 200M+ papers, TLDR summaries', 'read.search': 'Search', 'read.searching': 'Searching…', @@ -3767,6 +3821,60 @@ const zh: Dict = { 'read.removeSearch': '从列表中移除', 'read.clearSearchHistory': '清除', 'read.searchHistoryPrivacy': '最近搜索仅保存在此设备。', + 'read.monitorUpdates': '更新', + 'read.monitorPrivacy': '监测数据仅保存在此设备。', + 'read.monitorUpdatesHint': '来自定时搜索和订阅的新论文。', + 'read.monitorForYou': '为你推荐', + 'read.monitorForYouHint': '根据你维护的一个文集生成推荐。', + 'read.monitorSubscriptions': '订阅', + 'read.monitorSubscriptionsHint': '关注研究对象并为已保存搜索设置计划。', + 'read.monitorRefresh': '立即检查', + 'read.monitorRefreshing': '正在检查…', + 'read.monitorMarkAllRead': '全部标为已读', + 'read.monitorAll': '全部', + 'read.monitorUnread': '未读', + 'read.monitorClearRead': '清除已读', + 'read.monitorLastChecked': '上次检查 {time}', + 'read.monitorSomeFailed': '{n} 个来源无法刷新。请在订阅中查看详情。', + 'read.monitorNoUnread': '已全部读完', + 'read.monitorNoUpdates': '暂无更新', + 'read.monitorNoUpdatesHint': '为已保存搜索设置计划或添加订阅,然后检查更新。', + 'read.monitorMarkRead': '标为已读', + 'read.monitorOpen': '打开', + 'read.monitorCitations': '次引用', + 'read.monitorCadence.daily': '每天', + 'read.monitorCadence.weekly': '每周', + 'read.monitorCadence.monthly': '每月', + 'read.monitorKind.author': '作者', + 'read.monitorKind.journal': '期刊', + 'read.monitorKind.topic': '主题', + 'read.monitorKind.citation': '引用', + 'read.monitorKind.rss': 'RSS', + 'read.monitorPlaceholder.author': '作者姓名', + 'read.monitorPlaceholder.journal': '期刊或会议', + 'read.monitorPlaceholder.topic': '研究主题', + 'read.monitorPlaceholder.citation': '论文', + 'read.monitorPlaceholder.rss': '订阅源 URL', + 'read.monitorOptionalName': '名称(可选)', + 'read.monitorProvider': '数据源', + 'read.monitorSchedule': '计划', + 'read.monitorSubscribe': '订阅', + 'read.monitorCitationWork': '要监测的论文', + 'read.monitorChooseWork': '选择文库中的论文…', + 'read.monitorScheduledSearches': '定时的已保存搜索', + 'read.monitorOff': '关闭', + 'read.monitorNoSubscriptions': '暂无订阅。', + 'read.monitorRefreshOne': '刷新此订阅', + 'read.monitorUnsubscribe': '取消订阅', + 'read.monitorRecommend': '查找推荐', + 'read.monitorSeedCollection': '种子文集', + 'read.monitorChooseCollection': '选择文集…', + 'read.monitorChooseCollectionHint': '“为你推荐”会学习一个文集中的论文、评分、主题和标签。', + 'read.monitorSeededBy': '基于文集中的 {n} 篇文献', + 'read.monitorEmptyCollection': '此文集为空', + 'read.monitorEmptyCollectionHint': '先添加几篇相关论文,再获取推荐。', + 'read.monitorReadyRecommend': '可以查找相关工作', + 'read.monitorReadyRecommendHint': 'TermiPod 会使用文集中最相关的主题、标签、来源和高评分标题构造查询。', 'read.discoverSource': 'Semantic Scholar — 2 亿+ 论文,TLDR 摘要', 'read.search': '搜索', 'read.searching': '搜索中…', diff --git a/desktop/src/state/discoveryHistory.ts b/desktop/src/state/discoveryHistory.ts index 030e564b..7808b5de 100644 --- a/desktop/src/state/discoveryHistory.ts +++ b/desktop/src/state/discoveryHistory.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import type { DiscoverySourceId } from '../discovery/types'; import type { DiscoverySort } from './discoverySearch'; +import type { DiscoveryCadence } from './discoveryMonitorCore'; import { discoveryQueryKey, upsertRecentSearch, @@ -25,6 +26,7 @@ interface DiscoveryHistoryState { save: (spec: DiscoveryQuerySpec, name?: string) => string; removeRecent: (id: string) => void; removeSaved: (id: string) => void; + setSavedSchedule: (id: string, cadence: DiscoveryCadence | undefined) => void; clearRecent: () => void; } @@ -38,6 +40,7 @@ const SOURCES = new Set([ 'core', ]); const SORTS = new Set(['relevance', 'newest', 'oldest', 'citations', 'title']); +const CADENCES = new Set(['daily', 'weekly', 'monthly']); function isSpec(value: unknown): value is DiscoveryQuerySpec { if (value === null || typeof value !== 'object') return false; @@ -67,7 +70,12 @@ function load(): Pick { const saved = Array.isArray(parsed.saved) ? parsed.saved.filter( (entry): entry is SavedDiscoverySearch => - isSpec(entry) && typeof entry.id === 'string' && typeof entry.name === 'string' && typeof entry.savedAt === 'number', + isSpec(entry) && + typeof entry.id === 'string' && + typeof entry.name === 'string' && + typeof entry.savedAt === 'number' && + (entry.schedule === undefined || + (typeof entry.schedule === 'string' && CADENCES.has(entry.schedule as DiscoveryCadence))), ) : []; return { recent: recent.slice(0, 20), saved }; @@ -116,6 +124,11 @@ export const useDiscoveryHistory = create((set, get) => ( set({ saved }); persist(get().recent, saved); }, + setSavedSchedule: (searchId, schedule) => { + const saved = get().saved.map((entry) => (entry.id === searchId ? { ...entry, schedule } : entry)); + set({ saved }); + persist(get().recent, saved); + }, clearRecent: () => { set({ recent: [] }); persist([], get().saved); diff --git a/desktop/src/state/discoveryHistoryCore.test.ts b/desktop/src/state/discoveryHistoryCore.test.ts index 4ce46ad4..06a76f6c 100644 --- a/desktop/src/state/discoveryHistoryCore.test.ts +++ b/desktop/src/state/discoveryHistoryCore.test.ts @@ -40,9 +40,11 @@ test('recent searches deduplicate, move to the top, and stay bounded', () => { test('saving the same search is idempotent and preserves its chosen name', () => { let saved = upsertSavedSearch([], base, 10, 'saved-one', 'My topic'); + saved = [{ ...saved[0]!, schedule: 'weekly' }]; saved = upsertSavedSearch(saved, { ...base, query: 'GRAPH LEARNING' }, 20, 'saved-two'); assert.equal(saved.length, 1); assert.equal(saved[0]?.id, 'saved-one'); assert.equal(saved[0]?.name, 'My topic'); assert.equal(saved[0]?.savedAt, 10); + assert.equal(saved[0]?.schedule, 'weekly'); }); diff --git a/desktop/src/state/discoveryHistoryCore.ts b/desktop/src/state/discoveryHistoryCore.ts index f7823db1..6d0b6017 100644 --- a/desktop/src/state/discoveryHistoryCore.ts +++ b/desktop/src/state/discoveryHistoryCore.ts @@ -1,5 +1,6 @@ import type { DiscoverySourceId } from '../discovery/types.ts'; import type { DiscoverySort } from './discoverySearch.ts'; +import type { DiscoveryCadence } from './discoveryMonitorCore.ts'; export interface DiscoveryQuerySpec { query: string; @@ -21,6 +22,7 @@ export interface SavedDiscoverySearch extends DiscoveryQuerySpec { id: string; name: string; savedAt: number; + schedule?: DiscoveryCadence; } export const MAX_RECENT_SEARCHES = 20; @@ -77,6 +79,7 @@ export function upsertSavedSearch( id: previous?.id ?? id, name: name?.trim() || previous?.name || normalized.query, savedAt: previous?.savedAt ?? savedAt, + schedule: previous?.schedule, }; return [entry, ...existing.filter((item) => discoveryQueryKey(item) !== key)].sort((a, b) => b.savedAt - a.savedAt); } diff --git a/desktop/src/state/discoveryMonitor.ts b/desktop/src/state/discoveryMonitor.ts new file mode 100644 index 00000000..8aeed0c8 --- /dev/null +++ b/desktop/src/state/discoveryMonitor.ts @@ -0,0 +1,332 @@ +import { create } from 'zustand'; +import { + enrichWithUnpaywall, + loadGoogleScholarCitations, + loadOpenAlexCitations, + sourceById, + type DiscoveryPaper, + type DiscoverySourceId, +} from '../discovery'; +import { fetchDiscoveryFeed } from '../discovery/rss'; +import { useDiscoveryHistory } from './discoveryHistory'; +import type { DiscoveryQuerySpec } from './discoveryHistoryCore'; +import { + mergeTargetResults, + targetDue, + type DiscoveryCadence, + type DiscoverySubscription, + type DiscoverySubscriptionKind, + type DiscoveryTargetRun, + type DiscoveryUpdate, +} from './discoveryMonitorCore'; + +const LS_KEY = 'termipod.discover.monitor.v1'; + +interface PersistedDiscoveryMonitor { + version: 1; + subscriptions: DiscoverySubscription[]; + updates: DiscoveryUpdate[]; + runs: Record; + lastRefreshAt?: number; +} + +interface AddSubscription { + kind: DiscoverySubscriptionKind; + label: string; + value: string; + sourceId?: DiscoverySourceId; + cadence: DiscoveryCadence; + referenceId?: string; +} + +interface DiscoveryMonitorState { + subscriptions: DiscoverySubscription[]; + updates: DiscoveryUpdate[]; + runs: Record; + lastRefreshAt?: number; + refreshing: boolean; + addSubscription: (input: AddSubscription) => string; + removeSubscription: (id: string) => void; + setSubscriptionCadence: (id: string, cadence: DiscoveryCadence) => void; + markRead: (id: string) => void; + markAllRead: () => void; + removeUpdate: (id: string) => void; + clearRead: () => void; +} + +const KINDS = new Set(['author', 'journal', 'topic', 'citation', 'rss']); +const CADENCES = new Set(['daily', 'weekly', 'monthly']); +const SOURCES = new Set([ + 'openalex', 'semanticscholar', 'google-scholar', 'crossref', 'arxiv', 'pubmed', 'core', +]); + +function isSubscription(value: unknown): value is DiscoverySubscription { + if (value === null || typeof value !== 'object') return false; + const row = value as Record; + return typeof row.id === 'string' && + typeof row.kind === 'string' && KINDS.has(row.kind as DiscoverySubscriptionKind) && + typeof row.label === 'string' && typeof row.value === 'string' && + typeof row.sourceId === 'string' && SOURCES.has(row.sourceId as DiscoverySourceId) && + typeof row.cadence === 'string' && CADENCES.has(row.cadence as DiscoveryCadence) && + typeof row.createdAt === 'number' && + (row.referenceId === undefined || typeof row.referenceId === 'string'); +} + +function isPaper(value: unknown): value is DiscoveryPaper { + if (value === null || typeof value !== 'object') return false; + const row = value as Record; + return typeof row.paperId === 'string' && typeof row.title === 'string' && Array.isArray(row.authors); +} + +function isUpdate(value: unknown): value is DiscoveryUpdate { + if (value === null || typeof value !== 'object') return false; + const row = value as Record; + return typeof row.id === 'string' && + (row.originType === 'saved-search' || row.originType === 'subscription') && + typeof row.originId === 'string' && typeof row.originLabel === 'string' && + typeof row.arrivedAt === 'number' && isPaper(row.paper) && + (row.readAt === undefined || typeof row.readAt === 'number'); +} + +function load(): Pick { + try { + const parsed = JSON.parse(localStorage.getItem(LS_KEY) ?? '') as Partial; + const subscriptions = Array.isArray(parsed.subscriptions) ? parsed.subscriptions.filter(isSubscription) : []; + const updates = Array.isArray(parsed.updates) ? parsed.updates.filter(isUpdate).slice(0, 500) : []; + const runs: Record = {}; + if (parsed.runs !== null && typeof parsed.runs === 'object') { + for (const [key, run] of Object.entries(parsed.runs)) { + if (run !== null && typeof run === 'object' && typeof run.lastRunAt === 'number' && Array.isArray(run.seen)) { + runs[key] = { + lastRunAt: run.lastRunAt, + lastError: typeof run.lastError === 'string' ? run.lastError : undefined, + seen: run.seen.filter((entry): entry is string => typeof entry === 'string').slice(0, 250), + }; + } + } + } + return { + subscriptions, + updates, + runs, + lastRefreshAt: typeof parsed.lastRefreshAt === 'number' ? parsed.lastRefreshAt : undefined, + }; + } catch { + return { subscriptions: [], updates: [], runs: {}, lastRefreshAt: undefined }; + } +} + +function persist(state: Pick): void { + try { + const payload: PersistedDiscoveryMonitor = { + version: 1, + subscriptions: state.subscriptions, + updates: state.updates, + runs: state.runs, + lastRefreshAt: state.lastRefreshAt, + }; + localStorage.setItem(LS_KEY, JSON.stringify(payload)); + } catch (error) { + console.error(`[discovery] failed to persist "${LS_KEY}"`, error); + } +} + +function commit(partial: Partial): void { + useDiscoveryMonitor.setState(partial); + const state = useDiscoveryMonitor.getState(); + persist(state); +} + +function newId(prefix: string): string { + return `${prefix}${crypto.randomUUID()}`; +} + +export const useDiscoveryMonitor = create((set, get) => ({ + ...load(), + refreshing: false, + addSubscription: (input) => { + const id = newId('subscription'); + const subscription: DiscoverySubscription = { + id, + kind: input.kind, + label: input.label.trim() || input.value.trim(), + value: input.value.trim(), + sourceId: input.sourceId ?? 'openalex', + cadence: input.cadence, + createdAt: Date.now(), + referenceId: input.referenceId, + }; + const subscriptions = [subscription, ...get().subscriptions]; + set({ subscriptions }); + persist({ ...get(), subscriptions }); + return id; + }, + removeSubscription: (id) => { + const subscriptions = get().subscriptions.filter((entry) => entry.id !== id); + const runs = { ...get().runs }; + delete runs[`subscription:${id}`]; + set({ subscriptions, runs }); + persist({ ...get(), subscriptions, runs }); + }, + setSubscriptionCadence: (id, cadence) => { + const subscriptions = get().subscriptions.map((entry) => entry.id === id ? { ...entry, cadence } : entry); + set({ subscriptions }); + persist({ ...get(), subscriptions }); + }, + markRead: (id) => { + const updates = get().updates.map((entry) => entry.id === id && entry.readAt === undefined ? { ...entry, readAt: Date.now() } : entry); + set({ updates }); + persist({ ...get(), updates }); + }, + markAllRead: () => { + const now = Date.now(); + const updates = get().updates.map((entry) => entry.readAt === undefined ? { ...entry, readAt: now } : entry); + set({ updates }); + persist({ ...get(), updates }); + }, + removeUpdate: (id) => { + const updates = get().updates.filter((entry) => entry.id !== id); + set({ updates }); + persist({ ...get(), updates }); + }, + clearRead: () => { + const updates = get().updates.filter((entry) => entry.readAt === undefined); + set({ updates }); + persist({ ...get(), updates }); + }, +})); + +function filterSpecResults(papers: DiscoveryPaper[], spec: DiscoveryQuerySpec): DiscoveryPaper[] { + const author = spec.authorFilter.trim().toLocaleLowerCase(); + const from = spec.yearFrom === '' ? undefined : Number(spec.yearFrom); + const to = spec.yearTo === '' ? undefined : Number(spec.yearTo); + const filtered = papers.filter((paper) => + (author === '' || paper.authors.some((name) => name.toLocaleLowerCase().includes(author))) && + (from === undefined || !Number.isFinite(from) || (paper.year !== undefined && paper.year >= from)) && + (to === undefined || !Number.isFinite(to) || (paper.year !== undefined && paper.year <= to)), + ); + if (spec.sort === 'relevance') return filtered; + return [...filtered].sort((a, b) => { + if (spec.sort === 'title') return a.title.localeCompare(b.title); + if (spec.sort === 'citations') return (b.citationCount ?? -1) - (a.citationCount ?? -1); + if (a.year === undefined) return b.year === undefined ? 0 : 1; + if (b.year === undefined) return -1; + return spec.sort === 'newest' ? b.year - a.year : a.year - b.year; + }); +} + +async function runSavedSearch(spec: DiscoveryQuerySpec): Promise { + const source = sourceById(spec.sourceId); + let papers: DiscoveryPaper[] = (await source.search(spec.query, 25)).map((paper) => ({ ...paper, source: source.id })); + if (spec.findPdfs) papers = await enrichWithUnpaywall(papers); + return filterSpecResults(papers, spec); +} + +async function runSubscription(subscription: DiscoverySubscription): Promise { + if (subscription.kind === 'rss') { + return (await fetchDiscoveryFeed(subscription.value)).papers; + } + if (subscription.kind === 'citation') { + if (subscription.sourceId === 'google-scholar') { + return (await loadGoogleScholarCitations(subscription.value, 0, 25)).papers.map((paper) => ({ + ...paper, + source: 'google-scholar' as const, + })); + } + return (await loadOpenAlexCitations(subscription.value, 25)).map((paper) => ({ ...paper, source: 'openalex' as const })); + } + const source = sourceById(subscription.sourceId); + let papers = (await source.search(subscription.value, 25)).map((paper) => ({ ...paper, source: source.id })); + const needle = subscription.value.toLocaleLowerCase(); + if (subscription.kind === 'author') { + papers = papers.filter((paper) => paper.authors.some((author) => author.toLocaleLowerCase().includes(needle))); + } else if (subscription.kind === 'journal') { + papers = papers.filter((paper) => paper.venue?.toLocaleLowerCase().includes(needle) === true); + } + return papers; +} + +let refreshPromise: Promise | null = null; + +export function refreshDiscoveryTargets(options: { force?: boolean; targetKey?: string } = {}): Promise { + if (refreshPromise !== null) return refreshPromise.then(() => refreshDiscoveryTargets(options)); + refreshPromise = (async () => { + const now = Date.now(); + commit({ refreshing: true }); + let totalAdded = 0; + const saved = useDiscoveryHistory.getState().saved.filter((entry) => entry.schedule !== undefined); + const subscriptions = useDiscoveryMonitor.getState().subscriptions; + const targets: Array<{ + key: string; + cadence: DiscoveryCadence; + origin: Pick; + run: () => Promise; + }> = [ + ...saved.map((entry) => ({ + key: `saved-search:${entry.id}`, + cadence: entry.schedule!, + origin: { originType: 'saved-search' as const, originId: entry.id, originLabel: entry.name }, + run: () => runSavedSearch(entry), + })), + ...subscriptions.map((entry) => ({ + key: `subscription:${entry.id}`, + cadence: entry.cadence, + origin: { originType: 'subscription' as const, originId: entry.id, originLabel: entry.label }, + run: () => runSubscription(entry), + })), + ]; + for (const target of targets) { + if (options.targetKey !== undefined && target.key !== options.targetKey) continue; + const state = useDiscoveryMonitor.getState(); + const previousRun = state.runs[target.key]; + if (options.force !== true && !targetDue(target.cadence, previousRun, now)) continue; + try { + const papers = await target.run(); + const merged = mergeTargetResults( + state.updates, + previousRun, + papers, + target.origin, + Date.now(), + papers.map(() => newId('update')), + ); + totalAdded += merged.added; + commit({ + updates: merged.updates, + runs: { ...useDiscoveryMonitor.getState().runs, [target.key]: merged.run }, + }); + } catch (error) { + const latest = useDiscoveryMonitor.getState(); + commit({ + runs: { + ...latest.runs, + [target.key]: { + lastRunAt: Date.now(), + lastError: error instanceof Error ? error.message : String(error), + seen: previousRun?.seen ?? [], + }, + }, + }); + } + } + commit({ refreshing: false, lastRefreshAt: Date.now() }); + return totalAdded; + })().finally(() => { + refreshPromise = null; + if (useDiscoveryMonitor.getState().refreshing) commit({ refreshing: false }); + }); + return refreshPromise; +} + +export function startDiscoveryScheduler(): () => void { + const refresh = (): void => { + void refreshDiscoveryTargets(); + }; + refresh(); + const timer = window.setInterval(refresh, 5 * 60 * 1000); + window.addEventListener('focus', refresh); + return () => { + window.clearInterval(timer); + window.removeEventListener('focus', refresh); + }; +} diff --git a/desktop/src/state/discoveryMonitorCore.test.ts b/desktop/src/state/discoveryMonitorCore.test.ts new file mode 100644 index 00000000..0d7d8c7b --- /dev/null +++ b/desktop/src/state/discoveryMonitorCore.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + buildRecommendationSeed, + mergeTargetResults, + paperFingerprint, + targetDue, +} from './discoveryMonitorCore.ts'; + +test('target cadence is due initially and again only after its interval', () => { + assert.equal(targetDue('daily', undefined, 1), true); + assert.equal(targetDue('daily', { lastRunAt: 100, seen: [] }, 100 + 86_399_999), false); + assert.equal(targetDue('daily', { lastRunAt: 100, seen: [] }, 100 + 86_400_000), true); + assert.equal(targetDue('weekly', { lastRunAt: 100, seen: [], lastError: 'offline' }, 100 + 899_999), false); + assert.equal(targetDue('weekly', { lastRunAt: 100, seen: [], lastError: 'offline' }, 100 + 900_000), true); +}); + +test('updates dedupe by DOI and remember earlier target results', () => { + const papers = [ + { paperId: 'one', doi: '10.1/ABC', title: 'First', authors: [] }, + { paperId: 'two', doi: '10.1/abc', title: 'Duplicate', authors: [] }, + ]; + assert.equal(paperFingerprint(papers[0]!), 'doi:10.1/abc'); + const first = mergeTargetResults([], undefined, papers, { + originType: 'subscription', originId: 'sub', originLabel: 'Topic', + }, 10, ['u1', 'u2']); + assert.equal(first.added, 1); + const second = mergeTargetResults(first.updates, first.run, papers, { + originType: 'subscription', originId: 'sub', originLabel: 'Topic', + }, 20, ['u3', 'u4']); + assert.equal(second.added, 0); +}); + +test('recommendations favor curated topics and tags from one collection', () => { + const seed = buildRecommendationSeed('c1', [{ id: 'c1', name: 'Reading' }], [{ + id: 'r1', type: 'article', title: 'Graph neural networks for molecules', authors: [], tags: ['drug discovery'], + topics: ['Graph learning'], collectionIds: ['c1'], notes: '', addedAt: 1, rating: 5, + }]); + assert.ok(seed !== null); + assert.equal(seed.collection.name, 'Reading'); + assert.ok(seed.query.includes('graph')); + assert.ok(seed.query.includes('learning')); + assert.equal(seed.references.length, 1); +}); diff --git a/desktop/src/state/discoveryMonitorCore.ts b/desktop/src/state/discoveryMonitorCore.ts new file mode 100644 index 00000000..b4122552 --- /dev/null +++ b/desktop/src/state/discoveryMonitorCore.ts @@ -0,0 +1,144 @@ +import type { DiscoveryPaper, DiscoverySourceId } from '../discovery/types.ts'; +import type { Collection, Reference } from './library.ts'; + +export type DiscoveryCadence = 'daily' | 'weekly' | 'monthly'; +export type DiscoverySubscriptionKind = 'author' | 'journal' | 'topic' | 'citation' | 'rss'; + +export interface DiscoverySubscription { + id: string; + kind: DiscoverySubscriptionKind; + label: string; + value: string; + sourceId: DiscoverySourceId; + cadence: DiscoveryCadence; + createdAt: number; + referenceId?: string; +} + +export interface DiscoveryTargetRun { + lastRunAt: number; + lastError?: string; + seen: string[]; +} + +export interface DiscoveryUpdate { + id: string; + originType: 'saved-search' | 'subscription'; + originId: string; + originLabel: string; + paper: DiscoveryPaper; + arrivedAt: number; + readAt?: number; +} + +export const MAX_UPDATES = 500; +export const MAX_SEEN_PER_TARGET = 250; + +function compactPaper(paper: DiscoveryPaper): DiscoveryPaper { + return { + ...paper, + authors: paper.authors.slice(0, 20), + abstract: paper.abstract?.slice(0, 4_000), + tldr: paper.tldr?.slice(0, 1_000), + }; +} + +const CADENCE_MS: Record = { + daily: 24 * 60 * 60 * 1000, + weekly: 7 * 24 * 60 * 60 * 1000, + monthly: 30 * 24 * 60 * 60 * 1000, +}; + +export function targetDue(cadence: DiscoveryCadence, run: DiscoveryTargetRun | undefined, now: number): boolean { + if (run === undefined) return true; + const retryAfter = run.lastError === undefined ? CADENCE_MS[cadence] : 15 * 60 * 1000; + return now - run.lastRunAt >= retryAfter; +} + +export function paperFingerprint(paper: Pick): string { + const doi = paper.doi?.trim().toLocaleLowerCase(); + if (doi !== undefined && doi !== '') return `doi:${doi}`; + const nativeId = paper.paperId.trim().toLocaleLowerCase(); + if (nativeId !== '') return `id:${nativeId}`; + return `title:${paper.title.trim().replace(/\s+/g, ' ').toLocaleLowerCase()}|${paper.year ?? ''}`; +} + +export function mergeTargetResults( + existingUpdates: DiscoveryUpdate[], + previousRun: DiscoveryTargetRun | undefined, + papers: DiscoveryPaper[], + origin: Pick, + now: number, + ids: string[], +): { updates: DiscoveryUpdate[]; run: DiscoveryTargetRun; added: number } { + const oldSeen = new Set(previousRun?.seen ?? []); + const batchSeen = new Set(); + const additions: DiscoveryUpdate[] = []; + for (let index = 0; index < papers.length; index += 1) { + const paper = papers[index]!; + const fingerprint = paperFingerprint(paper); + if (batchSeen.has(fingerprint)) continue; + batchSeen.add(fingerprint); + if (oldSeen.has(fingerprint)) continue; + additions.push({ + id: ids[index] ?? `${origin.originId}:${fingerprint}`, + ...origin, + paper: compactPaper(paper), + arrivedAt: now, + }); + } + const seen = [...batchSeen, ...(previousRun?.seen ?? [])].filter( + (value, index, all) => all.indexOf(value) === index, + ).slice(0, MAX_SEEN_PER_TARGET); + return { + updates: [...additions, ...existingUpdates].slice(0, MAX_UPDATES), + run: { lastRunAt: now, seen }, + added: additions.length, + }; +} + +const STOP_WORDS = new Set([ + 'about', 'after', 'among', 'based', 'between', 'from', 'into', 'model', 'models', 'paper', 'study', + 'their', 'these', 'this', 'through', 'toward', 'using', 'with', 'without', +]); + +function words(value: string): string[] { + return value + .toLocaleLowerCase() + .replace(/[^\p{L}\p{N}-]+/gu, ' ') + .split(/\s+/) + .filter((word) => word.length >= 3 && !STOP_WORDS.has(word)); +} + +export interface RecommendationSeed { + collection: Collection; + references: Reference[]; + query: string; + terms: string[]; +} + +export function buildRecommendationSeed( + collectionId: string, + collections: Collection[], + references: Reference[], +): RecommendationSeed | null { + const collection = collections.find((candidate) => candidate.id === collectionId); + if (collection === undefined) return null; + const members = references.filter((reference) => reference.collectionIds.includes(collectionId)); + if (members.length === 0) return { collection, references: [], query: '', terms: [] }; + const scored = new Map(); + const add = (value: string, weight: number): void => { + for (const word of words(value)) scored.set(word, (scored.get(word) ?? 0) + weight); + }; + for (const reference of members) { + for (const topic of reference.topics ?? []) add(topic, 5); + for (const tag of reference.tags) add(tag, 4); + if (reference.venue !== undefined) add(reference.venue, 2); + add(reference.title, reference.rating !== undefined ? 1 + reference.rating / 2 : 1); + } + const terms = [...scored] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 6) + .map(([term]) => term); + return { collection, references: members, terms, query: terms.join(' ') }; +} diff --git a/desktop/src/styles/partials/04-library-nav.css b/desktop/src/styles/partials/04-library-nav.css index 6bca6e5e..8f3a5b3d 100644 --- a/desktop/src/styles/partials/04-library-nav.css +++ b/desktop/src/styles/partials/04-library-nav.css @@ -170,6 +170,8 @@ color: var(--text-secondary); } .discover-nav-primary { + display: grid; + gap: var(--spacing-s2); flex: 0 0 auto; padding: var(--spacing-s8); border-bottom: 1px solid var(--border); @@ -179,7 +181,7 @@ align-items: center; gap: var(--spacing-s8); width: 100%; - padding: var(--spacing-s8) var(--spacing-s10); + padding: var(--spacing-s8); border: none; border-radius: var(--radius-sm); background: transparent; @@ -191,6 +193,28 @@ color: var(--text); font-weight: 600; } +.discover-nav-destination > span:nth-child(2) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.discover-nav-count { + display: grid; + place-items: center; + min-width: 20px; + height: 20px; + margin-left: auto; + padding: 0 var(--spacing-s4); + border-radius: 10px; + background: var(--accent); + color: var(--accent-ink); + font-size: 10px; + font-variant-numeric: tabular-nums; +} +.discover-nav-spacer { + flex: 1 1 auto; +} .discover-nav-section { flex: 0 1 auto; min-height: 72px; @@ -902,6 +926,248 @@ color: var(--warning, var(--accent)); background: var(--raised); } + +/* Discovery monitoring workspace: the header and tool row follow the same + compact vertical rhythm as Explore, while each long list owns its scroller. */ +.discovery-workspace-pane { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; + background: var(--glass-pane); +} +.discovery-workspace-header { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--spacing-s16); + min-height: 72px; + padding: var(--spacing-s12) var(--spacing-s16); + border-bottom: 1px solid var(--border); +} +.discovery-workspace-header > div:first-child { + min-width: 0; +} +.discovery-workspace-header h2, +.discovery-monitor-section h3 { + margin: 0; + color: var(--text); +} +.discovery-workspace-header h2 { + font-size: var(--font-size-title); +} +.discovery-workspace-header p { + margin: var(--spacing-s2) 0 0; + color: var(--text-muted); + font-size: var(--font-size-body-small); +} +.discovery-workspace-actions { + display: flex; + flex: 0 0 auto; + gap: var(--spacing-s6); + margin-left: auto; +} +.discovery-workspace-actions button, +.discovery-workspace-header > button { + display: inline-flex; + align-items: center; + gap: var(--spacing-s6); + min-height: var(--control-md); +} +.discovery-workspace-toolbar { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--spacing-s12); + min-height: 50px; + padding: var(--spacing-s8) var(--spacing-s16); + border-bottom: 1px solid var(--border); +} +.discovery-workspace-toolbar .seg.compact, +.discovery-subscribe-kind.seg.compact { + padding: 2px; +} +.discovery-workspace-toolbar .seg-btn, +.discovery-subscribe-kind .seg-btn { + min-height: 30px; + padding: 0 var(--spacing-s8); +} +.discovery-monitor-warning { + display: flex; + align-items: center; + gap: var(--spacing-s6); + padding: var(--spacing-s8) var(--spacing-s16); + border-bottom: 1px solid color-mix(in srgb, var(--warning) 42%, transparent); + background: color-mix(in srgb, var(--warning) 10%, transparent); + color: var(--warning); + font-size: var(--font-size-body-small); +} +.discovery-workspace-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-s8); + min-height: 220px; + padding: var(--spacing-s24); + color: var(--text-muted); + text-align: center; +} +.discovery-workspace-empty strong { + color: var(--text-secondary); +} +.workspace-paper-card { + position: relative; +} +.workspace-paper-card.unread { + border-left: 3px solid var(--accent); +} +.workspace-paper-origin { + display: flex; + align-items: center; + margin-bottom: var(--spacing-s6); + color: var(--accent); + font-size: var(--font-size-label); + font-weight: 600; +} +.workspace-paper-origin span:last-child { + color: var(--text-muted); + font-weight: 400; +} +.workspace-paper-abstract { + display: -webkit-box; + overflow: hidden; + margin-top: var(--spacing-s8); + color: var(--text-secondary); + font-size: var(--font-size-body-small); + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} +.discovery-subscribe-card { + flex: 0 0 auto; + padding: var(--spacing-s12) var(--spacing-s16); + border-bottom: 1px solid var(--border); + background: var(--glass-surface); +} +.discovery-subscribe-kind { + display: inline-flex; + max-width: 100%; + overflow-x: auto; + margin-bottom: var(--spacing-s12); +} +.discovery-subscribe-form { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(120px, auto) minmax(100px, auto) auto; + gap: var(--spacing-s8); +} +.discovery-subscribe-form input, +.discovery-subscribe-form select, +.discovery-subscribe-form button, +.discovery-monitor-row select, +.discovery-recommend-seed select { + min-height: var(--control-md); +} +.discovery-subscribe-form .primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-s6); +} +.discovery-monitor-section { + flex: 0 1 auto; + min-height: 0; + padding: var(--spacing-s12) var(--spacing-s16); + overflow-y: auto; + border-bottom: 1px solid var(--border); +} +.discovery-monitor-section.grow { + flex: 1 1 auto; +} +.discovery-monitor-section h3 { + margin-bottom: var(--spacing-s8); + color: var(--text-muted); + font-size: var(--font-size-label); + letter-spacing: 0.06em; + text-transform: uppercase; +} +.discovery-monitor-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-s8); + min-height: 48px; + padding: var(--spacing-s6) 0; + border-bottom: 1px solid var(--border); +} +.discovery-monitor-row-main { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; +} +.discovery-monitor-row > .discovery-monitor-row-main { + min-width: 180px; +} +.discovery-monitor-row-main strong, +.discovery-monitor-row-main span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.discovery-monitor-row-main span { + color: var(--text-muted); + font-size: var(--font-size-label); +} +.discovery-monitor-error { + color: var(--danger); +} +.discovery-recommend-seed { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--spacing-s16); + padding: var(--spacing-s12) var(--spacing-s16); + border-bottom: 1px solid var(--border); + background: var(--glass-surface); +} +.discovery-recommend-seed label { + display: flex; + align-items: center; + gap: var(--spacing-s8); + color: var(--text-muted); + font-size: var(--font-size-body-small); +} +.discovery-recommend-explain { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-s8); + color: var(--text-muted); + font-size: var(--font-size-label); +} +.discovery-recommend-explain > div { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--spacing-s4); +} +@media (max-width: 940px) { + .discovery-subscribe-form { + grid-template-columns: minmax(160px, 1fr) minmax(110px, auto); + } + .discovery-recommend-seed, + .discovery-recommend-explain { + align-items: flex-start; + flex-direction: column; + } + .discovery-recommend-explain > div { + justify-content: flex-start; + } +} .discover-source { padding: 0 var(--spacing-s8) var(--spacing-s8); border-bottom: 1px solid var(--border); diff --git a/desktop/src/surfaces/DiscoveryWorkspace.tsx b/desktop/src/surfaces/DiscoveryWorkspace.tsx new file mode 100644 index 00000000..b0755ac0 --- /dev/null +++ b/desktop/src/surfaces/DiscoveryWorkspace.tsx @@ -0,0 +1,398 @@ +import { useMemo, useState } from 'react'; +import { sourceById, type DiscoveryPaper, type DiscoverySourceId } from '../discovery'; +import { useT } from '../i18n'; +import { useDiscoveryHistory } from '../state/discoveryHistory'; +import { + refreshDiscoveryTargets, + useDiscoveryMonitor, +} from '../state/discoveryMonitor'; +import { + buildRecommendationSeed, + paperFingerprint, + type DiscoveryCadence, + type DiscoverySubscriptionKind, + type DiscoveryUpdate, +} from '../state/discoveryMonitorCore'; +import { useLibrary } from '../state/library'; +import { Icon } from '../ui/Icon'; +import { useOpenLink } from '../ui/OpenLinkContext'; + +export type DiscoveryWorkspaceView = 'explore' | 'updates' | 'subscriptions' | 'for-you'; + +function cadenceLabel(cadence: DiscoveryCadence, t: ReturnType): string { + return t(`read.monitorCadence.${cadence}`); +} + +function timeLabel(timestamp: number | undefined): string { + if (timestamp === undefined) return '—'; + return new Date(timestamp).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); +} + +function WorkspacePaperCard({ + paper, + update, + onAdd, + onInspect, +}: { + paper: DiscoveryPaper; + update?: DiscoveryUpdate; + onAdd: (paper: DiscoveryPaper) => string; + onInspect: (id: string) => void; +}): JSX.Element { + const t = useT(); + const openLink = useOpenLink(); + const references = useLibrary((state) => state.references); + const markRead = useDiscoveryMonitor((state) => state.markRead); + const removeUpdate = useDiscoveryMonitor((state) => state.removeUpdate); + const fingerprint = paperFingerprint(paper); + const imported = references.find((reference) => + (paper.doi !== undefined && reference.doi?.toLocaleLowerCase() === paper.doi.toLocaleLowerCase()) || + (paper.paperId !== '' && reference.externalId === paper.paperId) || + paperFingerprint({ paperId: reference.externalId ?? '', doi: reference.doi, title: reference.title, year: reference.year }) === fingerprint, + ); + return ( +
+ {update !== undefined && ( +
+ {update.originLabel} + + {timeLabel(update.arrivedAt)} +
+ )} +
{paper.title}
+
+ {paper.authors.slice(0, 4).join(', ')} + {paper.authors.length > 4 ? ' et al.' : ''} + {paper.year !== undefined ? ` · ${paper.year}` : ''} + {paper.venue !== undefined ? ` · ${paper.venue}` : ''} + {paper.citationCount !== undefined ? ` · ${paper.citationCount} ${t('read.monitorCitations')}` : ''} +
+ {paper.abstract !== undefined &&
{paper.abstract}
} +
+ + {paper.url !== undefined && ( + + )} + {update !== undefined && update.readAt === undefined && ( + + )} + {update !== undefined && ( + + )} +
+
+ ); +} + +export function DiscoveryUpdatesPanel({ + onAdd, + onInspect, +}: { + onAdd: (paper: DiscoveryPaper) => string; + onInspect: (id: string) => void; +}): JSX.Element { + const t = useT(); + const updates = useDiscoveryMonitor((state) => state.updates); + const refreshing = useDiscoveryMonitor((state) => state.refreshing); + const lastRefreshAt = useDiscoveryMonitor((state) => state.lastRefreshAt); + const markAllRead = useDiscoveryMonitor((state) => state.markAllRead); + const clearRead = useDiscoveryMonitor((state) => state.clearRead); + const runs = useDiscoveryMonitor((state) => state.runs); + const [unreadOnly, setUnreadOnly] = useState(false); + const unread = updates.filter((entry) => entry.readAt === undefined).length; + const shown = unreadOnly ? updates.filter((entry) => entry.readAt === undefined) : updates; + const errors = Object.values(runs).filter((run) => run.lastError !== undefined); + return ( +
+
+
+

{t('read.monitorUpdates')}

+

{t('read.monitorUpdatesHint')}

+
+
+ + +
+
+
+
+ + +
+ {t('read.monitorLastChecked').replace('{time}', timeLabel(lastRefreshAt))} + + +
+ {errors.length > 0 && ( +
+ {t('read.monitorSomeFailed').replace('{n}', String(errors.length))} +
+ )} +
+ {shown.length === 0 ? ( +
+ + {unreadOnly ? t('read.monitorNoUnread') : t('read.monitorNoUpdates')} + {t('read.monitorNoUpdatesHint')} +
+ ) : shown.map((update) => ( + + ))} +
+
+ ); +} + +const SUBSCRIPTION_KINDS: DiscoverySubscriptionKind[] = ['author', 'journal', 'topic', 'citation', 'rss']; +const CADENCES: DiscoveryCadence[] = ['daily', 'weekly', 'monthly']; + +export function DiscoverySubscriptionsPanel(): JSX.Element { + const t = useT(); + const references = useLibrary((state) => state.references); + const saved = useDiscoveryHistory((state) => state.saved); + const setSavedSchedule = useDiscoveryHistory((state) => state.setSavedSchedule); + const subscriptions = useDiscoveryMonitor((state) => state.subscriptions); + const runs = useDiscoveryMonitor((state) => state.runs); + const addSubscription = useDiscoveryMonitor((state) => state.addSubscription); + const removeSubscription = useDiscoveryMonitor((state) => state.removeSubscription); + const setCadence = useDiscoveryMonitor((state) => state.setSubscriptionCadence); + const [kind, setKind] = useState('topic'); + const [value, setValue] = useState(''); + const [label, setLabel] = useState(''); + const [sourceId, setSourceId] = useState('openalex'); + const [cadence, setNewCadence] = useState('weekly'); + const [referenceId, setReferenceId] = useState(''); + + const suggestions = useMemo(() => { + if (kind === 'author') return [...new Set(references.flatMap((reference) => reference.authors))].sort(); + if (kind === 'journal') return [...new Set(references.map((reference) => reference.venue).filter((item): item is string => item !== undefined && item !== ''))].sort(); + if (kind === 'topic') return [...new Set(references.flatMap((reference) => [...(reference.topics ?? []), ...reference.tags]))].sort(); + return []; + }, [kind, references]); + + function reset(nextKind: DiscoverySubscriptionKind): void { + setKind(nextKind); + setValue(''); + setLabel(''); + setReferenceId(''); + } + + function submit(): void { + let targetValue = value.trim(); + let targetLabel = label.trim() || targetValue; + if (kind === 'citation') { + const reference = references.find((entry) => entry.id === referenceId); + if (reference === undefined) return; + const scholarCitesId = reference.scholar?.citesId; + const externalOpenAlex = reference.externalId !== undefined && /openalex\.org\/W\d+/i.test(reference.externalId) + ? reference.externalId + : undefined; + targetValue = scholarCitesId ?? reference.openAlexId ?? externalOpenAlex ?? reference.doi ?? ''; + targetLabel = reference.title; + } + if (targetValue === '') return; + const citationReference = kind === 'citation' ? references.find((entry) => entry.id === referenceId) : undefined; + const targetSource = citationReference?.scholar?.citesId !== undefined ? 'google-scholar' : sourceId; + const id = addSubscription({ kind, label: targetLabel, value: targetValue, sourceId: targetSource, cadence, referenceId: referenceId || undefined }); + setValue(''); + setLabel(''); + setReferenceId(''); + void refreshDiscoveryTargets({ force: true, targetKey: `subscription:${id}` }); + } + + return ( +
+
+

{t('read.monitorSubscriptions')}

{t('read.monitorSubscriptionsHint')}

+
+
+
+ {SUBSCRIPTION_KINDS.map((entry) => ( + + ))} +
+
+ {kind === 'citation' ? ( + + ) : ( + <> + 0 ? 'discovery-subscription-values' : undefined} + placeholder={kind === 'rss' ? 'https://example.org/feed.xml' : t(`read.monitorPlaceholder.${kind}`)} + onChange={(event) => setValue(event.target.value)} + /> + {suggestions.map((entry) => + + )} + {kind === 'rss' && setLabel(event.target.value)} />} + {kind !== 'rss' && kind !== 'citation' && ( + + )} + + +
+
+ +
+

{t('read.monitorScheduledSearches')}

+ {saved.length === 0 ?

{t('read.savedSearchesEmpty')}

: saved.map((search) => { + const run = runs[`saved-search:${search.id}`]; + return ( +
+ +
{search.name}{sourceById(search.sourceId).label} · {timeLabel(run?.lastRunAt)}
+ {run?.lastError !== undefined && } + +
+ ); + })} +
+ +
+

{t('read.monitorSubscriptions')}

+ {subscriptions.length === 0 ?

{t('read.monitorNoSubscriptions')}

: subscriptions.map((subscription) => { + const run = runs[`subscription:${subscription.id}`]; + return ( +
+ +
+ {subscription.label} + {t(`read.monitorKind.${subscription.kind}`)} · {timeLabel(run?.lastRunAt)} +
+ {run?.lastError !== undefined && } + + + +
+ ); + })} +
+
+ ); +} + +function storedCollection(): string { + try { return localStorage.getItem('termipod.discover.forYouCollection') ?? ''; } catch { return ''; } +} + +export function DiscoveryForYouPanel({ + onAdd, + onInspect, +}: { + onAdd: (paper: DiscoveryPaper) => string; + onInspect: (id: string) => void; +}): JSX.Element { + const t = useT(); + const references = useLibrary((state) => state.references); + const collections = useLibrary((state) => state.collections); + const [collectionId, setCollectionId] = useState(storedCollection); + const [results, setResults] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const seed = useMemo(() => buildRecommendationSeed(collectionId, collections, references), [collectionId, collections, references]); + + async function refresh(): Promise { + if (seed === null || seed.query === '') return; + setBusy(true); + setError(null); + try { + const existing = new Set(references.map((reference) => paperFingerprint({ + paperId: reference.externalId ?? '', doi: reference.doi, title: reference.title, year: reference.year, + }))); + const papers = (await sourceById('openalex').search(seed.query, 40)) + .map((paper) => ({ ...paper, source: 'openalex' as const })) + .filter((paper) => !existing.has(paperFingerprint(paper))) + .slice(0, 25); + setResults(papers); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + setResults([]); + } finally { + setBusy(false); + } + } + + return ( +
+
+

{t('read.monitorForYou')}

{t('read.monitorForYouHint')}

+ +
+
+ + {seed !== null && ( +
+ {t('read.monitorSeededBy').replace('{n}', String(seed.references.length))} +
{seed.terms.map((term) => {term})}
+
+ )} +
+ {error !== null &&
{error}
} +
+ {collectionId === '' ? ( +
{t('read.monitorChooseCollection')}{t('read.monitorChooseCollectionHint')}
+ ) : seed?.references.length === 0 ? ( +
{t('read.monitorEmptyCollection')}{t('read.monitorEmptyCollectionHint')}
+ ) : results.length === 0 && !busy ? ( +
{t('read.monitorReadyRecommend')}{t('read.monitorReadyRecommendHint')}
+ ) : results.map((paper) => )} +
+
+ ); +} diff --git a/desktop/src/surfaces/ReadSurface.tsx b/desktop/src/surfaces/ReadSurface.tsx index 90924726..3e7e45ec 100644 --- a/desktop/src/surfaces/ReadSurface.tsx +++ b/desktop/src/surfaces/ReadSurface.tsx @@ -85,6 +85,13 @@ import { useContextMenu } from '../ui/ContextMenu'; import { WebdavModal } from '../ui/WebdavModal'; import { HeaderPaneToggle, WorkbenchSurface } from '../ui/WorkbenchSurface'; import { PopoverMenu } from '../ui/PopoverMenu'; +import { + DiscoveryForYouPanel, + DiscoverySubscriptionsPanel, + DiscoveryUpdatesPanel, + type DiscoveryWorkspaceView, +} from './DiscoveryWorkspace'; +import { useDiscoveryMonitor } from '../state/discoveryMonitor'; const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n)); @@ -2053,7 +2060,13 @@ function DiscoverySearchRow({ ); } -function DiscoveryRail(): JSX.Element { +function DiscoveryRail({ + view, + onView, +}: { + view: DiscoveryWorkspaceView; + onView: (view: DiscoveryWorkspaceView) => void; +}): JSX.Element { const t = useT(); const recent = useDiscoveryHistory((state) => state.recent); const saved = useDiscoveryHistory((state) => state.saved); @@ -2062,16 +2075,32 @@ function DiscoveryRail(): JSX.Element { const removeSaved = useDiscoveryHistory((state) => state.removeSaved); const clearRecent = useDiscoveryHistory((state) => state.clearRecent); const restoreAndRun = useDiscoverySearch((state) => state.restoreAndRun); + const unreadUpdates = useDiscoveryMonitor((state) => state.updates.filter((entry) => entry.readAt === undefined).length); const savedKeys = useMemo(() => new Set(saved.map(discoveryQueryKey)), [saved]); return (
- + + +
+ {view === 'explore' ? ( + <>
{t('read.savedSearches')} @@ -2085,7 +2114,10 @@ function DiscoveryRail(): JSX.Element { key={entry.id} entry={entry} saved - onRun={() => restoreAndRun(entry)} + onRun={() => { + onView('explore'); + restoreAndRun(entry); + }} onRemove={() => removeSaved(entry.id)} /> )) @@ -2111,7 +2143,10 @@ function DiscoveryRail(): JSX.Element { key={entry.id} entry={entry} saved={isSaved} - onRun={() => restoreAndRun(entry)} + onRun={() => { + onView('explore'); + restoreAndRun(entry); + }} onSave={() => save(entry)} onRemove={() => removeRecent(entry.id)} /> @@ -2119,10 +2154,12 @@ function DiscoveryRail(): JSX.Element { }) )}
+ + ) :
}
- {t('read.searchHistoryPrivacy')} + {view === 'explore' ? t('read.searchHistoryPrivacy') : t('read.monitorPrivacy')}
); @@ -2617,6 +2654,7 @@ export function ReadSurface(): JSX.Element { const storagePath = useZoteroStorage((s) => s.path); const [mode, setMode] = useState('library'); + const [discoveryView, setDiscoveryView] = useState('explore'); const [collection, setCollection] = useState(ALL); const [tag, setTag] = useState(null); const [tagFilter, setTagFilter] = useState(''); @@ -3394,7 +3432,7 @@ export function ReadSurface(): JSX.Element { <>
); })} - + }

{t('read.monitorSubscriptions')}

- {subscriptions.length === 0 ?

{t('read.monitorNoSubscriptions')}

: subscriptions.map((subscription) => { + {subscriptions.filter((subscription) => subscriptionGroup(subscription.kind) === group).length === 0 ?

{t('read.monitorNoSubscriptions')}

: subscriptions.filter((subscription) => subscriptionGroup(subscription.kind) === group).map((subscription) => { const run = runs[`subscription:${subscription.id}`]; return (
- +
{subscription.label} - {t(`read.monitorKind.${subscription.kind}`)} · {timeLabel(run?.lastRunAt)} + {t(`read.monitorKind.${subscription.kind}`)} · {subscription.paused === true ? t('read.monitorPaused') : timeLabel(run?.lastRunAt)}
- {run?.lastError !== undefined && } - + {run?.lastError !== undefined && } + + @@ -315,6 +467,59 @@ export function DiscoverySubscriptionsPanel(): JSX.Element { ); } +export function DiscoveryTrendsPanel({ + onAdd, + onInspect, +}: { + onAdd: (paper: DiscoveryPaper) => string; + onInspect: (id: string) => void; +}): JSX.Element { + const t = useT(); + const updates = useDiscoveryMonitor((state) => state.updates); + const trends = useMemo(() => buildDiscoveryTrends(updates, Date.now()), [updates]); + const [selected, setSelected] = useState(null); + const active = trends.find((trend) => trend.term === selected) ?? trends[0]; + const evidence = active === undefined ? [] : active.evidenceIds + .map((id) => updates.find((update) => update.id === id)) + .filter((update): update is DiscoveryUpdate => update !== undefined); + return ( +
+
+

{t('read.monitorTrends')}

{t('read.monitorTrendsHint')}

+
+ {trends.length === 0 ? ( +
{t('read.monitorNoTrends')}{t('read.monitorNoTrendsHint')}
+ ) : ( +
+
+ {trends.map((trend) => { + const max = Math.max(1, ...trend.days); + return ( + + ); + })} +
+
+
+ {active?.term} + {t('read.monitorTrendDrilldown')} +
+ {evidence.map((update) => )} +
+
+ )} +
+ ); +} + function storedCollection(): string { try { return localStorage.getItem('termipod.discover.forYouCollection') ?? ''; } catch { return ''; } } diff --git a/desktop/src/surfaces/ReadSurface.tsx b/desktop/src/surfaces/ReadSurface.tsx index 3e7e45ec..8d5770e6 100644 --- a/desktop/src/surfaces/ReadSurface.tsx +++ b/desktop/src/surfaces/ReadSurface.tsx @@ -88,6 +88,7 @@ import { PopoverMenu } from '../ui/PopoverMenu'; import { DiscoveryForYouPanel, DiscoverySubscriptionsPanel, + DiscoveryTrendsPanel, DiscoveryUpdatesPanel, type DiscoveryWorkspaceView, } from './DiscoveryWorkspace'; @@ -2093,6 +2094,10 @@ function DiscoveryRail({ {t('read.monitorForYou')} +