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) {
-
@@ -585,17 +547,13 @@ function formatBadgeStyle(fmt: string, result?: GlobalSearchResult) {
-
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 @@
+
+
+
+
+ {{ formatKeyCode(formatKey) }}
+
+ {{ name }}
+ {{ t('book.formats.primary') }}
+
+
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 {
-
-
-
- {{ row.format }}
-
+
@@ -186,7 +113,7 @@ function rowMeasure(row: EditionRow): string {
v-if="progressFor(row)?.resetFileId != null"
type="button"
class="inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
- :aria-label="t('book.detail.details.resetFileProgress')"
+ :aria-label="t('book.detail.details.resetEditionProgress', { format: formatKeyName(row.key) })"
:disabled="isResetting(row)"
@click="handleReset(row)"
>
@@ -201,7 +128,7 @@ function rowMeasure(row: EditionRow): string {
{{ t('book.detail.details.filesOnDisk') }}
-
{{ formatNumber(fileCount) }}
+
{{ formatNumber(book.files.length) }}
diff --git a/client/src/features/book/components/detail/details/BookReadingActivityCard.vue b/client/src/features/book/components/detail/details/BookReadingActivityCard.vue
index c67f05e52..9099a06b8 100644
--- a/client/src/features/book/components/detail/details/BookReadingActivityCard.vue
+++ b/client/src/features/book/components/detail/details/BookReadingActivityCard.vue
@@ -2,7 +2,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatNumber, formatPercent, formatRelativeFromNow } from '@/i18n/formatters'
-import { formatColorVar } from '@/features/book/lib/format-colors'
+import BookFormatChip from '@/features/book/components/BookFormatChip.vue'
import type { BookReadingSession, BookReadingSessionStats } from '@bookorbit/types'
const props = withDefaults(
@@ -83,15 +83,6 @@ function relative(iso: string | null | undefined): string {
function dayLabel(day: string, minutes: number): string {
return t('book.detail.details.activityDayTooltip', { day, minutes: Math.round(minutes) })
}
-
-function formatBadgeStyle(format: string) {
- const color = formatColorVar(format)
- return {
- color,
- borderColor: `color-mix(in oklch, ${color} 45%, transparent)`,
- backgroundColor: `color-mix(in oklch, ${color} 12%, transparent)`,
- }
-}
@@ -140,13 +131,11 @@ function formatBadgeStyle(format: string) {
{{ t('book.detail.details.activityReached', { percent: formatPercent((session.endProgress ?? 0) / 100) }) }}
-
- {{ session.source ?? session.format }}
-
+ :format-key="session.format"
+ class="h-4.5 shrink-0 rounded px-1.5 text-[9px] leading-none tracking-wider"
+ />
diff --git a/client/src/features/book/components/detail/files/FileDetailCard.vue b/client/src/features/book/components/detail/files/FileDetailCard.vue
index 5653f563e..f99d4ef2c 100644
--- a/client/src/features/book/components/detail/files/FileDetailCard.vue
+++ b/client/src/features/book/components/detail/files/FileDetailCard.vue
@@ -107,7 +107,7 @@ function handleCopyPath() {
-
+
{{ file.leaf }}
diff --git a/client/src/features/book/components/detail/files/FileFormatGlyph.vue b/client/src/features/book/components/detail/files/FileFormatGlyph.vue
index 9c7138661..68736d7c7 100644
--- a/client/src/features/book/components/detail/files/FileFormatGlyph.vue
+++ b/client/src/features/book/components/detail/files/FileFormatGlyph.vue
@@ -1,9 +1,15 @@
@@ -41,7 +43,7 @@ function formatLabel(format: string | null): string {
{{ t('book.detail.editMetadata.primaryRow') }}
- {{ formatLabel(primaryFile.format) }}
+ {{ formatLabel(primaryFile) }}
{{ formatBytes(primaryFile.sizeBytes) }}
@@ -63,7 +65,7 @@ function formatLabel(format: string | null): string {
- {{ formatLabel(file.format) }}
+ {{ formatLabel(file) }}
{{ formatBytes(file.sizeBytes) }}
diff --git a/client/src/features/book/components/detail/tabs/ReadingLogTab.vue b/client/src/features/book/components/detail/tabs/ReadingLogTab.vue
index aa867d9c0..c3f72f260 100644
--- a/client/src/features/book/components/detail/tabs/ReadingLogTab.vue
+++ b/client/src/features/book/components/detail/tabs/ReadingLogTab.vue
@@ -2,7 +2,8 @@
import { computed, nextTick, onActivated, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Ellipsis, RotateCcw } from '@lucide/vue'
-import { Permission, type BookDetail, type UserBookStatus } from '@bookorbit/types'
+import { formatOfKey, Permission, type BookDetail, type UserBookStatus } from '@bookorbit/types'
+import { bookFormatEntries } from '@/features/book/lib/book-formats'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useDeferredLoading } from '@/composables/useDeferredLoading'
import { useBookReadingLog, type AddReadingSessionPayload } from '@/features/book/composables/useBookReadingLog'
@@ -89,10 +90,10 @@ type QuickFilter = 'all' | 'last30' | 'last90' | 'thisYear'
const activeQuick = ref
('all')
const selectedFormat = ref(undefined)
-const uniqueFormats = computed(() => {
- const formats = props.book.files.map((file) => file.format).filter((format): format is string => format != null && format.length > 0)
- return [...new Set(formats)]
-})
+// Sessions record the file format, so a read-along and a plain EPUB share the `epub` filter.
+const uniqueFormats = computed(() => [
+ ...new Set(bookFormatEntries(props.book.files, props.book.formatPriority).map((entry) => formatOfKey(entry.key))),
+])
const hasMultipleFormats = computed(() => uniqueFormats.value.length >= 2)
diff --git a/client/src/features/book/components/table/BookTableFormatCell.vue b/client/src/features/book/components/table/BookTableFormatCell.vue
index ca58d0b3d..8d35a4147 100644
--- a/client/src/features/book/components/table/BookTableFormatCell.vue
+++ b/client/src/features/book/components/table/BookTableFormatCell.vue
@@ -1,77 +1,37 @@
-
+
-
- {{ fmt }}
-
-
- +{{ formats.length - visibleFormats.length }}
+
+ +{{ hiddenCount }}
- {{ file.format ?? t('book.table.format.unknown') }} ({{ file.role }})
- {{ READ_ALONG_FORMAT_TITLE }}
+ {{ formatKeyName(entry.key) }}
-
diff --git a/client/src/features/book/components/table/BookTableReadButtonCell.vue b/client/src/features/book/components/table/BookTableReadButtonCell.vue
index 4e209b232..684fd722e 100644
--- a/client/src/features/book/components/table/BookTableReadButtonCell.vue
+++ b/client/src/features/book/components/table/BookTableReadButtonCell.vue
@@ -4,9 +4,10 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { BookOpen, ChevronDown, Eye, Play } from '@lucide/vue'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
-import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types'
+import { isAudioFormat, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import type { BookCard, BookFileRef } from '@bookorbit/types'
-import { getFormatColor } from '@/features/book/lib/format-colors'
+import BookFormatChip from '@/features/book/components/BookFormatChip.vue'
+import { bookFormatEntries, fileFormatKey, formatKeyName } from '@/features/book/lib/book-formats'
const props = defineProps<{
book: BookCard
@@ -15,78 +16,31 @@ const props = defineProps<{
const router = useRouter()
const { t } = useI18n()
-const readableFiles = computed(() => {
- const normalized = props.book.files.filter((file) => {
- const format = file.format?.trim().toLowerCase()
- return format ? READER_OPENABLE_FORMATS.has(format) : false
- })
- const primary = normalized.find((file) => file.role === 'primary')
- return primary ? [primary, ...normalized.filter((file) => file.id !== primary.id)] : normalized
-})
+const entries = computed(() => bookFormatEntries(props.book.files))
+const isMultiTrackAudio = computed(() => entries.value.some((entry) => entry.audio && entry.files.length > 1))
-const isMultiTrackAudio = computed(() => {
- const audioFiles = readableFiles.value.filter((file) => {
- const format = file.format?.toLowerCase()
- return format ? FORMAT_TO_GROUP[format] === 'audio' : false
- })
- return audioFiles.length > 1
-})
-
-const openableFiles = computed(() => {
- const collapsed = isMultiTrackAudio.value
- ? (() => {
- const firstAudio = readableFiles.value.find((file) => {
- const format = file.format?.toLowerCase()
- return format ? FORMAT_TO_GROUP[format] === 'audio' : false
- })
- const nonAudio = readableFiles.value.filter((file) => {
- const format = file.format?.toLowerCase()
- return format ? FORMAT_TO_GROUP[format] !== 'audio' : false
- })
- return firstAudio ? [firstAudio, ...nonAudio] : nonAudio
- })()
- : readableFiles.value
-
- const seen = new Set()
- return collapsed.filter((file) => {
- const key = file.format!.trim().toLowerCase()
- if (seen.has(key)) return false
- seen.add(key)
- return true
- })
-})
+/** One file per edition the reader can open; an audiobook opens from its first track. */
+const openableFiles = computed(() =>
+ entries.value.map((entry) => entry.files[0]!).filter((file) => READER_OPENABLE_FORMATS.has(file.format!.toLowerCase())),
+)
const primaryFile = computed(() => openableFiles.value.find((file) => file.role === 'primary') ?? openableFiles.value[0] ?? null)
-const primaryIsAudio = computed(() => {
- const format = primaryFile.value?.format?.toLowerCase()
- return format ? FORMAT_TO_GROUP[format] === 'audio' : false
-})
-
const canOpen = computed(() => props.book.status !== 'missing' && !!primaryFile.value)
const hasMultipleFormats = computed(() => openableFiles.value.length > 1)
-function actionVerb(file: BookFileRef | null): string {
- const format = file?.format?.toLowerCase()
- return format && FORMAT_TO_GROUP[format] === 'audio' ? t('book.table.read.play') : t('book.table.read.read')
-}
-
function isAudioFile(file: BookFileRef | null): boolean {
- const format = file?.format?.toLowerCase()
- return format ? FORMAT_TO_GROUP[format] === 'audio' : false
+ return file?.format != null && isAudioFormat(file.format)
}
function formatLabel(file: BookFileRef | null): string {
- return file?.format?.trim().toUpperCase() ?? t('book.table.read.fileFallback')
+ const key = file ? fileFormatKey(file) : null
+ return key ? formatKeyName(key) : t('book.table.read.fileFallback')
}
-function formatBadgeStyle(format: string) {
- const color = getFormatColor(format)
- return {
- color,
- borderColor: `${color}66`,
- backgroundColor: `${color}1a`,
- }
+function actionLabel(file: BookFileRef | null): string {
+ const format = formatLabel(file)
+ return isAudioFile(file) ? t('book.table.read.playFormat', { format }) : t('book.table.read.readFormat', { format })
}
function openFile(file: BookFileRef | null, mode?: 'peek') {
@@ -113,11 +67,11 @@ function peekPrimaryFile() {
@@ -134,13 +88,8 @@ function peekPrimaryFile() {
-
- {{ file.format }}
-
- {{ actionVerb(file) }} {{ formatLabel(file) }}
+
+ {{ actionLabel(file) }}
{{ t('book.table.read.primary') }}
@@ -155,8 +104,8 @@ function peekPrimaryFile() {