From 9f049ec04c6832eff8b9adefb82639cdfd5eef14 Mon Sep 17 00:00:00 2001 From: agentfleet Date: Sun, 16 Aug 2026 11:12:05 +0800 Subject: [PATCH 1/7] feat(read): add literature ratings Co-Authored-By: Codex --- desktop/electron/e2e/app.spec.ts | 61 ++++++++++++ desktop/src/i18n/index.ts | 12 +++ desktop/src/state/library.ts | 8 ++ desktop/src/state/librarySync.ts | 2 + desktop/src/styles/partials/03-pdf.css | 38 ++++++++ .../src/styles/partials/04-library-nav.css | 13 +++ desktop/src/surfaces/ReadSurface.tsx | 94 +++++++++++++++++-- .../053-hub-reference-library-entity.md | 3 +- hub/internal/server/handlers_references.go | 19 ++-- .../server/handlers_references_test.go | 17 +++- hub/internal/server/native_tools.go | 4 +- hub/migrations/0076_reference_rating.down.sql | 1 + hub/migrations/0076_reference_rating.up.sql | 5 + 13 files changed, 257 insertions(+), 20 deletions(-) create mode 100644 hub/migrations/0076_reference_rating.down.sql create mode 100644 hub/migrations/0076_reference_rating.up.sql diff --git a/desktop/electron/e2e/app.spec.ts b/desktop/electron/e2e/app.spec.ts index d4fdd527c..8cb6b1fdd 100644 --- a/desktop/electron/e2e/app.spec.ts +++ b/desktop/electron/e2e/app.spec.ts @@ -572,6 +572,67 @@ test('read: synced Zotero files open from the default attachment location', asyn } }); +test('read: ratings persist, update in one click, and sort highest first', async () => { + await dismissConnectModal(); + const originalLibrary = await page.evaluate(() => { + const libraryKey = 'termipod.library.v1'; + const original = localStorage.getItem(libraryKey); + const base = { + type: 'article', + authors: ['TermiPod'], + tags: [], + collectionIds: [], + notes: '', + addedAt: Date.now(), + dirty: false, + attachments: [], + }; + localStorage.setItem(libraryKey, JSON.stringify({ + references: [ + { ...base, id: 'ref-rating-two', title: 'Rating Alpha', rating: 2 }, + { ...base, id: 'ref-rating-five', title: 'Rating Beta', rating: 5 }, + { ...base, id: 'ref-rating-none', title: 'Rating Gamma' }, + ], + collections: [], + })); + return original; + }); + + try { + await page.reload({ waitUntil: 'domcontentloaded' }); + await dismissConnectModal(); + await page.locator('[data-job="read"]').click(); + + const table = page.locator('.read-table'); + const rows = table.locator('tbody tr'); + await table.getByRole('button', { name: 'Sort by Rating', exact: true }).click(); + await expect(rows.nth(0)).toContainText('Rating Beta'); + await expect(rows.nth(1)).toContainText('Rating Alpha'); + await expect(rows.nth(2)).toContainText('Rating Gamma'); + + const unrated = rows.filter({ hasText: 'Rating Gamma' }); + await unrated.getByRole('button', { name: 'Rate 4 out of 5', exact: true }).click(); + await expect(rows.nth(0)).toContainText('Rating Beta'); + await expect(rows.nth(1)).toContainText('Rating Gamma'); + await expect(rows.nth(2)).toContainText('Rating Alpha'); + await expect(rows.nth(1).getByRole('button', { name: 'Clear 4-star rating', exact: true })).toBeVisible(); + + const persisted = await page.evaluate(() => { + const library = JSON.parse(localStorage.getItem('termipod.library.v1') ?? '{}') as { + references?: { id: string; rating?: number }[]; + }; + return library.references?.find((reference) => reference.id === 'ref-rating-none')?.rating; + }); + expect(persisted).toBe(4); + } finally { + await page.evaluate((original) => { + if (original === null) localStorage.removeItem('termipod.library.v1'); + else localStorage.setItem('termipod.library.v1', original); + }, originalLibrary); + await page.reload({ waitUntil: 'domcontentloaded' }); + } +}); + test('read: PDF frequent actions stay visible and the outline folds by level', async () => { await dismissConnectModal(); const fixture = await page.evaluate(async ({ bytes }) => { diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index 8960f9de1..9118ce2eb 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -1415,6 +1415,11 @@ const en: Dict = { 'read.tabCite': 'Cite', 'read.fType': 'Type', 'read.fTitle': 'Title', + 'read.fRating': 'Rating', + 'read.rating': 'Literature rating', + 'read.ratingSet': 'Rate {n} out of 5', + 'read.ratingClear': 'Clear {n}-star rating', + 'read.ratingUnrated': 'Unrated', 'read.fAuthors': 'Authors (; separated)', 'read.fYear': 'Year', 'read.fVenue': 'Venue', @@ -1626,6 +1631,7 @@ const en: Dict = { 'read.colCreator': 'Creator', 'read.colYear': 'Year', 'read.colVenue': 'Journal', + 'read.colRating': 'Rating', 'read.colType': 'Type', 'read.resizeColumn': 'Resize {col} column', 'read.resizeColumnHint': 'Drag to resize · double-click to reset', @@ -3668,6 +3674,11 @@ const zh: Dict = { 'read.tabCite': '引用', 'read.fType': '类型', 'read.fTitle': '标题', + 'read.fRating': '评分', + 'read.rating': '文献评分', + 'read.ratingSet': '评为 {n} 分(满分 5 分)', + 'read.ratingClear': '清除 {n} 星评分', + 'read.ratingUnrated': '未评分', 'read.fAuthors': '作者(分号分隔)', 'read.fYear': '年份', 'read.fVenue': '来源', @@ -3877,6 +3888,7 @@ const zh: Dict = { 'read.colCreator': '作者', 'read.colYear': '年份', 'read.colVenue': '期刊', + 'read.colRating': '评分', 'read.colType': '类型', 'read.resizeColumn': '调整{col}列宽', 'read.resizeColumnHint': '拖动调整列宽 · 双击重置', diff --git a/desktop/src/state/library.ts b/desktop/src/state/library.ts index 51b0b7f23..afbb19c03 100644 --- a/desktop/src/state/library.ts +++ b/desktop/src/state/library.ts @@ -61,6 +61,7 @@ export interface Reference { abstract?: string; tldr?: string; // Semantic Scholar one-line summary citationCount?: number; + rating?: number; // director-curated score, 1..5; undefined means unrated source?: 'semantic-scholar' | 'manual' | 'paste' | 'zotero' | 'scrape'; externalId?: string; // e.g. Semantic Scholar paperId / Zotero item key — dedupes imports tags: string[]; @@ -435,6 +436,13 @@ export const useLibrary = create((set, get) => ({ addedAt: cur.addedAt, notes: cur.notes, bodyMarkdown: cur.bodyMarkdown ?? it.ref.bodyMarkdown, + // Ratings are director curation: a Zotero re-import never clears one. + // A clean hub pull may update it (agent/device edit); a failed local + // push keeps the dirty local value for the next retry. + rating: + it.ref.syncedAt !== undefined && cur.dirty !== true + ? it.ref.rating + : (cur.rating ?? it.ref.rating), tags: [...new Set([...cur.tags, ...it.ref.tags])], collectionIds: [...new Set([...cur.collectionIds, ...collectionIds])], attachments, diff --git a/desktop/src/state/librarySync.ts b/desktop/src/state/librarySync.ts index 4039032df..e6be22e93 100644 --- a/desktop/src/state/librarySync.ts +++ b/desktop/src/state/librarySync.ts @@ -76,6 +76,7 @@ function refToHubBody(r: Reference, collName: Map): Record; const LIB_COLUMN_WIDTHS_KEY = 'termipod.read.libraryColumnWidths'; const DEFAULT_LIB_COLUMN_WIDTHS: LibraryColumnWidths = { - title: 40, - creator: 22, - year: 9, - venue: 19, - type: 10, + title: 34, + creator: 20, + year: 8, + venue: 18, + rating: 12, + type: 8, }; const MIN_LIB_COLUMN_WIDTHS: LibraryColumnWidths = { - title: 20, - creator: 14, + title: 18, + creator: 13, year: 7, - venue: 12, + venue: 11, + rating: 10, type: 8, }; @@ -173,11 +176,64 @@ function sortVal(r: Reference, key: SortKey): string | number { return r.year ?? 0; case 'venue': return (r.venue ?? '').toLowerCase(); + case 'rating': + return r.rating ?? 0; case 'type': return r.type; } } +function RatingControl({ + value, + onChange, + compact = false, +}: { + value?: number; + onChange: (rating: number | undefined) => void; + compact?: boolean; +}): JSX.Element { + const t = useT(); + const [preview, setPreview] = useState(null); + const shown = preview ?? value ?? 0; + return ( + setPreview(null)} + > + {[1, 2, 3, 4, 5].map((score) => { + const current = value === score; + const title = current + ? t('read.ratingClear').replace('{n}', String(score)) + : t('read.ratingSet').replace('{n}', String(score)); + return ( + + ); + })} + + ); +} + function splitList(s: string, sep: string): string[] { return s .split(sep) @@ -196,6 +252,7 @@ interface LibRowCtx { onOpen: (id: string) => void; // open the reader (double-click / Enter with a viewable attachment) onMenu: (id: string, x: number, y: number) => void; hasPdf: (r: Reference) => boolean; + onRate: (id: string, rating: number | undefined) => void; } // Custom table wrapper keeps the class stable. Header widths control the fixed @@ -1235,6 +1292,15 @@ function Inspector({ {t('read.fTitle')} update(ref.id, { title: e.target.value })} /> +
+ {t('read.fRating')} +
+ update(ref.id, { rating })} /> + + {ref.rating === undefined ? t('read.ratingUnrated') : `${ref.rating}/5`} + +
+