Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions core/highlights/repo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { Database } from 'better-sqlite3'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { upsertPaper } from '../library/repo.js'
import { openDb } from '../store/db.js'
import { createHighlight, deleteHighlight, listHighlights } from './repo.js'

describe('highlights repo', () => {
let db: Database

beforeEach(() => {
db = openDb({ path: ':memory:' })
upsertPaper(db, {
slug: 'attention-is-all-you-need',
title: 'Attention Is All You Need',
authors: ['A. Vaswani'],
addedAt: new Date().toISOString(),
})
})

afterEach(() => {
db.close()
})

it('returns an empty list when a paper has no highlights yet', () => {
expect(listHighlights(db, 'attention-is-all-you-need')).toEqual([])
})

it('creates a highlight and reads it back', () => {
const created = createHighlight(db, {
id: 'h1',
paperSlug: 'attention-is-all-you-need',
page: 3,
color: 'yellow',
quote: 'scaled dot-product attention',
anchor: '{"start":10,"end":40}',
createdAt: '2026-01-01T00:00:00.000Z',
})

expect(created).toEqual({
id: 'h1',
paperSlug: 'attention-is-all-you-need',
page: 3,
color: 'yellow',
quote: 'scaled dot-product attention',
anchor: '{"start":10,"end":40}',
createdAt: '2026-01-01T00:00:00.000Z',
})

const highlights = listHighlights(db, 'attention-is-all-you-need')
expect(highlights).toEqual([created])
})

it('lists multiple highlights for a paper ordered by page then created_at ascending', () => {
createHighlight(db, {
id: 'h-page2-later',
paperSlug: 'attention-is-all-you-need',
page: 2,
color: 'green',
quote: 'second page, later',
anchor: '{}',
createdAt: '2026-01-02T00:00:00.000Z',
})
createHighlight(db, {
id: 'h-page1',
paperSlug: 'attention-is-all-you-need',
page: 1,
color: 'blue',
quote: 'first page',
anchor: '{}',
createdAt: '2026-01-03T00:00:00.000Z',
})
createHighlight(db, {
id: 'h-page2-earlier',
paperSlug: 'attention-is-all-you-need',
page: 2,
color: 'pink',
quote: 'second page, earlier',
anchor: '{}',
createdAt: '2026-01-01T00:00:00.000Z',
})

const highlights = listHighlights(db, 'attention-is-all-you-need')
expect(highlights.map((h) => h.id)).toEqual(['h-page1', 'h-page2-earlier', 'h-page2-later'])
})

it('only returns highlights for the requested paper', () => {
upsertPaper(db, {
slug: 'other-paper',
title: 'Other Paper',
authors: [],
addedAt: new Date().toISOString(),
})
createHighlight(db, {
id: 'h-mine',
paperSlug: 'attention-is-all-you-need',
page: 1,
color: 'yellow',
quote: 'mine',
anchor: '{}',
createdAt: '2026-01-01T00:00:00.000Z',
})
createHighlight(db, {
id: 'h-other',
paperSlug: 'other-paper',
page: 1,
color: 'yellow',
quote: 'other',
anchor: '{}',
createdAt: '2026-01-01T00:00:00.000Z',
})

const highlights = listHighlights(db, 'attention-is-all-you-need')
expect(highlights.map((h) => h.id)).toEqual(['h-mine'])
})

it('deletes a highlight', () => {
createHighlight(db, {
id: 'h1',
paperSlug: 'attention-is-all-you-need',
page: 1,
color: 'yellow',
quote: 'to be deleted',
anchor: '{}',
createdAt: '2026-01-01T00:00:00.000Z',
})

deleteHighlight(db, 'h1')
expect(listHighlights(db, 'attention-is-all-you-need')).toEqual([])
})

it('deleting a highlight that does not exist is a no-op', () => {
expect(() => deleteHighlight(db, 'no-such-id')).not.toThrow()
})

it('deleting the paper cascades and removes its highlights', () => {
createHighlight(db, {
id: 'h1',
paperSlug: 'attention-is-all-you-need',
page: 1,
color: 'yellow',
quote: 'cascade me',
anchor: '{}',
createdAt: '2026-01-01T00:00:00.000Z',
})

db.prepare('DELETE FROM papers WHERE slug = ?').run('attention-is-all-you-need')

const row = db.prepare('SELECT * FROM highlights WHERE id = ?').get('h1')
expect(row).toBeUndefined()
})
})
93 changes: 93 additions & 0 deletions core/highlights/repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Highlights CRUD against the `highlights` table (core/store/schema.ts
// SCHEMA_V4_HIGHLIGHTS, migration 4). [P2-02] Highlight tool + Annotations
// tab: multiple highlights per paper, each pinned to a PDF page.
//
// Unlike notes (one row per paper, `paper_slug` as PRIMARY KEY), a paper can
// have many highlights, so this table uses a synthetic, caller-supplied `id`
// (uuid) as its primary key. The caller also supplies `createdAt` (ISO
// string) rather than this module stamping `new Date()` itself, to keep
// creation deterministic and testable.
//
// `anchor` is an opaque JSON string produced by the renderer's text-layer
// selection logic (start/end offsets etc. needed to re-locate the highlight
// on the page). This module stores it verbatim and never parses it — same
// "state vs. content" boundary as everywhere else in core/store. `quote` is
// the exact selected text, kept alongside for the Annotations list UI and as
// a fallback re-find anchor.
//
// `ON DELETE CASCADE` on the papers FK means purging a paper drops its
// highlights automatically. There is no on-disk file counterpart for
// highlights (they only reference page numbers into the existing paper.pdf),
// so there's nothing for the file layer to coordinate on purge here.

import type { Database } from 'better-sqlite3'

export interface HighlightRecord {
id: string
paperSlug: string
page: number
color: string
quote: string
anchor: string
createdAt: string
}

export interface CreateHighlightInput {
id: string
paperSlug: string
page: number
color: string
quote: string
anchor: string
createdAt: string
}

interface HighlightRow {
id: string
paper_slug: string
page: number
color: string
quote: string
anchor: string
created_at: string
}

function toRecord(row: HighlightRow): HighlightRecord {
return {
id: row.id,
paperSlug: row.paper_slug,
page: row.page,
color: row.color,
quote: row.quote,
anchor: row.anchor,
createdAt: row.created_at,
}
}

/** Insert a new highlight. `id` and `createdAt` are caller-supplied for testability. */
export function createHighlight(db: Database, input: CreateHighlightInput): HighlightRecord {
db.prepare(
`INSERT INTO highlights (id, paper_slug, page, color, quote, anchor, created_at)
VALUES (@id, @paperSlug, @page, @color, @quote, @anchor, @createdAt)`,
).run(input)

return { ...input }
}

/** List all highlights for a paper, ordered by page then creation time ascending. */
export function listHighlights(db: Database, paperSlug: string): HighlightRecord[] {
const rows = db
.prepare(
`SELECT * FROM highlights
WHERE paper_slug = ?
ORDER BY page ASC, created_at ASC`,
)
.all(paperSlug) as HighlightRow[]

return rows.map(toRecord)
}

/** Delete a highlight by id. No-op if it doesn't exist. */
export function deleteHighlight(db: Database, id: string): void {
db.prepare('DELETE FROM highlights WHERE id = ?').run(id)
}
2 changes: 1 addition & 1 deletion core/store/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe('openDb', () => {
const row = db2.prepare('SELECT * FROM papers WHERE slug = ?').get('p1')
expect(row).toBeTruthy()
const version = db2.pragma('user_version', { simple: true })
expect(version).toBe(3)
expect(version).toBe(4)
} finally {
db2.close()
}
Expand Down
17 changes: 15 additions & 2 deletions core/store/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('runMigrations', () => {
'chat_sessions',
'chat_messages',
'notes',
'highlights',
]),
)
})
Expand All @@ -36,7 +37,19 @@ describe('runMigrations', () => {
runMigrations(db)

const version = db.pragma('user_version', { simple: true })
expect(version).toBe(3)
expect(version).toBe(4)
})

it('creates the highlights table index', () => {
db = new Database(':memory:')
runMigrations(db)

const indexes = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'highlights'")
.all()
.map((row) => (row as { name: string }).name)

expect(indexes).toContain('idx_highlights_paper')
})

it('re-running is a no-op: applying twice does not error or reset data', () => {
Expand All @@ -51,7 +64,7 @@ describe('runMigrations', () => {
runMigrations(db)

const version = db.pragma('user_version', { simple: true })
expect(version).toBe(3)
expect(version).toBe(4)

const row = db.prepare('SELECT * FROM papers WHERE slug = ?').get('a')
expect(row).toBeTruthy()
Expand Down
3 changes: 2 additions & 1 deletion core/store/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import type { Database } from 'better-sqlite3'

import { SCHEMA_V1, SCHEMA_V3_NOTES } from './schema.js'
import { SCHEMA_V1, SCHEMA_V3_NOTES, SCHEMA_V4_HIGHLIGHTS } from './schema.js'

export interface Migration {
version: number
Expand All @@ -24,6 +24,7 @@ export const MIGRATIONS: Migration[] = [
{ version: 1, sql: SCHEMA_V1 },
{ version: 2, sql: 'ALTER TABLE papers ADD COLUMN summary TEXT;' },
{ version: 3, sql: SCHEMA_V3_NOTES }, // [P2-01] notes table
{ version: 4, sql: SCHEMA_V4_HIGHLIGHTS }, // [P2-02] highlights table
]

/**
Expand Down
22 changes: 22 additions & 0 deletions core/store/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,25 @@ CREATE TABLE IF NOT EXISTS notes (
updated_at TEXT NOT NULL
);
`

// [P2-02] Highlight tool + Annotations tab: multiple highlights per paper, so
// (unlike notes) this table uses a synthetic caller-supplied `id` rather than
// `paper_slug` as the primary key. `anchor` is an opaque JSON string produced
// by the renderer's text-layer selection logic (offsets etc.) — this module
// stores it verbatim and never parses it, same "state vs. content" boundary
// as everywhere else in this file. `ON DELETE CASCADE` on the papers FK means
// purging a paper drops its highlights automatically (coordinate the on-disk
// side, if any, with the file layer — this table has none, highlights are
// pure DB state referencing paper.pdf pages).
export const SCHEMA_V4_HIGHLIGHTS = `
CREATE TABLE IF NOT EXISTS highlights (
id TEXT PRIMARY KEY,
paper_slug TEXT NOT NULL REFERENCES papers(slug) ON DELETE CASCADE,
page INTEGER NOT NULL,
color TEXT NOT NULL,
quote TEXT NOT NULL,
anchor TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_highlights_paper ON highlights(paper_slug);
`
Loading
Loading