diff --git a/client/src/features/book/components/detail/BookDetailTabs.vue b/client/src/features/book/components/detail/BookDetailTabs.vue index 29a4928f4..6523bf521 100644 --- a/client/src/features/book/components/detail/BookDetailTabs.vue +++ b/client/src/features/book/components/detail/BookDetailTabs.vue @@ -18,6 +18,7 @@ const tabs = computed<{ label: string; tab: BookDetailTab }[]>(() => { } result.push({ label: 'Files', tab: 'files' }) result.push({ label: 'Reading Log', tab: 'reading-log' }) + result.push({ label: 'Highlights', tab: 'highlights' }) return result }) diff --git a/client/src/features/book/components/detail/tabs/HighlightCard.vue b/client/src/features/book/components/detail/tabs/HighlightCard.vue new file mode 100644 index 000000000..3e322f8f6 --- /dev/null +++ b/client/src/features/book/components/detail/tabs/HighlightCard.vue @@ -0,0 +1,177 @@ + + + + + + + + {{ highlight.text }} + + + {{ highlight.note }} + + + + + + handleColorSelect(c.hex)" + /> + + + + + {{ new Date(highlight.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) }} + + + + + + + + + + {{ highlight.note ? 'Edit note' : 'Add note' }} + + + + + + + + + Change color + + + + + + + + + + {{ copied ? 'Copied!' : 'Copy text' }} + + + + + + + + Confirm + + + + + + + + + Delete + + + + + + + diff --git a/client/src/features/book/components/detail/tabs/HighlightChapterGroup.vue b/client/src/features/book/components/detail/tabs/HighlightChapterGroup.vue new file mode 100644 index 000000000..a02c0b789 --- /dev/null +++ b/client/src/features/book/components/detail/tabs/HighlightChapterGroup.vue @@ -0,0 +1,45 @@ + + + + + + + + {{ chapterTitle }} + ({{ highlights.length }}) + + + + emit('updateNote', id, note)" + @update-color="(id, color) => emit('updateColor', id, color)" + @delete="(id) => emit('delete', id)" + /> + + + diff --git a/client/src/features/book/components/detail/tabs/HighlightNoteEditor.vue b/client/src/features/book/components/detail/tabs/HighlightNoteEditor.vue new file mode 100644 index 000000000..1254503e8 --- /dev/null +++ b/client/src/features/book/components/detail/tabs/HighlightNoteEditor.vue @@ -0,0 +1,47 @@ + + + + + + + + + Cancel + + + + Save + + + + diff --git a/client/src/features/book/components/detail/tabs/HighlightsExportMenu.vue b/client/src/features/book/components/detail/tabs/HighlightsExportMenu.vue new file mode 100644 index 000000000..0070b5d15 --- /dev/null +++ b/client/src/features/book/components/detail/tabs/HighlightsExportMenu.vue @@ -0,0 +1,132 @@ + + + + + + + Export + + + + + + + + + + Markdown + + + + Plain Text + + + + JSON + + + + diff --git a/client/src/features/book/components/detail/tabs/HighlightsFilterBar.vue b/client/src/features/book/components/detail/tabs/HighlightsFilterBar.vue new file mode 100644 index 000000000..741c24bef --- /dev/null +++ b/client/src/features/book/components/detail/tabs/HighlightsFilterBar.vue @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + All chapters + {{ ch }} + + + + + to + + + + + + + + diff --git a/client/src/features/book/components/detail/tabs/HighlightsTab.vue b/client/src/features/book/components/detail/tabs/HighlightsTab.vue new file mode 100644 index 000000000..a2b868bfb --- /dev/null +++ b/client/src/features/book/components/detail/tabs/HighlightsTab.vue @@ -0,0 +1,191 @@ + + + + + + {{ error }} + + + + + + + {{ sortLabel }} + + + + + + + + + + + No highlights yet + Select text while reading to create highlights + + + + + + + + + + + + + + Showing {{ startItem }}-{{ endItem }} of {{ total }} highlights + + + Prev + + {{ page }} / {{ totalPages }} + + Next + + + + + diff --git a/client/src/features/book/components/detail/tabs/__tests__/HighlightNoteEditor.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/HighlightNoteEditor.spec.ts new file mode 100644 index 000000000..3ec8cc233 --- /dev/null +++ b/client/src/features/book/components/detail/tabs/__tests__/HighlightNoteEditor.spec.ts @@ -0,0 +1,66 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import HighlightNoteEditor from '../HighlightNoteEditor.vue' + +describe('HighlightNoteEditor', () => { + it('renders textarea with initial note value', () => { + const wrapper = mount(HighlightNoteEditor, { + props: { initialNote: 'existing note' }, + }) + const textarea = wrapper.find('textarea') + expect(textarea.exists()).toBe(true) + expect((textarea.element as HTMLTextAreaElement).value).toBe('existing note') + }) + + it('renders empty textarea when initialNote is null', () => { + const wrapper = mount(HighlightNoteEditor, { + props: { initialNote: null }, + }) + const textarea = wrapper.find('textarea') + expect((textarea.element as HTMLTextAreaElement).value).toBe('') + }) + + it('emits save with trimmed note on save button click', async () => { + const wrapper = mount(HighlightNoteEditor, { + props: { initialNote: null }, + }) + const textarea = wrapper.find('textarea') + await textarea.setValue(' new note ') + + const saveBtn = wrapper.findAll('button').find((b) => b.text().includes('Save'))! + await saveBtn.trigger('click') + + expect(wrapper.emitted('save')).toBeTruthy() + expect(wrapper.emitted('save')![0]).toEqual(['new note']) + }) + + it('emits save with null when note is empty/whitespace', async () => { + const wrapper = mount(HighlightNoteEditor, { + props: { initialNote: 'old note' }, + }) + const textarea = wrapper.find('textarea') + await textarea.setValue(' ') + + const saveBtn = wrapper.findAll('button').find((b) => b.text().includes('Save'))! + await saveBtn.trigger('click') + + expect(wrapper.emitted('save')![0]).toEqual([null]) + }) + + it('emits cancel on cancel button click', async () => { + const wrapper = mount(HighlightNoteEditor, { + props: { initialNote: null }, + }) + const cancelBtn = wrapper.findAll('button').find((b) => b.text().includes('Cancel'))! + await cancelBtn.trigger('click') + + expect(wrapper.emitted('cancel')).toBeTruthy() + }) + + it('shows placeholder text', () => { + const wrapper = mount(HighlightNoteEditor, { + props: { initialNote: null }, + }) + expect(wrapper.find('textarea').attributes('placeholder')).toBe('Add a note...') + }) +}) diff --git a/client/src/features/book/components/detail/tabs/__tests__/HighlightsExportMenu.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/HighlightsExportMenu.spec.ts new file mode 100644 index 000000000..8d5c9629f --- /dev/null +++ b/client/src/features/book/components/detail/tabs/__tests__/HighlightsExportMenu.spec.ts @@ -0,0 +1,126 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi, afterEach } from 'vitest' +import HighlightsExportMenu from '../HighlightsExportMenu.vue' +import type { AnnotationItem } from '@bookorbit/types' + +function makeHighlight(overrides: Partial = {}): AnnotationItem { + return { + id: 1, + bookId: 5, + cfi: 'epubcfi(/6/4)', + text: 'highlighted text', + color: '#FACC15', + style: 'highlight', + note: null, + chapterTitle: 'Chapter 1', + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +function setupDownloadMocks() { + const createObjectURL = vi.fn<(obj: Blob) => string>().mockReturnValue('blob:url') + const revokeObjectURL = vi.fn<(url: string) => void>() + const origURL = globalThis.URL + Object.defineProperty(globalThis, 'URL', { + value: { ...origURL, createObjectURL, revokeObjectURL }, + writable: true, + configurable: true, + }) + + const clickMock = vi.fn<() => void>() + const origCreateElement = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag === 'a') { + return { href: '', download: '', click: clickMock } as unknown as HTMLAnchorElement + } + return origCreateElement(tag) + }) + + return { createObjectURL, revokeObjectURL, clickMock } +} + +function mountMenu(items: AnnotationItem[], bookTitle = 'Test Book') { + return mount(HighlightsExportMenu, { + props: { items, bookTitle }, + global: { stubs: { teleport: true } }, + }) +} + +describe('HighlightsExportMenu', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders export button', () => { + const wrapper = mountMenu([makeHighlight()]) + expect(wrapper.text()).toContain('Export') + }) + + it('disables button when no items', () => { + const wrapper = mountMenu([]) + const btn = wrapper.find('button') + expect(btn.attributes('disabled')).toBeDefined() + }) + + it('opens menu on click', async () => { + const wrapper = mountMenu([makeHighlight()]) + await wrapper.find('button').trigger('click') + expect(wrapper.text()).toContain('Markdown') + expect(wrapper.text()).toContain('Plain Text') + expect(wrapper.text()).toContain('JSON') + }) + + it('generates markdown export', async () => { + const { createObjectURL, clickMock, revokeObjectURL } = setupDownloadMocks() + + const wrapper = mountMenu([makeHighlight({ text: 'my highlight', note: 'my note', chapterTitle: 'Ch 1' })], 'Test Book') + + await wrapper.find('button').trigger('click') + const mdBtn = wrapper.findAll('button').find((b) => b.text().includes('Markdown'))! + await mdBtn.trigger('click') + + expect(createObjectURL).toHaveBeenCalled() + expect(clickMock).toHaveBeenCalled() + expect(revokeObjectURL).toHaveBeenCalled() + }) + + it('generates JSON export', async () => { + const { createObjectURL, clickMock } = setupDownloadMocks() + + const wrapper = mountMenu([makeHighlight()]) + + await wrapper.find('button').trigger('click') + const jsonBtn = wrapper.findAll('button').find((b) => b.text().includes('JSON'))! + await jsonBtn.trigger('click') + + expect(createObjectURL).toHaveBeenCalled() + expect(clickMock).toHaveBeenCalled() + }) + + it('generates plain text export', async () => { + const { createObjectURL, clickMock } = setupDownloadMocks() + + const wrapper = mountMenu([makeHighlight()]) + + await wrapper.find('button').trigger('click') + const txtBtn = wrapper.findAll('button').find((b) => b.text().includes('Plain Text'))! + await txtBtn.trigger('click') + + expect(createObjectURL).toHaveBeenCalled() + expect(clickMock).toHaveBeenCalled() + }) + + it('closes menu after export', async () => { + setupDownloadMocks() + + const wrapper = mountMenu([makeHighlight()]) + await wrapper.find('button').trigger('click') + expect(wrapper.text()).toContain('Markdown') + + const mdBtn = wrapper.findAll('button').find((b) => b.text().includes('Markdown'))! + await mdBtn.trigger('click') + + expect(wrapper.text()).not.toContain('Plain Text') + }) +}) diff --git a/client/src/features/book/components/detail/tabs/__tests__/HighlightsFilterBar.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/HighlightsFilterBar.spec.ts new file mode 100644 index 000000000..945fac0c2 --- /dev/null +++ b/client/src/features/book/components/detail/tabs/__tests__/HighlightsFilterBar.spec.ts @@ -0,0 +1,110 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import HighlightsFilterBar from '../HighlightsFilterBar.vue' + +function makeProps(overrides = {}) { + return { + activeColors: [] as string[], + chapters: ['Chapter 1', 'Chapter 2'], + selectedChapter: undefined as string | undefined, + dateFrom: undefined as string | undefined, + dateTo: undefined as string | undefined, + ...overrides, + } +} + +describe('HighlightsFilterBar', () => { + it('renders search input', () => { + const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) + const input = wrapper.find('input[type="text"]') + expect(input.exists()).toBe(true) + expect(input.attributes('placeholder')).toBe('Search highlights...') + }) + + it('renders color filter buttons', () => { + const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) + const colorButtons = wrapper.findAll('button[title]') + expect(colorButtons.length).toBe(5) + }) + + it('emits toggleColor when a color button is clicked', async () => { + const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) + const yellowBtn = wrapper.find('button[title="Yellow"]') + await yellowBtn.trigger('click') + expect(wrapper.emitted('toggleColor')).toBeTruthy() + expect(wrapper.emitted('toggleColor')![0]).toEqual(['#FACC15']) + }) + + it('highlights active color buttons', () => { + const wrapper = mount(HighlightsFilterBar, { + props: makeProps({ activeColors: ['#FACC15'] }), + }) + const yellowBtn = wrapper.find('button[title="Yellow"]') + expect(yellowBtn.classes()).toContain('border-foreground') + }) + + it('renders chapter dropdown when chapters exist', () => { + const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) + const select = wrapper.find('select') + expect(select.exists()).toBe(true) + const options = select.findAll('option') + expect(options.length).toBe(3) + expect(options[0]?.text()).toBe('All chapters') + }) + + it('does not render chapter dropdown when no chapters', () => { + const wrapper = mount(HighlightsFilterBar, { + props: makeProps({ chapters: [] }), + }) + expect(wrapper.find('select').exists()).toBe(false) + }) + + it('emits chapterChange when chapter is selected', async () => { + const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) + const select = wrapper.find('select') + await select.setValue('Chapter 1') + expect(wrapper.emitted('chapterChange')).toBeTruthy() + expect(wrapper.emitted('chapterChange')![0]).toEqual(['Chapter 1']) + }) + + it('renders date range inputs', () => { + const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) + const dateInputs = wrapper.findAll('input[type="date"]') + expect(dateInputs.length).toBe(2) + }) + + it('emits dateRangeChange when date from changes', async () => { + const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) + const dateInputs = wrapper.findAll('input[type="date"]') + await dateInputs[0]?.setValue('2026-01-01') + expect(wrapper.emitted('dateRangeChange')).toBeTruthy() + expect(wrapper.emitted('dateRangeChange')![0]).toEqual(['2026-01-01', undefined]) + }) + + it('emits search with debounce on input', async () => { + vi.useFakeTimers() + const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) + const input = wrapper.find('input[type="text"]') + + await input.setValue('test') + await input.trigger('input') + expect(wrapper.emitted('search')).toBeFalsy() + + vi.advanceTimersByTime(300) + expect(wrapper.emitted('search')).toBeTruthy() + + vi.useRealTimers() + }) + + it('shows clear button when search has value and emits on click', async () => { + vi.useFakeTimers() + const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) + const input = wrapper.find('input[type="text"]') + + await input.setValue('test') + const clearBtn = wrapper.findAll('button').find((b) => !b.attributes('title')) + expect(clearBtn).toBeTruthy() + + vi.useRealTimers() + }) +}) diff --git a/client/src/features/book/composables/useBookHighlights.test.ts b/client/src/features/book/composables/useBookHighlights.test.ts new file mode 100644 index 000000000..bb04fbaef --- /dev/null +++ b/client/src/features/book/composables/useBookHighlights.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref, nextTick } from 'vue' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockFetch = vi.fn<(...args: any[]) => any>() +vi.mock('@/lib/api', () => ({ + api: (...args: unknown[]) => mockFetch(...args), +})) + +import { useBookHighlights } from './useBookHighlights' + +function makeListResponse(overrides?: Record) { + return { + items: [ + { + id: 1, + bookId: 5, + cfi: 'epubcfi(/6/4!/4/2/1:0)', + text: 'highlighted text', + color: '#FACC15', + style: 'highlight', + note: null, + chapterTitle: 'Chapter 1', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + total: 1, + page: 1, + pageSize: 25, + stats: { + totalHighlights: 1, + colorBreakdown: [{ color: '#FACC15', count: 1 }], + chaptersWithHighlights: 1, + highlightsWithNotes: 0, + chapters: ['Chapter 1'], + }, + ...overrides, + } +} + +function mockOkResponse(data: unknown) { + return { ok: true, json: () => Promise.resolve(data) } +} + +function mockErrorResponse(status = 500) { + return { ok: false, status, json: () => Promise.resolve({}) } +} + +describe('useBookHighlights', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetches highlights on mount', async () => { + const response = makeListResponse() + mockFetch.mockResolvedValue(mockOkResponse(response)) + const bookId = ref(5) + const { items, total, stats, loading } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(loading.value).toBe(false)) + + expect(items.value).toHaveLength(1) + expect(total.value).toBe(1) + expect(stats.value?.totalHighlights).toBe(1) + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/v1/books/5/annotations?')) + }) + + it('sets error on failed fetch', async () => { + mockFetch.mockResolvedValue(mockErrorResponse()) + const bookId = ref(5) + const { error, loading } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(loading.value).toBe(false)) + + expect(error.value).toBe('HTTP 500') + }) + + it('includes pagination params in URL', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + + const url = mockFetch.mock.calls[0]?.[0] as string + expect(url).toContain('page=1') + expect(url).toContain('pageSize=25') + expect(url).toContain('sortBy=position') + expect(url).toContain('sortDir=asc') + }) + + it('toggleColor adds and removes colors', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { colors, toggleColor } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + mockFetch.mockClear() + + toggleColor('#FACC15') + expect(colors.value).toContain('#FACC15') + + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + const url = mockFetch.mock.calls[0]?.[0] as string + expect(url).toContain('colors=%23FACC15') + + mockFetch.mockClear() + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + toggleColor('#FACC15') + expect(colors.value).not.toContain('#FACC15') + }) + + it('setSort changes sort params and resets page', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { page, sortBy, sortDir, setSort } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + mockFetch.mockClear() + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + + setSort('createdAt', 'desc') + + expect(sortBy.value).toBe('createdAt') + expect(sortDir.value).toBe('desc') + expect(page.value).toBe(1) + }) + + it('setPage changes the page', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { page, setPage } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + mockFetch.mockClear() + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + + setPage(3) + + expect(page.value).toBe(3) + }) + + it('setSearch updates search and resets page', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { search, setSearch } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + mockFetch.mockClear() + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + + setSearch('freedom') + + expect(search.value).toBe('freedom') + }) + + it('updateNote patches annotation and updates items', async () => { + const initial = makeListResponse() + mockFetch.mockResolvedValue(mockOkResponse(initial)) + const bookId = ref(5) + const { items, updateNote } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(items.value).toHaveLength(1)) + mockFetch.mockClear() + + const updatedItem = { ...initial.items[0], note: 'my note' } + mockFetch.mockResolvedValue(mockOkResponse(updatedItem)) + + await updateNote(1, 'my note') + + expect(mockFetch).toHaveBeenCalledWith('/api/v1/books/5/annotations/1', expect.objectContaining({ method: 'PATCH' })) + expect(items.value[0]?.note).toBe('my note') + }) + + it('updateColor patches annotation color', async () => { + const initial = makeListResponse() + mockFetch.mockResolvedValue(mockOkResponse(initial)) + const bookId = ref(5) + const { items, updateColor } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(items.value).toHaveLength(1)) + mockFetch.mockClear() + + const updatedItem = { ...initial.items[0], color: '#4ADE80' } + mockFetch.mockResolvedValue(mockOkResponse(updatedItem)) + + await updateColor(1, '#4ADE80') + + expect(items.value[0]?.color).toBe('#4ADE80') + }) + + it('deleteHighlight removes item optimistically and refetches', async () => { + const initial = makeListResponse() + mockFetch.mockResolvedValue(mockOkResponse(initial)) + const bookId = ref(5) + const { items, deleteHighlight } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(items.value).toHaveLength(1)) + mockFetch.mockClear() + + mockFetch.mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce(mockOkResponse(makeListResponse({ items: [], total: 0 }))) + + await deleteHighlight(1) + + expect(mockFetch).toHaveBeenCalledWith('/api/v1/books/5/annotations/1', expect.objectContaining({ method: 'DELETE' })) + }) + + it('deleteHighlight reverts on failure', async () => { + const initial = makeListResponse() + mockFetch.mockResolvedValue(mockOkResponse(initial)) + const bookId = ref(5) + const { items, total, error, deleteHighlight } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(items.value).toHaveLength(1)) + mockFetch.mockClear() + + mockFetch.mockResolvedValue(mockErrorResponse()) + + await deleteHighlight(1) + + expect(items.value).toHaveLength(1) + expect(total.value).toBe(1) + expect(error.value).toBeTruthy() + }) + + it('resets state when bookId changes', async () => { + const initial = makeListResponse() + mockFetch.mockResolvedValue(mockOkResponse(initial)) + const bookId = ref(5) + const { colors, search, chapter, page, toggleColor, setSearch, setChapter, setPage } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + + toggleColor('#FACC15') + setSearch('test') + setChapter('Chapter 1') + setPage(3) + + mockFetch.mockClear() + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + + bookId.value = 10 + + await nextTick() + + expect(colors.value).toEqual([]) + expect(search.value).toBe('') + expect(chapter.value).toBeUndefined() + expect(page.value).toBe(1) + }) + + it('setDateRange updates date filters', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { dateFrom, dateTo, setDateRange } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + mockFetch.mockClear() + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + + setDateRange('2026-01-01', '2026-06-01') + + expect(dateFrom.value).toBe('2026-01-01') + expect(dateTo.value).toBe('2026-06-01') + }) +}) diff --git a/client/src/features/book/composables/useBookHighlights.ts b/client/src/features/book/composables/useBookHighlights.ts new file mode 100644 index 000000000..e76efe751 --- /dev/null +++ b/client/src/features/book/composables/useBookHighlights.ts @@ -0,0 +1,184 @@ +import { computed, ref, watch, type Ref } from 'vue' +import type { AnnotationItem, AnnotationListResponse, AnnotationStats } from '@bookorbit/types' +import { api } from '@/lib/api' + +export function useBookHighlights(bookIdRef: Ref) { + const items = ref([]) + const total = ref(0) + const stats = ref(null) + const loading = ref(false) + const error = ref(null) + const page = ref(1) + const pageSize = ref(25) + const sortBy = ref<'position' | 'createdAt'>('position') + const sortDir = ref<'asc' | 'desc'>('asc') + const colors = ref([]) + const search = ref('') + const chapter = ref(undefined) + const dateFrom = ref(undefined) + const dateTo = ref(undefined) + + async function fetchHighlights() { + const bookId = bookIdRef.value + loading.value = true + error.value = null + try { + const params = new URLSearchParams({ + page: String(page.value), + pageSize: String(pageSize.value), + sortBy: sortBy.value, + sortDir: sortDir.value, + }) + if (colors.value.length > 0) params.set('colors', colors.value.join(',')) + if (search.value.trim()) params.set('search', search.value.trim()) + if (chapter.value) params.set('chapter', chapter.value) + if (dateFrom.value) params.set('dateFrom', dateFrom.value) + if (dateTo.value) params.set('dateTo', dateTo.value) + + const res = await api(`/api/v1/books/${bookId}/annotations?${params.toString()}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data: AnnotationListResponse = await res.json() + items.value = data.items + total.value = data.total + stats.value = data.stats + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to load highlights' + } finally { + loading.value = false + } + } + + async function updateNote(annotationId: number, note: string | null) { + try { + const bookId = bookIdRef.value + const res = await api(`/api/v1/books/${bookId}/annotations/${annotationId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ note }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const updated: AnnotationItem = await res.json() + items.value = items.value.map((a) => (a.id === annotationId ? updated : a)) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to update note' + } + } + + async function updateColor(annotationId: number, color: string) { + try { + const bookId = bookIdRef.value + const res = await api(`/api/v1/books/${bookId}/annotations/${annotationId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ color }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const updated: AnnotationItem = await res.json() + items.value = items.value.map((a) => (a.id === annotationId ? updated : a)) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to update color' + } + } + + async function deleteHighlight(annotationId: number) { + const bookId = bookIdRef.value + const prev = items.value + const prevTotal = total.value + items.value = items.value.filter((a) => a.id !== annotationId) + total.value = Math.max(0, total.value - 1) + try { + const res = await api(`/api/v1/books/${bookId}/annotations/${annotationId}`, { method: 'DELETE' }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + await fetchHighlights() + } catch (e) { + items.value = prev + total.value = prevTotal + error.value = e instanceof Error ? e.message : 'Failed to delete highlight' + } + } + + function setPage(p: number) { + page.value = p + void fetchHighlights() + } + + function setSort(by: typeof sortBy.value, dir: typeof sortDir.value) { + sortBy.value = by + sortDir.value = dir + page.value = 1 + void fetchHighlights() + } + + function toggleColor(color: string) { + if (colors.value.includes(color)) { + colors.value = colors.value.filter((c) => c !== color) + } else { + colors.value = [...colors.value, color] + } + page.value = 1 + void fetchHighlights() + } + + function setSearch(q: string) { + search.value = q + page.value = 1 + void fetchHighlights() + } + + function setChapter(ch: string | undefined) { + chapter.value = ch + page.value = 1 + void fetchHighlights() + } + + function setDateRange(from: string | undefined, to: string | undefined) { + dateFrom.value = from + dateTo.value = to + page.value = 1 + void fetchHighlights() + } + + watch( + bookIdRef, + () => { + page.value = 1 + colors.value = [] + search.value = '' + chapter.value = undefined + dateFrom.value = undefined + dateTo.value = undefined + void fetchHighlights() + }, + { immediate: true }, + ) + + const chapters = computed(() => stats.value?.chapters ?? []) + + return { + items, + total, + stats, + loading, + error, + page, + pageSize, + sortBy, + sortDir, + colors, + search, + chapter, + chapters, + dateFrom, + dateTo, + fetchHighlights, + updateNote, + updateColor, + deleteHighlight, + setPage, + setSort, + toggleColor, + setSearch, + setChapter, + setDateRange, + } +} diff --git a/client/src/features/book/lib/book-detail-tabs.ts b/client/src/features/book/lib/book-detail-tabs.ts index e490838eb..7a928ba68 100644 --- a/client/src/features/book/lib/book-detail-tabs.ts +++ b/client/src/features/book/lib/book-detail-tabs.ts @@ -1,4 +1,4 @@ -export const BOOK_DETAIL_TABS = ['details', 'edit', 'files', 'reading-log'] as const +export const BOOK_DETAIL_TABS = ['details', 'edit', 'files', 'reading-log', 'highlights'] as const export type BookDetailTab = (typeof BOOK_DETAIL_TABS)[number] diff --git a/client/src/views/BookDetailView.vue b/client/src/views/BookDetailView.vue index 60476a6e4..2c1e27aa4 100644 --- a/client/src/views/BookDetailView.vue +++ b/client/src/views/BookDetailView.vue @@ -17,6 +17,7 @@ import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/b import EntityNotFound from '@/components/EntityNotFound.vue' const ReadingLogTab = defineAsyncComponent(() => import('@/features/book/components/detail/tabs/ReadingLogTab.vue')) +const HighlightsTab = defineAsyncComponent(() => import('@/features/book/components/detail/tabs/HighlightsTab.vue')) const route = useRoute() const { hasPermission } = usePermissions() @@ -41,6 +42,7 @@ const pageTitle = computed(() => { if (tab.value === 'edit') return `Edit Metadata · ${base}` if (tab.value === 'files') return `Files · ${base}` if (tab.value === 'reading-log') return `Reading Log · ${base}` + if (tab.value === 'highlights') return `Highlights · ${base}` return `Book · ${base}` }) usePageTitle(pageTitle) @@ -95,6 +97,7 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) { /> + @@ -138,6 +141,15 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) { + + + + + + + + + diff --git a/packages/types/src/annotation.ts b/packages/types/src/annotation.ts new file mode 100644 index 000000000..3e13be94d --- /dev/null +++ b/packages/types/src/annotation.ts @@ -0,0 +1,27 @@ +export interface AnnotationItem { + id: number; + bookId: number; + cfi: string; + text: string; + color: string; + style: string; + note: string | null; + chapterTitle: string | null; + createdAt: string; +} + +export interface AnnotationStats { + totalHighlights: number; + colorBreakdown: { color: string; count: number }[]; + chaptersWithHighlights: number; + highlightsWithNotes: number; + chapters: string[]; +} + +export interface AnnotationListResponse { + items: AnnotationItem[]; + total: number; + page: number; + pageSize: number; + stats: AnnotationStats; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7b8813f50..e79ce1f27 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -37,3 +37,4 @@ export * from "./font"; export * from "./dictionary"; export * from "./achievement"; export * from "./reading-session"; +export * from "./annotation"; diff --git a/server/src/modules/annotation/annotation.controller.test.ts b/server/src/modules/annotation/annotation.controller.test.ts index c28726901..82d5b9036 100644 --- a/server/src/modules/annotation/annotation.controller.test.ts +++ b/server/src/modules/annotation/annotation.controller.test.ts @@ -1,5 +1,6 @@ import type { RequestUser } from '../../common/types/request-user'; import { AnnotationController } from './annotation.controller'; +import type { AnnotationQueryDto } from './dto/annotation-query.dto'; function makeUser(overrides?: Partial): RequestUser { return { @@ -22,6 +23,7 @@ function makeUser(overrides?: Partial): RequestUser { function makeController() { const annotationService = { getAnnotations: vi.fn(), + getAnnotationsPaginated: vi.fn(), createAnnotation: vi.fn(), updateAnnotation: vi.fn(), deleteAnnotation: vi.fn(), @@ -34,18 +36,53 @@ function makeController() { } describe('AnnotationController', () => { - it('delegates getAnnotations with bookId and current user', async () => { + it('delegates getAnnotations with bookId and current user when no page param', async () => { const { controller, annotationService } = makeController(); const expected = [{ id: 1 }]; annotationService.getAnnotations.mockResolvedValue(expected); const user = makeUser(); + const query: AnnotationQueryDto = {}; - const result = await controller.getAnnotations(15, user); + const result = await controller.getAnnotations(15, user, query); expect(annotationService.getAnnotations).toHaveBeenCalledWith(15, user); + expect(annotationService.getAnnotationsPaginated).not.toHaveBeenCalled(); expect(result).toEqual(expected); }); + it('delegates getAnnotationsPaginated when page param is present', async () => { + const { controller, annotationService } = makeController(); + const expected = { items: [], total: 0, page: 1, pageSize: 25, stats: {} }; + annotationService.getAnnotationsPaginated.mockResolvedValue(expected); + const user = makeUser(); + const query: AnnotationQueryDto = { page: 1, pageSize: 25 }; + + const result = await controller.getAnnotations(15, user, query); + + expect(annotationService.getAnnotationsPaginated).toHaveBeenCalledWith(15, user, query); + expect(annotationService.getAnnotations).not.toHaveBeenCalled(); + expect(result).toEqual(expected); + }); + + it('passes filter params through to paginated service', async () => { + const { controller, annotationService } = makeController(); + annotationService.getAnnotationsPaginated.mockResolvedValue({ items: [], total: 0 }); + const user = makeUser(); + const query: AnnotationQueryDto = { + page: 2, + pageSize: 10, + sortBy: 'createdAt', + sortDir: 'desc', + colors: '#FACC15,#4ADE80', + search: 'freedom', + chapter: 'Chapter 1', + }; + + await controller.getAnnotations(15, user, query); + + expect(annotationService.getAnnotationsPaginated).toHaveBeenCalledWith(15, user, query); + }); + it('delegates createAnnotation with dto payload', async () => { const { controller, annotationService } = makeController(); const dto = { cfi: 'epubcfi(/6/4)', text: 'selected text', note: null }; diff --git a/server/src/modules/annotation/annotation.controller.ts b/server/src/modules/annotation/annotation.controller.ts index 6b9e32b36..93f747f4d 100644 --- a/server/src/modules/annotation/annotation.controller.ts +++ b/server/src/modules/annotation/annotation.controller.ts @@ -1,17 +1,21 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post, Query } from '@nestjs/common'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import type { RequestUser } from '../../common/types/request-user'; import { AnnotationService } from './annotation.service'; import { CreateAnnotationDto } from './dto/create-annotation.dto'; import { UpdateAnnotationDto } from './dto/update-annotation.dto'; +import { AnnotationQueryDto } from './dto/annotation-query.dto'; @Controller('books/:bookId/annotations') export class AnnotationController { constructor(private readonly annotationService: AnnotationService) {} @Get() - getAnnotations(@Param('bookId', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser) { + getAnnotations(@Param('bookId', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser, @Query() query: AnnotationQueryDto) { + if (query.page != null) { + return this.annotationService.getAnnotationsPaginated(bookId, user, query); + } return this.annotationService.getAnnotations(bookId, user); } diff --git a/server/src/modules/annotation/annotation.repository.test.ts b/server/src/modules/annotation/annotation.repository.test.ts index d06706f5a..c1e2abdba 100644 --- a/server/src/modules/annotation/annotation.repository.test.ts +++ b/server/src/modules/annotation/annotation.repository.test.ts @@ -68,6 +68,60 @@ function makeRepository() { return { repo, db }; } +function makePaginatedDb(items: ReturnType[], total: number) { + const offsetFn = vi.fn().mockResolvedValue(items); + const limitFn = vi.fn().mockReturnValue({ offset: offsetFn }); + const orderByFn = vi.fn().mockReturnValue({ limit: limitFn }); + const whereFn = vi + .fn() + .mockReturnValueOnce({ orderBy: orderByFn }) + .mockReturnValueOnce([{ count: total }]); + const fromFn = vi.fn().mockReturnValue({ where: whereFn }); + const selectFn = vi.fn().mockReturnValue({ from: fromFn }); + + return { + select: selectFn, + _offset: offsetFn, + }; +} + +function makeStatsDb( + aggregate: { totalHighlights: number; chaptersWithHighlights: number; highlightsWithNotes: number }, + colorBreakdown: { color: string; count: number }[], +) { + let callCount = 0; + const selectFn = vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([aggregate]), + }), + }; + } + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + groupBy: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue(colorBreakdown), + }), + }), + }), + }; + }); + + return { select: selectFn }; +} + +function makeDistinctChaptersDb(chapters: (string | null)[]) { + const orderByFn = vi.fn().mockResolvedValue(chapters.map((c) => ({ chapterTitle: c }))); + const whereFn = vi.fn().mockReturnValue({ orderBy: orderByFn }); + const fromFn = vi.fn().mockReturnValue({ where: whereFn }); + const selectDistinctFn = vi.fn().mockReturnValue({ from: fromFn }); + + return { selectDistinct: selectDistinctFn }; +} + describe('AnnotationRepository', () => { describe('findByBookId', () => { it('queries with bookId and userId filters and applies createdAt ordering', async () => { @@ -201,4 +255,94 @@ describe('AnnotationRepository', () => { expect(result).toBe(false); }); }); + + describe('findPaginated', () => { + it('returns items and total count', async () => { + const db = makePaginatedDb([makeRow()], 1); + const repo = new AnnotationRepository(db as never); + + const result = await repo.findPaginated(5, 10, {}, { by: 'position', dir: 'asc' }, 1, 25); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('returns empty result when no annotations match', async () => { + const db = makePaginatedDb([], 0); + const repo = new AnnotationRepository(db as never); + + const result = await repo.findPaginated(5, 10, {}, { by: 'position', dir: 'asc' }, 1, 25); + + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + }); + + it('applies offset based on page and pageSize', async () => { + const db = makePaginatedDb([], 0); + const repo = new AnnotationRepository(db as never); + + await repo.findPaginated(5, 10, {}, { by: 'position', dir: 'asc' }, 3, 10); + + expect(db._offset).toHaveBeenCalledWith(20); + }); + }); + + describe('getStats', () => { + it('returns aggregated stats', async () => { + const db = makeStatsDb({ totalHighlights: 5, chaptersWithHighlights: 2, highlightsWithNotes: 3 }, [ + { color: 'yellow', count: 3 }, + { color: '#4ADE80', count: 2 }, + ]); + const repo = new AnnotationRepository(db as never); + + const result = await repo.getStats(5, 10, {}); + + expect(result.totalHighlights).toBe(5); + expect(result.chaptersWithHighlights).toBe(2); + expect(result.highlightsWithNotes).toBe(3); + expect(result.colorBreakdown).toEqual([ + { color: 'yellow', count: 3 }, + { color: '#4ADE80', count: 2 }, + ]); + }); + + it('returns zero stats when no annotations exist', async () => { + const db = makeStatsDb({ totalHighlights: 0, chaptersWithHighlights: 0, highlightsWithNotes: 0 }, []); + const repo = new AnnotationRepository(db as never); + + const result = await repo.getStats(5, 10, {}); + + expect(result.totalHighlights).toBe(0); + expect(result.colorBreakdown).toEqual([]); + }); + }); + + describe('getDistinctChapters', () => { + it('returns distinct chapter titles', async () => { + const db = makeDistinctChaptersDb(['Chapter 1', 'Chapter 2']); + const repo = new AnnotationRepository(db as never); + + const result = await repo.getDistinctChapters(5, 10); + + expect(result).toEqual(['Chapter 1', 'Chapter 2']); + }); + + it('returns empty array when no chapters exist', async () => { + const db = makeDistinctChaptersDb([]); + const repo = new AnnotationRepository(db as never); + + const result = await repo.getDistinctChapters(5, 10); + + expect(result).toEqual([]); + }); + + it('filters out null chapter titles', async () => { + const db = makeDistinctChaptersDb([null, 'Chapter 1']); + const repo = new AnnotationRepository(db as never); + + const result = await repo.getDistinctChapters(5, 10); + + expect(result).toEqual(['Chapter 1']); + }); + }); }); diff --git a/server/src/modules/annotation/annotation.repository.ts b/server/src/modules/annotation/annotation.repository.ts index 907b131cb..3f1061551 100644 --- a/server/src/modules/annotation/annotation.repository.ts +++ b/server/src/modules/annotation/annotation.repository.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { and, asc, eq, sql } from 'drizzle-orm'; +import { SQL, and, asc, count, countDistinct, desc, eq, gte, ilike, isNotNull, lte, inArray, or, sql } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { DB } from '../../db'; @@ -8,6 +8,31 @@ import { annotations, NewAnnotation } from '../../db/schema'; type Db = NodePgDatabase; +export interface AnnotationFilters { + colors?: string[]; + search?: string; + chapter?: string; + dateFrom?: Date; + dateTo?: Date; +} + +export interface AnnotationSort { + by: 'position' | 'createdAt'; + dir: 'asc' | 'desc'; +} + +export interface PaginatedAnnotations { + items: (typeof annotations.$inferSelect)[]; + total: number; +} + +export interface AnnotationStatsResult { + totalHighlights: number; + colorBreakdown: { color: string; count: number }[]; + chaptersWithHighlights: number; + highlightsWithNotes: number; +} + @Injectable() export class AnnotationRepository { constructor(@Inject(DB) private readonly db: Db) {} @@ -20,6 +45,78 @@ export class AnnotationRepository { .orderBy(asc(annotations.createdAt)); } + async findPaginated( + bookId: number, + userId: number, + filters: AnnotationFilters, + sort: AnnotationSort, + page: number, + pageSize: number, + ): Promise { + const conditions = this.buildConditions(bookId, userId, filters); + const orderBy = this.buildOrderBy(sort); + const offset = (page - 1) * pageSize; + + const [items, totalResult] = await Promise.all([ + this.db + .select() + .from(annotations) + .where(and(...conditions)) + .orderBy(...orderBy) + .limit(pageSize) + .offset(offset), + this.db + .select({ count: count() }) + .from(annotations) + .where(and(...conditions)), + ]); + + return { items, total: totalResult[0]?.count ?? 0 }; + } + + async getStats(bookId: number, userId: number, filters: AnnotationFilters): Promise { + const conditions = this.buildConditions(bookId, userId, filters); + + const [aggregateResult, colorResult] = await Promise.all([ + this.db + .select({ + totalHighlights: count(), + chaptersWithHighlights: countDistinct(annotations.chapterTitle), + highlightsWithNotes: count(sql`case when ${annotations.note} is not null and ${annotations.note} != '' then 1 end`), + }) + .from(annotations) + .where(and(...conditions)), + this.db + .select({ + color: annotations.color, + count: count(), + }) + .from(annotations) + .where(and(...conditions)) + .groupBy(annotations.color) + .orderBy(desc(count())), + ]); + + const agg = aggregateResult[0]; + + return { + totalHighlights: agg?.totalHighlights ?? 0, + chaptersWithHighlights: agg?.chaptersWithHighlights ?? 0, + highlightsWithNotes: agg?.highlightsWithNotes ?? 0, + colorBreakdown: colorResult.map((r) => ({ color: r.color, count: r.count })), + }; + } + + async getDistinctChapters(bookId: number, userId: number): Promise { + const rows = await this.db + .selectDistinct({ chapterTitle: annotations.chapterTitle }) + .from(annotations) + .where(and(eq(annotations.bookId, bookId), eq(annotations.userId, userId), isNotNull(annotations.chapterTitle))) + .orderBy(asc(annotations.chapterTitle)); + + return rows.map((r) => r.chapterTitle).filter((t): t is string => t != null); + } + async create(data: NewAnnotation) { const [row] = await this.db.insert(annotations).values(data).returning(); return row; @@ -41,4 +138,35 @@ export class AnnotationRepository { .returning({ id: annotations.id }); return result.length > 0; } + + private buildConditions(bookId: number, userId: number, filters: AnnotationFilters): SQL[] { + const conditions: SQL[] = [eq(annotations.bookId, bookId), eq(annotations.userId, userId)]; + + if (filters.colors && filters.colors.length > 0) { + conditions.push(inArray(annotations.color, filters.colors)); + } + if (filters.search) { + const pattern = `%${filters.search}%`; + conditions.push(or(ilike(annotations.text, pattern), ilike(annotations.note, pattern))!); + } + if (filters.chapter) { + conditions.push(eq(annotations.chapterTitle, filters.chapter)); + } + if (filters.dateFrom) { + conditions.push(gte(annotations.createdAt, filters.dateFrom)); + } + if (filters.dateTo) { + conditions.push(lte(annotations.createdAt, filters.dateTo)); + } + + return conditions; + } + + private buildOrderBy(sort: AnnotationSort) { + const direction = sort.dir === 'desc' ? desc : asc; + if (sort.by === 'position') { + return [direction(annotations.cfi), direction(annotations.id)]; + } + return [direction(annotations.createdAt), direction(annotations.id)]; + } } diff --git a/server/src/modules/annotation/annotation.service.test.ts b/server/src/modules/annotation/annotation.service.test.ts index d233144ad..71c437050 100644 --- a/server/src/modules/annotation/annotation.service.test.ts +++ b/server/src/modules/annotation/annotation.service.test.ts @@ -6,6 +6,7 @@ import { AnnotationService } from './annotation.service'; import { AnnotationResponseDto } from './dto/annotation-response.dto'; import type { CreateAnnotationDto } from './dto/create-annotation.dto'; import type { UpdateAnnotationDto } from './dto/update-annotation.dto'; +import type { AnnotationQueryDto } from './dto/annotation-query.dto'; function makeUser(overrides?: Partial): RequestUser { return { @@ -45,6 +46,9 @@ function makeAnnotationRow(overrides?: Record) { function makeService() { const annotationRepo = { findByBookId: vi.fn(), + findPaginated: vi.fn(), + getStats: vi.fn(), + getDistinctChapters: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), @@ -368,4 +372,183 @@ describe('AnnotationService', () => { expect(dto.chapterTitle).toBeNull(); }); }); + + describe('getAnnotationsPaginated', () => { + function makeStatsResult(overrides?: Record) { + return { + totalHighlights: 3, + colorBreakdown: [ + { color: 'yellow', count: 2 }, + { color: '#4ADE80', count: 1 }, + ], + chaptersWithHighlights: 2, + highlightsWithNotes: 1, + ...overrides, + }; + } + + it('returns paginated response with items, total, and stats', async () => { + const { service, annotationRepo } = makeService(); + const rows = [makeAnnotationRow(), makeAnnotationRow({ id: 11 })]; + annotationRepo.findPaginated.mockResolvedValue({ items: rows, total: 2 }); + annotationRepo.getStats.mockResolvedValue(makeStatsResult()); + annotationRepo.getDistinctChapters.mockResolvedValue(['Chapter 1', 'Chapter 2']); + + const query: AnnotationQueryDto = { page: 1, pageSize: 25 }; + const result = await service.getAnnotationsPaginated(5, makeUser(), query); + + expect(result.items).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.page).toBe(1); + expect(result.pageSize).toBe(25); + expect(result.stats.totalHighlights).toBe(3); + expect(result.stats.chapters).toEqual(['Chapter 1', 'Chapter 2']); + }); + + it('verifies book access before querying', async () => { + const { service, annotationRepo, bookService } = makeService(); + annotationRepo.findPaginated.mockResolvedValue({ items: [], total: 0 }); + annotationRepo.getStats.mockResolvedValue( + makeStatsResult({ totalHighlights: 0, colorBreakdown: [], chaptersWithHighlights: 0, highlightsWithNotes: 0 }), + ); + annotationRepo.getDistinctChapters.mockResolvedValue([]); + + await service.getAnnotationsPaginated(5, makeUser(), { page: 1 }); + + expect(bookService.verifyBookAccess).toHaveBeenCalledWith(5, expect.objectContaining({ id: 1 })); + }); + + it('propagates ForbiddenException from book access check', async () => { + const { service, bookService } = makeService(); + bookService.verifyBookAccess.mockRejectedValue(new ForbiddenException()); + + await expect(service.getAnnotationsPaginated(5, makeUser(), { page: 1 })).rejects.toThrow(ForbiddenException); + }); + + it('defaults to page 1 and pageSize 25 when not specified', async () => { + const { service, annotationRepo } = makeService(); + annotationRepo.findPaginated.mockResolvedValue({ items: [], total: 0 }); + annotationRepo.getStats.mockResolvedValue( + makeStatsResult({ totalHighlights: 0, colorBreakdown: [], chaptersWithHighlights: 0, highlightsWithNotes: 0 }), + ); + annotationRepo.getDistinctChapters.mockResolvedValue([]); + + const result = await service.getAnnotationsPaginated(5, makeUser(), {}); + + expect(result.page).toBe(1); + expect(result.pageSize).toBe(25); + }); + + it('parses color filter from comma-separated string', async () => { + const { service, annotationRepo } = makeService(); + annotationRepo.findPaginated.mockResolvedValue({ items: [], total: 0 }); + annotationRepo.getStats.mockResolvedValue( + makeStatsResult({ totalHighlights: 0, colorBreakdown: [], chaptersWithHighlights: 0, highlightsWithNotes: 0 }), + ); + annotationRepo.getDistinctChapters.mockResolvedValue([]); + + await service.getAnnotationsPaginated(5, makeUser(), { page: 1, colors: '#FACC15,#4ADE80' }); + + const filtersArg = annotationRepo.findPaginated.mock.calls[0][2]; + expect(filtersArg.colors).toEqual(['#FACC15', '#4ADE80']); + }); + + it('passes search filter to repository', async () => { + const { service, annotationRepo } = makeService(); + annotationRepo.findPaginated.mockResolvedValue({ items: [], total: 0 }); + annotationRepo.getStats.mockResolvedValue( + makeStatsResult({ totalHighlights: 0, colorBreakdown: [], chaptersWithHighlights: 0, highlightsWithNotes: 0 }), + ); + annotationRepo.getDistinctChapters.mockResolvedValue([]); + + await service.getAnnotationsPaginated(5, makeUser(), { page: 1, search: 'freedom' }); + + const filtersArg = annotationRepo.findPaginated.mock.calls[0][2]; + expect(filtersArg.search).toBe('freedom'); + }); + + it('passes date filters to repository', async () => { + const { service, annotationRepo } = makeService(); + annotationRepo.findPaginated.mockResolvedValue({ items: [], total: 0 }); + annotationRepo.getStats.mockResolvedValue( + makeStatsResult({ totalHighlights: 0, colorBreakdown: [], chaptersWithHighlights: 0, highlightsWithNotes: 0 }), + ); + annotationRepo.getDistinctChapters.mockResolvedValue([]); + + await service.getAnnotationsPaginated(5, makeUser(), { page: 1, dateFrom: '2026-01-01', dateTo: '2026-06-01' }); + + const filtersArg = annotationRepo.findPaginated.mock.calls[0][2]; + expect(filtersArg.dateFrom).toBeInstanceOf(Date); + expect(filtersArg.dateTo).toBeInstanceOf(Date); + }); + + it('defaults sort to position asc', async () => { + const { service, annotationRepo } = makeService(); + annotationRepo.findPaginated.mockResolvedValue({ items: [], total: 0 }); + annotationRepo.getStats.mockResolvedValue( + makeStatsResult({ totalHighlights: 0, colorBreakdown: [], chaptersWithHighlights: 0, highlightsWithNotes: 0 }), + ); + annotationRepo.getDistinctChapters.mockResolvedValue([]); + + await service.getAnnotationsPaginated(5, makeUser(), { page: 1 }); + + const sortArg = annotationRepo.findPaginated.mock.calls[0][3]; + expect(sortArg).toEqual({ by: 'position', dir: 'asc' }); + }); + + it('respects custom sort params', async () => { + const { service, annotationRepo } = makeService(); + annotationRepo.findPaginated.mockResolvedValue({ items: [], total: 0 }); + annotationRepo.getStats.mockResolvedValue( + makeStatsResult({ totalHighlights: 0, colorBreakdown: [], chaptersWithHighlights: 0, highlightsWithNotes: 0 }), + ); + annotationRepo.getDistinctChapters.mockResolvedValue([]); + + await service.getAnnotationsPaginated(5, makeUser(), { page: 1, sortBy: 'createdAt', sortDir: 'desc' }); + + const sortArg = annotationRepo.findPaginated.mock.calls[0][3]; + expect(sortArg).toEqual({ by: 'createdAt', dir: 'desc' }); + }); + + it('serializes createdAt as ISO string in items', async () => { + const { service, annotationRepo } = makeService(); + const row = makeAnnotationRow({ createdAt: new Date('2026-03-15T12:00:00Z') }); + annotationRepo.findPaginated.mockResolvedValue({ items: [row], total: 1 }); + annotationRepo.getStats.mockResolvedValue(makeStatsResult()); + annotationRepo.getDistinctChapters.mockResolvedValue([]); + + const result = await service.getAnnotationsPaginated(5, makeUser(), { page: 1 }); + + expect(result.items[0].createdAt).toBe('2026-03-15T12:00:00.000Z'); + }); + + it('returns empty items and zero total when no annotations match', async () => { + const { service, annotationRepo } = makeService(); + annotationRepo.findPaginated.mockResolvedValue({ items: [], total: 0 }); + annotationRepo.getStats.mockResolvedValue( + makeStatsResult({ totalHighlights: 0, colorBreakdown: [], chaptersWithHighlights: 0, highlightsWithNotes: 0 }), + ); + annotationRepo.getDistinctChapters.mockResolvedValue([]); + + const result = await service.getAnnotationsPaginated(5, makeUser(), { page: 1 }); + + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + expect(result.stats.totalHighlights).toBe(0); + }); + + it('ignores empty color string', async () => { + const { service, annotationRepo } = makeService(); + annotationRepo.findPaginated.mockResolvedValue({ items: [], total: 0 }); + annotationRepo.getStats.mockResolvedValue( + makeStatsResult({ totalHighlights: 0, colorBreakdown: [], chaptersWithHighlights: 0, highlightsWithNotes: 0 }), + ); + annotationRepo.getDistinctChapters.mockResolvedValue([]); + + await service.getAnnotationsPaginated(5, makeUser(), { page: 1, colors: '' }); + + const filtersArg = annotationRepo.findPaginated.mock.calls[0][2]; + expect(filtersArg.colors).toBeUndefined(); + }); + }); }); diff --git a/server/src/modules/annotation/annotation.service.ts b/server/src/modules/annotation/annotation.service.ts index 883204a41..a979d3b13 100644 --- a/server/src/modules/annotation/annotation.service.ts +++ b/server/src/modules/annotation/annotation.service.ts @@ -1,12 +1,14 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import type { AnnotationListResponse } from '@bookorbit/types'; import type { RequestUser } from '../../common/types/request-user'; import { BookService } from '../book/book.service'; import { AchievementEventsService, ACHIEVEMENT_EVENT_ANNOTATION_CREATED } from '../achievement/achievement-events.service'; import { DEFAULT_ANNOTATION_COLOR, DEFAULT_ANNOTATION_STYLE } from './annotation.constants'; -import { AnnotationRepository } from './annotation.repository'; +import { AnnotationRepository, type AnnotationFilters, type AnnotationSort } from './annotation.repository'; import { AnnotationResponseDto } from './dto/annotation-response.dto'; import { CreateAnnotationDto } from './dto/create-annotation.dto'; +import { AnnotationQueryDto } from './dto/annotation-query.dto'; import { UpdateAnnotationDto } from './dto/update-annotation.dto'; @Injectable() @@ -23,6 +25,45 @@ export class AnnotationService { return rows.map((row) => AnnotationResponseDto.from(row)); } + async getAnnotationsPaginated(bookId: number, user: RequestUser, query: AnnotationQueryDto): Promise { + await this.bookService.verifyBookAccess(bookId, user); + + const filters = this.buildFilters(query); + const sort = this.buildSort(query); + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 25; + + const [{ items, total }, statsResult, chapters] = await Promise.all([ + this.annotationRepo.findPaginated(bookId, user.id, filters, sort, page, pageSize), + this.annotationRepo.getStats(bookId, user.id, filters), + this.annotationRepo.getDistinctChapters(bookId, user.id), + ]); + + return { + items: items.map((row) => { + const dto = AnnotationResponseDto.from(row); + return { + id: dto.id, + bookId: dto.bookId, + cfi: dto.cfi, + text: dto.text, + color: dto.color, + style: dto.style, + note: dto.note, + chapterTitle: dto.chapterTitle, + createdAt: dto.createdAt instanceof Date ? dto.createdAt.toISOString() : String(dto.createdAt), + }; + }), + total, + page, + pageSize, + stats: { + ...statsResult, + chapters, + }, + }; + } + async createAnnotation(bookId: number, user: RequestUser, dto: CreateAnnotationDto): Promise { await this.bookService.verifyBookAccess(bookId, user); const row = await this.annotationRepo.create({ @@ -60,6 +101,28 @@ export class AnnotationService { if (!deleted) throw new NotFoundException(this.notFoundMessage(bookId, annotationId)); } + private buildFilters(query: AnnotationQueryDto): AnnotationFilters { + return { + colors: query.colors + ? query.colors + .split(',') + .map((c) => c.trim()) + .filter(Boolean) + : undefined, + search: query.search || undefined, + chapter: query.chapter || undefined, + dateFrom: query.dateFrom ? new Date(query.dateFrom) : undefined, + dateTo: query.dateTo ? new Date(query.dateTo) : undefined, + }; + } + + private buildSort(query: AnnotationQueryDto): AnnotationSort { + return { + by: query.sortBy ?? 'position', + dir: query.sortDir ?? 'asc', + }; + } + private notFoundMessage(bookId: number, annotationId: number): string { return `Annotation ${annotationId} not found for book ${bookId}`; } diff --git a/server/src/modules/annotation/dto/annotation-query.dto.ts b/server/src/modules/annotation/dto/annotation-query.dto.ts new file mode 100644 index 000000000..31928f37a --- /dev/null +++ b/server/src/modules/annotation/dto/annotation-query.dto.ts @@ -0,0 +1,51 @@ +import { IsDateString, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +const SORT_BY_OPTIONS = ['position', 'createdAt'] as const; +const SORT_DIR_OPTIONS = ['asc', 'desc'] as const; + +export class AnnotationQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number; + + @IsOptional() + @IsIn(SORT_BY_OPTIONS) + sortBy?: 'position' | 'createdAt'; + + @IsOptional() + @IsIn(SORT_DIR_OPTIONS) + sortDir?: 'asc' | 'desc'; + + @IsOptional() + @IsString() + @MaxLength(500) + colors?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + search?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + chapter?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; +}
{{ highlight.text }}
+ {{ highlight.note }} +
No highlights yet
Select text while reading to create highlights