diff --git a/.github/workflows/e2e-runner.yml b/.github/workflows/e2e-runner.yml index 3bb6423f4..cbbbb866a 100644 --- a/.github/workflows/e2e-runner.yml +++ b/.github/workflows/e2e-runner.yml @@ -72,6 +72,10 @@ jobs: pnpm-version: ${{ env.PNPM_VERSION }} prefetch: "true" + - name: Install DjVuLibre tools + if: inputs.suite-id == 'djvu-reader' || inputs.suite-id == 'all' + run: sudo apt-get update && sudo apt-get install -y djvulibre-bin util-linux + - name: Run e2e suite shell: bash run: | diff --git a/Dockerfile b/Dockerfile index 76bb3455e..c8a860fb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,7 +58,7 @@ COPY server/requirements/kobo-cloudscraper.txt /tmp/kobo-cloudscraper-requiremen # pip is build-only here. Leaving it installed also leaves pip/_vendor/vendor.txt, # which Trivy reads as installed msgpack and setuptools and fails the image scan on. RUN apk upgrade --no-cache && \ - apk add --no-cache poppler-utils su-exec ffmpeg python3 py3-pip tini tzdata && \ + apk add --no-cache poppler-utils djvulibre util-linux su-exec ffmpeg python3 py3-pip tini tzdata && \ python3 -m venv /opt/bookorbit-python && \ /opt/bookorbit-python/bin/python -m pip install --no-cache-dir -r /tmp/kobo-cloudscraper-requirements.txt && \ /opt/bookorbit-python/bin/python -m pip uninstall -y pip && \ diff --git a/client/src/features/book-dock/composables/__tests__/useBookDockUpload.spec.ts b/client/src/features/book-dock/composables/__tests__/useBookDockUpload.spec.ts index a6a146e8d..54f52bc65 100644 --- a/client/src/features/book-dock/composables/__tests__/useBookDockUpload.spec.ts +++ b/client/src/features/book-dock/composables/__tests__/useBookDockUpload.spec.ts @@ -115,6 +115,14 @@ describe('useBookDockUpload client-side validation', () => { expect(upload.failures.value).toHaveLength(0) }) + it('sends DjVu to the Book Dock instead of rejecting it as unsupported', async () => { + const { upload } = await loadComposable() + upload.addFiles([book('DjVuLibre-book.DJVU')]) + await flushPromises() + expect(FakeXhr.sent).toHaveLength(1) + expect(upload.failures.value).toHaveLength(0) + }) + it('rejects a file over the configured upload limit before sending it', async () => { const { upload, appInfo } = await loadComposable() appInfo.maxUploadSizeMb.value = 1 diff --git a/client/src/features/book-dock/composables/useBookDockUpload.ts b/client/src/features/book-dock/composables/useBookDockUpload.ts index 94ddb62f7..8ea9c5465 100644 --- a/client/src/features/book-dock/composables/useBookDockUpload.ts +++ b/client/src/features/book-dock/composables/useBookDockUpload.ts @@ -1,11 +1,11 @@ import { computed, ref } from 'vue' import { getValidToken } from '@/lib/api' -import type { BookDockFile } from '@bookorbit/types' +import { UPLOAD_SUPPORTED_FORMATS, type BookDockFile } from '@bookorbit/types' import { useAppInfo } from '@/features/settings/composables/useAppInfo' import { isRetryableUploadFailure, type BookDockUploadFailure } from '../lib/upload-failure' -export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac'] +export const SUPPORTED_FORMATS: readonly string[] = UPLOAD_SUPPORTED_FORMATS export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',') export type FileUploadStatus = 'pending' | 'uploading' | 'done' | 'error' diff --git a/client/src/features/book/components/BookQuickView.vue b/client/src/features/book/components/BookQuickView.vue index eef9b933d..c598d0cf7 100644 --- a/client/src/features/book/components/BookQuickView.vue +++ b/client/src/features/book/components/BookQuickView.vue @@ -15,7 +15,7 @@ import { useCoverVersions } from '../composables/useCoverVersions' import BookFormatChip from './BookFormatChip.vue' import { bookFormatEntries, fileFormatKey } from '../lib/book-formats' import { displayPublishedDate } from '../lib/published-date' -import { FORMAT_TO_GROUP, getPrimaryBookFile } from '@bookorbit/types' +import { FORMAT_TO_GROUP, getPrimaryBookFile, isComicFormat } from '@bookorbit/types' import { useBookCoverAspectRatio } from '../composables/useBookCoverAspectRatio' import { useDisplaySettings } from '@/composables/useDisplaySettings' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' @@ -98,7 +98,7 @@ const quickViewCoverAspectRatio = computed(() => { const primaryFile = computed(() => (detail.value ? getPrimaryBookFile(detail.value.files) : null)) const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio') -const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx') +const isPrimaryComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format)) const formatEntries = computed(() => (detail.value ? bookFormatEntries(detail.value.files, detail.value.formatPriority) : [])) const publishedDisplay = computed(() => (detail.value ? displayPublishedDate(detail.value.publishedDate, detail.value.publishedYear) : null)) diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue index e54bee988..efe5292ae 100644 --- a/client/src/features/book/components/detail/tabs/DetailsTab.vue +++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue @@ -32,7 +32,7 @@ import { readingDateToDateKey } from '@/features/book/lib/reading-date' import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors' import { useCoverVersions } from '@/features/book/composables/useCoverVersions' import { useBookCoverAspectRatio } from '@/features/book/composables/useBookCoverAspectRatio' -import { FORMAT_TO_GROUP, getPrimaryBookFile, READER_OPENABLE_FORMATS } from '@bookorbit/types' +import { FORMAT_TO_GROUP, getPrimaryBookFile, READER_OPENABLE_FORMATS, isComicFormat } from '@bookorbit/types' import type { BookDetail, BookKoboState, CustomMetadataBookValue, ReadAloudProgressSync, ReadStatus, UserBookStatus } from '@bookorbit/types' import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus' import BookDownloadButton from '@/features/book/components/BookDownloadButton.vue' @@ -364,7 +364,7 @@ const isFaceAudio = computed(() => { if (!props.book.covers.ebook && !props.book.covers.audio) return isPrimaryAudio.value return faceMedium(props.book, coverAspectRatio.value) === 'audio' }) -const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx') +const isPrimaryComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format)) const formatEntries = computed(() => bookFormatEntries(props.book.files, props.book.formatPriority)) const isMultiTrackAudio = computed(() => formatEntries.value.some((entry) => entry.audio && entry.files.length > 1)) // A multi-file audiobook opens from its first track, so it is one entry in the menu. diff --git a/client/src/features/book/components/detail/tabs/EditMetadataTab.vue b/client/src/features/book/components/detail/tabs/EditMetadataTab.vue index f986c377c..1129e27ab 100644 --- a/client/src/features/book/components/detail/tabs/EditMetadataTab.vue +++ b/client/src/features/book/components/detail/tabs/EditMetadataTab.vue @@ -25,7 +25,14 @@ import type { MetadataProviderInfo, WriteResult, } from '@bookorbit/types' -import { BOOK_FILE_WRITE_FIELD_LABELS, FORMAT_TO_GROUP, getPrimaryBookFile, isValidSeriesIndex, parseSeriesIndex } from '@bookorbit/types' +import { + BOOK_FILE_WRITE_FIELD_LABELS, + FORMAT_TO_GROUP, + getPrimaryBookFile, + isValidSeriesIndex, + parseSeriesIndex, + isComicFormat, +} from '@bookorbit/types' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { api } from '@/lib/api' @@ -119,7 +126,7 @@ const COMIC_FIELD_MAP = { const primaryFile = computed(() => getPrimaryBookFile(props.book.files)) const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio') -const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx') +const isPrimaryComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format)) const fileWriteStatus = computed(() => props.book.fileWriteStatus ?? null) const fileWriteWritableFormats = computed(() => fileWriteStatus.value?.writableFormats ?? []) const fileWriteFieldLabels = computed(() => (fileWriteStatus.value?.writableFields ?? []).map((field) => BOOK_FILE_WRITE_FIELD_LABELS[field])) diff --git a/client/src/features/book/components/table/BookTableCellDispatcher.vue b/client/src/features/book/components/table/BookTableCellDispatcher.vue index b57cd6f51..b9a3bdef6 100644 --- a/client/src/features/book/components/table/BookTableCellDispatcher.vue +++ b/client/src/features/book/components/table/BookTableCellDispatcher.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { Lock, LockOpen } from '@lucide/vue' -import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types' +import { FORMAT_TO_GROUP, isComicFormat, type BookCard } from '@bookorbit/types' import BookTableCoverCell from './BookTableCoverCell.vue' import BookTableTextCell from './BookTableTextCell.vue' import BookTableNumberCell from './BookTableNumberCell.vue' @@ -97,7 +97,7 @@ function handleToggleAllLocks() { const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null) const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio') -const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx') +const isComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format))