From 218f8d5dbdc430b8c72699cae83e09268d0d25c0 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:05:24 +0000 Subject: [PATCH] fix(books): list a book's formats from one ranked, read-along aware source Every format chip, edition row and format menu now builds from shared helpers: content files only, a read-along EPUB kept apart from a plain one, in the library's format priority, which gains a read-along entry. Labels, colours and the chip each have one source, and cards, OPDS, email, Kobo and the dashboard rank files the same way. Book detail file order is unchanged because iOS builds its track queue from it. --- client/src/assets/theme/tokens.css | 4 + client/src/components/AppHeader.vue | 80 ++----- .../components/AnnotationListItem.vue | 7 +- .../__tests__/AnnotationListItem.spec.ts | 2 +- .../components/shared/AnnotationEntry.vue | 7 +- .../book/components/BookCoverCard.test.ts | 8 +- .../book/components/BookCoverCard.vue | 93 ++++----- .../book/components/BookDownloadButton.vue | 56 ++--- .../book/components/BookFormatChip.vue | 40 ++++ .../features/book/components/BookListRow.vue | 87 +++----- .../book/components/BookQuickView.vue | 61 ++---- .../components/__tests__/BookListRow.spec.ts | 4 +- .../__tests__/BookTableCells.spec.ts | 18 +- .../components/__tests__/DetailsTab.spec.ts | 4 +- .../detail/details/BookEditionsCard.vue | 117 ++--------- .../details/BookReadingActivityCard.vue | 21 +- .../detail/files/FileDetailCard.vue | 2 +- .../detail/files/FileFormatGlyph.vue | 11 +- .../components/detail/files/FileListCard.vue | 5 +- .../components/detail/files/FilesHeroBar.vue | 10 +- .../detail/reading-log/ReadingLogVitals.vue | 5 +- .../components/detail/tabs/DetailsTab.vue | 196 +++++------------- .../detail/tabs/EditMetadataTab.vue | 9 +- .../book/components/detail/tabs/FilesTab.vue | 12 +- .../detail/tabs/MetadataSourceCard.vue | 16 +- .../components/detail/tabs/ReadingLogTab.vue | 11 +- .../components/table/BookTableFormatCell.vue | 74 ++----- .../table/BookTableReadButtonCell.vue | 95 ++------- .../book/composables/useBookFileTree.ts | 53 +++-- .../features/book/lib/book-formats.test.ts | 72 +++++++ client/src/features/book/lib/book-formats.ts | 95 +++++++++ .../features/book/lib/file-capabilities.ts | 11 - client/src/features/book/lib/format-colors.ts | 38 +++- .../email/components/SendBookDialog.vue | 26 +-- .../__tests__/SendBookDialog.spec.ts | 8 +- .../components/LibraryCreatorMetadata.vue | 21 +- .../components/LibraryCreatorScanner.vue | 6 +- .../__tests__/LibraryCreatorMetadata.spec.ts | 44 ++++ .../__tests__/useLibraryCreator.spec.ts | 23 ++ .../library/composables/useLibraryCreator.ts | 21 +- .../components/LibraryDetailPanel.vue | 4 +- client/src/locales/en.json | 32 ++- packages/types/src/book.ts | 23 +- packages/types/src/format-priority.ts | 61 ++++++ packages/types/src/index.ts | 1 + packages/types/src/library.ts | 18 -- .../primary-file-selection.utils.test.ts | 102 ++++++++- .../utils/primary-file-selection.utils.ts | 66 +++++- .../book-dock/book-dock-finalize.service.ts | 12 +- .../src/modules/book/book.repository.test.ts | 7 +- server/src/modules/book/book.repository.ts | 29 ++- server/src/modules/book/book.service.ts | 3 +- .../book/utils/assemble-book-cards.test.ts | 21 +- .../modules/book/utils/assemble-book-cards.ts | 7 +- .../dashboard-widget.calculations.ts | 30 +-- .../dashboard-widget.repository.test.ts | 13 +- .../dashboard/dashboard-widget.repository.ts | 10 +- .../email/email-book-read.repository.test.ts | 17 +- .../email/email-book-read.repository.ts | 16 +- .../modules/email/email-file-selector.test.ts | 67 +++--- .../src/modules/email/email-file-selector.ts | 25 ++- .../email/email-send-orchestrator.service.ts | 38 +++- .../kobo/services/kobo-sync.service.test.ts | 22 ++ .../kobo/services/kobo-sync.service.ts | 15 +- .../missing-resources.repository.ts | 14 +- .../opds/__tests__/opds-book.service.test.ts | 32 +++ server/src/modules/opds/opds-book.service.ts | 71 ++++++- server/src/modules/opds/opds-xml.helpers.ts | 15 ++ server/src/modules/opds/opds.controller.ts | 63 ++++-- .../src/modules/scanner/scanner.repository.ts | 13 +- 70 files changed, 1415 insertions(+), 905 deletions(-) create mode 100644 client/src/features/book/components/BookFormatChip.vue create mode 100644 client/src/features/book/lib/book-formats.test.ts create mode 100644 client/src/features/book/lib/book-formats.ts create mode 100644 client/src/features/library/components/__tests__/LibraryCreatorMetadata.spec.ts create mode 100644 packages/types/src/format-priority.ts diff --git a/client/src/assets/theme/tokens.css b/client/src/assets/theme/tokens.css index 38b199308..449215383 100644 --- a/client/src/assets/theme/tokens.css +++ b/client/src/assets/theme/tokens.css @@ -221,6 +221,7 @@ --pill-format-opus: oklch(0.55 0.11 205); --pill-format-ogg: oklch(0.56 0.15 130); --pill-format-flac: oklch(0.55 0.13 165); + --pill-format-readalong: oklch(0.52 0.1 186); /* Rename diff. A bulk rename is a small edit inside a long path, so the ink that marks removed and added characters carries the whole feature. Tuned per theme like every other @@ -266,6 +267,7 @@ --pill-format-opus: oklch(0.55 0.11 205); --pill-format-ogg: oklch(0.56 0.15 130); --pill-format-flac: oklch(0.55 0.13 165); + --pill-format-readalong: oklch(0.52 0.1 186); } @media (min-width: 40rem) { @@ -444,6 +446,7 @@ --pill-format-opus: oklch(0.76 0.11 205); --pill-format-ogg: oklch(0.78 0.15 130); --pill-format-flac: oklch(0.75 0.13 165); + --pill-format-readalong: oklch(0.78 0.11 186); --diff-del: oklch(0.79 0.16 25); --diff-del-bg: oklch(0.79 0.16 25 / 0.16); @@ -486,4 +489,5 @@ --pill-format-opus: oklch(0.76 0.11 205); --pill-format-ogg: oklch(0.78 0.15 130); --pill-format-flac: oklch(0.75 0.13 165); + --pill-format-readalong: oklch(0.78 0.11 186); } diff --git a/client/src/components/AppHeader.vue b/client/src/components/AppHeader.vue index 7bec7bdf5..ad913d0cf 100644 --- a/client/src/components/AppHeader.vue +++ b/client/src/components/AppHeader.vue @@ -14,7 +14,6 @@ import { Trophy, MoreVertical, BadgeQuestionMark, - Headphones, ExternalLink, Sparkles, Languages, @@ -55,12 +54,12 @@ import NotificationSheet from '@/features/notifications/components/NotificationS import { useNotifications } from '@/features/notifications/composables/useNotifications' import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew' import UserAvatar from '@/components/UserAvatar.vue' -import { DEFAULT_FORMAT_PRIORITY, LOCALE_LABELS, Permission, type Locale } from '@bookorbit/types' +import { LOCALE_LABELS, Permission, type Locale } from '@bookorbit/types' import { useThemeStore } from '@/stores/theme' import { useLocaleStore } from '@/stores/locale' -import { getFormatColor } from '@/features/book/lib/format-colors' +import BookFormatChip from '@/features/book/components/BookFormatChip.vue' +import { bookFormatEntries } from '@/features/book/lib/book-formats' import { useLegalNotices } from '@/components/legal/useLegalNotices' -import { hasReadAlong, isReadAlongFormat, READ_ALONG_FORMAT_COLOR, READ_ALONG_FORMAT_TITLE } from '@/features/book/lib/file-capabilities' const { t } = useI18n() const router = useRouter() @@ -337,41 +336,8 @@ function highlightSegments(text: string | null, query: string) { return parts.map((part) => ({ text: part, match: part.toLowerCase() === lower })) } -function sortFormats(formats: string[]): string[] { - return [...formats].sort((a, b) => { - const aIndex = (DEFAULT_FORMAT_PRIORITY as readonly string[]).indexOf(a.toLowerCase()) - const bIndex = (DEFAULT_FORMAT_PRIORITY as readonly string[]).indexOf(b.toLowerCase()) - - if (aIndex === -1 && bIndex === -1) return a.localeCompare(b) - if (aIndex === -1) return 1 - if (bIndex === -1) return -1 - return aIndex - bIndex - }) -} - -function resultFormats(result: GlobalSearchResult): string[] { - const formats = new Set() - for (const file of result.files) { - const fmt = file.format?.toLowerCase() - if (fmt) formats.add(fmt) - } - return sortFormats([...formats]) -} - -function formatHasReadAlong(fmt: string, result: GlobalSearchResult): boolean { - return isReadAlongFormat( - fmt, - result.files.some((file) => hasReadAlong(file)), - ) -} - -function formatBadgeStyle(fmt: string, result?: GlobalSearchResult) { - const color = result && formatHasReadAlong(fmt, result) ? READ_ALONG_FORMAT_COLOR : getFormatColor(fmt) - return { - color, - backgroundColor: `color-mix(in oklch, ${color} 10%, transparent)`, - borderColor: `color-mix(in oklch, ${color} 20%, transparent)`, - } +function resultFormatEntries(result: GlobalSearchResult) { + return bookFormatEntries(result.files) } @@ -456,17 +422,13 @@ function formatBadgeStyle(fmt: string, result?: GlobalSearchResult) {

-
- - {{ fmt }} - +
+
@@ -585,17 +547,13 @@ function formatBadgeStyle(fmt: string, result?: GlobalSearchResult) {

-
- - {{ fmt }} - +
+
diff --git a/client/src/features/annotations/components/AnnotationListItem.vue b/client/src/features/annotations/components/AnnotationListItem.vue index 014d8fdb9..7c39ad6a0 100644 --- a/client/src/features/annotations/components/AnnotationListItem.vue +++ b/client/src/features/annotations/components/AnnotationListItem.vue @@ -26,7 +26,7 @@ import { RouterLink } from 'vue-router' import { ANNOTATION_HIGHLIGHT_COLORS, type AnnotationHubItem, type AnnotationItem } from '@bookorbit/types' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { PILL_CLASS, sourcePill, statusPill } from '@/features/annotations/lib/pill-styles' -import { getFormatColor } from '@/features/book/lib/format-colors' +import { formatChipStyle } from '@/features/book/lib/format-colors' import HighlightNoteEditor from '@/features/book/components/detail/tabs/HighlightNoteEditor.vue' import AnnotationBookThumb from './AnnotationBookThumb.vue' import AnnotationSyncDetailPanel from './AnnotationSyncDetailPanel.vue' @@ -95,10 +95,7 @@ const jumpFileFormat = computed(() => { const format = props.annotation.jumpFileFormat?.trim() return format ? format.toUpperCase() : null }) -const jumpFileFormatStyle = computed(() => { - const color = getFormatColor(jumpFileFormat.value) - return { color, borderColor: `${color}66`, backgroundColor: `${color}1a` } -}) +const jumpFileFormatStyle = computed(() => formatChipStyle(jumpFileFormat.value)) const canJump = computed(() => props.annotation.jumpFileId != null && (props.mode !== 'hub' || jumpFileFormat.value != null) && !props.trashed) const isLong = computed(() => props.annotation.text.length > (props.density === 'compact' ? 180 : 260)) const isApproximate = computed(() => props.annotation.cfi == null && props.annotation.origin !== 'web') diff --git a/client/src/features/annotations/components/__tests__/AnnotationListItem.spec.ts b/client/src/features/annotations/components/__tests__/AnnotationListItem.spec.ts index 088b07817..fe7ed4f09 100644 --- a/client/src/features/annotations/components/__tests__/AnnotationListItem.spec.ts +++ b/client/src/features/annotations/components/__tests__/AnnotationListItem.spec.ts @@ -61,7 +61,7 @@ describe('AnnotationListItem', () => { const wrapper = mountItem({ annotation: makeHubAnnotation(), mode: 'hub' }) const pill = wrapper.findAll('span').find((span) => span.text() === 'MOBI') - expect(pill?.attributes('style')).toContain('color: rgb(99, 102, 241)') + expect(pill?.attributes('style')).toContain('color: var(--pill-format-mobi)') }) it('renders the quote and the note', () => { diff --git a/client/src/features/annotations/components/shared/AnnotationEntry.vue b/client/src/features/annotations/components/shared/AnnotationEntry.vue index 34803d292..634b416fd 100644 --- a/client/src/features/annotations/components/shared/AnnotationEntry.vue +++ b/client/src/features/annotations/components/shared/AnnotationEntry.vue @@ -18,7 +18,7 @@ import { } from '@lucide/vue' import type { AnnotationHubItem, AnnotationItem } from '@bookorbit/types' import { formatDate } from '@/i18n/formatters' -import { getFormatColor } from '@/features/book/lib/format-colors' +import { formatChipStyle } from '@/features/book/lib/format-colors' import AnnotationBookThumb from '../AnnotationBookThumb.vue' type EntryAnnotation = AnnotationItem | AnnotationHubItem @@ -71,10 +71,7 @@ const hub = computed(() => ('bookTitle' in props.annotation ? props.annotation : const bookTitle = computed(() => hub.value?.bookTitle ?? t('annotations.unknownBook')) const author = computed(() => hub.value?.author ?? null) const format = computed(() => hub.value?.jumpFileFormat?.trim().toUpperCase() ?? null) -const formatStyle = computed(() => { - const color = getFormatColor(format.value) - return { color, borderColor: `${color}66`, backgroundColor: `${color}1a` } -}) +const formatStyle = computed(() => formatChipStyle(format.value)) // A book page's rows carry no jumpFileFormat, so there it only needs a file to jump to. const canJump = computed(() => props.annotation.jumpFileId != null && (hub.value == null || format.value != null) && !props.trashed) const needsReview = computed(() => props.annotation.positionStatus != null && props.annotation.positionStatus !== 'exact') diff --git a/client/src/features/book/components/BookCoverCard.test.ts b/client/src/features/book/components/BookCoverCard.test.ts index c8be39860..df63f3caf 100644 --- a/client/src/features/book/components/BookCoverCard.test.ts +++ b/client/src/features/book/components/BookCoverCard.test.ts @@ -602,7 +602,7 @@ describe('BookCoverCard', () => { it('downloads a pure multi-track audiobook as an audio ZIP', async () => { const book = makeBook({ id: 42, - files: [makeFile({ id: 10, format: 'mp3', role: 'primary' }), makeFile({ id: 11, format: 'mp3', role: 'secondary' })], + files: [makeFile({ id: 10, format: 'mp3', role: 'primary' }), makeFile({ id: 11, format: 'mp3', role: 'content' })], }) const wrapper = mountCard({ book }) const downloadItem = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().trim() === 'Download') @@ -619,8 +619,8 @@ describe('BookCoverCard', () => { id: 77, files: [ makeFile({ id: 20, format: 'mp3', role: 'primary' }), - makeFile({ id: 21, format: 'mp3', role: 'secondary' }), - makeFile({ id: 22, format: 'epub', role: 'secondary' }), + makeFile({ id: 21, format: 'mp3', role: 'content' }), + makeFile({ id: 22, format: 'epub', role: 'content' }), ], }) const wrapper = mountCard({ book }) @@ -634,7 +634,7 @@ describe('BookCoverCard', () => { mockExportBooks.mockClear() mockDownloadFile.mockClear() - const epubItems = wrapper.findAll('[data-testid="dropdown-item"]').filter((item) => item.text().trim() === 'EPUB') + const epubItems = wrapper.findAll('[data-testid="dropdown-item"]').filter((item) => item.text().trim() === 'EPUB e-book') expect(epubItems).toHaveLength(2) await epubItems[1]!.trigger('click') diff --git a/client/src/features/book/components/BookCoverCard.vue b/client/src/features/book/components/BookCoverCard.vue index c8d295879..b9c9a1ef4 100644 --- a/client/src/features/book/components/BookCoverCard.vue +++ b/client/src/features/book/components/BookCoverCard.vue @@ -1,7 +1,8 @@ + + diff --git a/client/src/features/book/components/BookListRow.vue b/client/src/features/book/components/BookListRow.vue index 8f3d5cc91..acdef3af0 100644 --- a/client/src/features/book/components/BookListRow.vue +++ b/client/src/features/book/components/BookListRow.vue @@ -1,6 +1,6 @@ @@ -156,13 +85,11 @@ function rowMeasure(row: EditionRow): string {