book import

This commit is contained in:
Gauthier Roebroeck 2025-10-03 11:50:23 +08:00
parent 0fc5b9ba54
commit b934e6145d
40 changed files with 3507 additions and 45 deletions

View file

@ -1,3 +1,13 @@
export function seriesThumbnailUrl(seriesId?: string): string | undefined {
if (seriesId) return `${import.meta.env.VITE_KOMGA_API_URL}/api/v1/series/${seriesId}/thumbnail`
return undefined
}
export function bookThumbnailUrl(bookId?: string): string | undefined {
if (bookId) return `${import.meta.env.VITE_KOMGA_API_URL}/api/v1/books/${bookId}/thumbnail`
return undefined
}
export function pageHashKnownThumbnailUrl(hash?: string): string | undefined {
if (hash) return `${import.meta.env.VITE_KOMGA_API_URL}/api/v1/page-hashes/${hash}/thumbnail`
return undefined

View file

@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
height="297mm"
viewBox="0 0 793.70081 1122.5197"
width="210mm"
version="1.1"
id="svg4586"
sodipodi:docname="cover.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<metadata
id="metadata4592">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs4590">
<linearGradient
id="linearGradient6082"
osb:paint="solid">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop6080"/>
</linearGradient>
<linearGradient
id="linearGradient6076"
osb:paint="solid">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop6074"/>
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient6082"
id="linearGradient6084"
x1="77.866814"
y1="386.00677"
x2="217.20259"
y2="386.00677"
gradientUnits="userSpaceOnUse"/>
<filter
style="color-interpolation-filters:sRGB"
inkscape:label="Greyscale"
id="filter4541">
<feColorMatrix
values="0.21 0.72 0.072 0 0 0.21 0.72 0.072 0 0 0.21 0.72 0.072 0 0 0 0 0 1 0 "
id="feColorMatrix4539"/>
</filter>
<filter
style="color-interpolation-filters:sRGB"
inkscape:label="Greyscale"
id="filter4545">
<feColorMatrix
values="0.21 0.72 0.072 0 0 0.21 0.72 0.072 0 0 0.21 0.72 0.072 0 0 0 0 0 1 0 "
id="feColorMatrix4543"/>
</filter>
</defs>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1656"
inkscape:window-height="1368"
id="namedview4588"
showgrid="false"
inkscape:zoom="0.31281188"
inkscape:cx="546.28827"
inkscape:cy="1456.7169"
inkscape:window-x="-7"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg4586"
inkscape:snap-page="true"
units="mm"
showborder="true"
viewbox-width="1"
viewbox-height="1"
scale-x="0.60001"/>
<rect
style="fill:#005ed3;fill-opacity:1;stroke:none;stroke-width:2.21274972;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter4541)"
id="rect826"
width="595.27557"
height="841.88977"
x="1.7763568e-14"
y="-2.3789293e-05"
transform="matrix(1.3333334,0,0,1.3333333,0,7.9297629e-6)"/>
<g
id="g4537"
transform="matrix(1.3333334,0,0,1.3333333,23.517062,16.073517)"
style="filter:url(#filter4545)">
<path
style="fill:#ff0335"
inkscape:connector-curvature="0"
id="path4560"
d="m 280,239.63194 37.10937,63.73828 70.57422,-31.41406 -10.52734,71.71875 77.07812,12.91015 -54.14453,52.30469 54.14453,52.30469 -77.07812,12.91015 10.52734,71.71875 L 317.10937,514.40928 280,578.14756 l -37.10938,-63.73828 -70.57421,31.41406 10.52734,-71.71875 -77.07813,-12.91015 54.14454,-52.30469 -54.14454,-52.30469 77.07813,-12.91015 -10.52734,-71.71875 70.57421,31.41406 z m 0,0"/>
<path
style="fill:#c2001b"
inkscape:connector-curvature="0"
id="path4562"
d="m 454.23047,461.19053 -77.07031,12.91016 10.51953,71.71875 L 317.10938,514.40928 280,578.15147 V 239.62803 l 37.10938,63.74219 70.57031,-31.41016 -6.75781,46.10156 -3.76172,25.61719 58.80078,9.85156 18.26953,3.0586 -13.39063,12.92969 -40.75,39.37109 11.37891,10.98828 z m 0,0"/>
<path
style="fill:#ffdf47"
inkscape:connector-curvature="0"
id="path4564"
d="M 280,607.95616 236.69531,533.58506 153.51953,570.6085 165.86719,486.46787 73.949219,471.07334 138.32031,408.88975 73.949219,346.70616 165.86719,331.31162 153.51953,247.171 236.69922,284.19444 280,209.82335 l 43.30469,74.37109 83.17578,-37.02344 -12.34766,84.14062 91.91797,15.39454 -64.37109,62.18359 64.37109,62.18359 -91.91797,15.39844 12.34766,84.13672 -83.17578,-37.02344 z M 249.08203,495.2335 280,548.33506 310.91797,495.2335 368.88281,521.03428 360.17969,461.74131 422.41797,451.31553 378.5,408.88975 422.41797,366.46397 360.17969,356.03819 368.88281,296.74522 310.91797,322.546 280,269.44444 249.08203,322.546 l -57.96484,-25.80078 8.70312,59.29297 -62.23828,10.42578 43.91797,42.42578 -43.91797,42.42578 62.23828,10.42578 -8.70312,59.29297 z m 0,0"/>
<path
style="fill:#fec000"
inkscape:connector-curvature="0"
id="path4566"
d="m 427.30859,414.33116 -5.6289,-5.44141 25.16015,-24.30078 39.21094,-37.87891 -55.75,-9.33984 -36.17187,-6.0586 2.80078,-19.09375 9.55078,-65.04687 -83.17969,37.01953 L 280,209.81944 v 59.62109 l 30.92188,53.10938 57.95703,-25.8086 -3.91016,26.66797 -2.54687,17.37891 -2.24219,15.25 2.48047,0.42187 59.76172,10.00781 -43.92188,42.42188 16.96875,16.39062 26.95313,26.03125 -62.24219,10.42969 8.69922,59.29688 -57.95703,-25.8086 L 280,548.33897 v 59.62109 l 43.30078,-74.37109 83.17969,37.01953 -12.35156,-84.14063 91.92187,-15.39843 z m 0,0"/>
<g
id="text4596"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:296.55969238px;line-height:125%;font-family:Impact;-inkscape-font-specification:Impact;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54528999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(1.1590846,-0.34467221,0.22789693,0.794981,24,152.88975)"
aria-label="K">
<path
inkscape:connector-curvature="0"
id="path824"
style="font-size:296.55969238px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54528999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 220.91497,266.9035 -34.89789,105.85211 38.2284,128.58643 H 161.2555 L 136.63873,400.84769 V 501.34204 H 75.676021 V 266.9035 h 60.962709 v 91.08205 l 27.07845,-91.08205 z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

View file

@ -105,40 +105,4 @@
x="1.7763568e-14"
y="-2.3789293e-05"
transform="matrix(1.3333334,0,0,1.3333333,0,7.9297629e-6)"/>
<g
id="g4537"
transform="matrix(1.3333334,0,0,1.3333333,23.517062,16.073517)"
style="filter:url(#filter4545)">
<path
style="fill:#ff0335"
inkscape:connector-curvature="0"
id="path4560"
d="m 280,239.63194 37.10937,63.73828 70.57422,-31.41406 -10.52734,71.71875 77.07812,12.91015 -54.14453,52.30469 54.14453,52.30469 -77.07812,12.91015 10.52734,71.71875 L 317.10937,514.40928 280,578.14756 l -37.10938,-63.73828 -70.57421,31.41406 10.52734,-71.71875 -77.07813,-12.91015 54.14454,-52.30469 -54.14454,-52.30469 77.07813,-12.91015 -10.52734,-71.71875 70.57421,31.41406 z m 0,0"/>
<path
style="fill:#c2001b"
inkscape:connector-curvature="0"
id="path4562"
d="m 454.23047,461.19053 -77.07031,12.91016 10.51953,71.71875 L 317.10938,514.40928 280,578.15147 V 239.62803 l 37.10938,63.74219 70.57031,-31.41016 -6.75781,46.10156 -3.76172,25.61719 58.80078,9.85156 18.26953,3.0586 -13.39063,12.92969 -40.75,39.37109 11.37891,10.98828 z m 0,0"/>
<path
style="fill:#ffdf47"
inkscape:connector-curvature="0"
id="path4564"
d="M 280,607.95616 236.69531,533.58506 153.51953,570.6085 165.86719,486.46787 73.949219,471.07334 138.32031,408.88975 73.949219,346.70616 165.86719,331.31162 153.51953,247.171 236.69922,284.19444 280,209.82335 l 43.30469,74.37109 83.17578,-37.02344 -12.34766,84.14062 91.91797,15.39454 -64.37109,62.18359 64.37109,62.18359 -91.91797,15.39844 12.34766,84.13672 -83.17578,-37.02344 z M 249.08203,495.2335 280,548.33506 310.91797,495.2335 368.88281,521.03428 360.17969,461.74131 422.41797,451.31553 378.5,408.88975 422.41797,366.46397 360.17969,356.03819 368.88281,296.74522 310.91797,322.546 280,269.44444 249.08203,322.546 l -57.96484,-25.80078 8.70312,59.29297 -62.23828,10.42578 43.91797,42.42578 -43.91797,42.42578 62.23828,10.42578 -8.70312,59.29297 z m 0,0"/>
<path
style="fill:#fec000"
inkscape:connector-curvature="0"
id="path4566"
d="m 427.30859,414.33116 -5.6289,-5.44141 25.16015,-24.30078 39.21094,-37.87891 -55.75,-9.33984 -36.17187,-6.0586 2.80078,-19.09375 9.55078,-65.04687 -83.17969,37.01953 L 280,209.81944 v 59.62109 l 30.92188,53.10938 57.95703,-25.8086 -3.91016,26.66797 -2.54687,17.37891 -2.24219,15.25 2.48047,0.42187 59.76172,10.00781 -43.92188,42.42188 16.96875,16.39062 26.95313,26.03125 -62.24219,10.42969 8.69922,59.29688 -57.95703,-25.8086 L 280,548.33897 v 59.62109 l 43.30078,-74.37109 83.17969,37.01953 -12.35156,-84.14063 91.92187,-15.39843 z m 0,0"/>
<g
id="text4596"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:296.55969238px;line-height:125%;font-family:Impact;-inkscape-font-specification:Impact;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54528999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(1.1590846,-0.34467221,0.22789693,0.794981,24,152.88975)"
aria-label="K">
<path
inkscape:connector-curvature="0"
id="path824"
style="font-size:296.55969238px;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.54528999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 220.91497,266.9035 -34.89789,105.85211 38.2284,128.58643 H 161.2555 L 136.63873,400.84769 V 501.34204 H 75.676021 V 266.9035 h 60.962709 v 91.08205 l 27.07845,-91.08205 z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

View file

@ -1,11 +1,35 @@
import { defineQueryOptions } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import type { components } from '@/generated/openapi/komga'
export const QUERY_KEYS_BOOKS = {
root: ['books'] as const,
bySearch: (search: components['schemas']['BookSearch']) =>
[...QUERY_KEYS_BOOKS.root, JSON.stringify(search)] as const,
byId: (bookId: string) => [...QUERY_KEYS_BOOKS.root, bookId] as const,
}
export const bookListQuery = defineQueryOptions(
({
search,
pause = false,
}: {
search: components['schemas']['BookSearch']
pause?: boolean
}) => ({
key: QUERY_KEYS_BOOKS.bySearch(search),
query: () =>
komgaClient
.POST('/api/v1/books/list', {
body: search,
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
enabled: !pause,
placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any
}),
)
export const bookDetailQuery = defineQueryOptions(({ bookId }: { bookId: string }) => ({
key: QUERY_KEYS_BOOKS.byId(bookId),
query: () =>

View file

@ -1,11 +1,35 @@
import { defineQueryOptions } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import type { components } from '@/generated/openapi/komga'
export const QUERY_KEYS_SERIES = {
root: ['series'] as const,
bySearch: (search: components['schemas']['SeriesSearch']) =>
[...QUERY_KEYS_SERIES.root, JSON.stringify(search)] as const,
byId: (seriesId: string) => [...QUERY_KEYS_SERIES.root, seriesId] as const,
}
export const seriesListQuery = defineQueryOptions(
({
search,
pause = false,
}: {
search: components['schemas']['SeriesSearch']
pause: boolean
}) => ({
key: QUERY_KEYS_SERIES.bySearch(search),
query: () =>
komgaClient
.POST('/api/v1/series/list', {
body: search,
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
enabled: !pause,
placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any
}),
)
export const seriesDetailQuery = defineQueryOptions(({ seriesId }: { seriesId: string }) => ({
key: QUERY_KEYS_SERIES.byId(seriesId),
query: () =>

View file

@ -0,0 +1,39 @@
import { defineQueryOptions } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
export const QUERY_KEYS_TRANSIENT_BOOKS = {
root: ['transient-books'] as const,
byPath: (path: string) => [...QUERY_KEYS_TRANSIENT_BOOKS.root, path] as const,
byId: (transientBookId: string) => [...QUERY_KEYS_TRANSIENT_BOOKS.root, transientBookId] as const,
}
export const transientBooksScan = defineQueryOptions(({ path }: { path: string }) => ({
key: QUERY_KEYS_TRANSIENT_BOOKS.byPath(path),
enabled: path.length > 0,
query: () =>
komgaClient
.POST('/api/v1/transient-books', {
body: {
path: path,
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
}))
export const transientBookAnalyze = defineQueryOptions(
({ transientBookId }: { transientBookId: string }) => ({
key: QUERY_KEYS_TRANSIENT_BOOKS.byId(transientBookId),
query: () =>
komgaClient
.POST('/api/v1/transient-books/{id}/analyze', {
params: {
path: {
id: transientBookId,
},
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
}),
)

View file

@ -12,8 +12,10 @@ declare module 'vue' {
ApikeyDeletionWarning: typeof import('./components/apikey/DeletionWarning.vue')['default']
ApikeyForceSyncWarning: typeof import('./components/apikey/ForceSyncWarning.vue')['default']
AppFooter: typeof import('./components/AppFooter.vue')['default']
DialogBookPicker: typeof import('./components/dialog/BookPicker.vue')['default']
DialogConfirm: typeof import('./components/dialog/Confirm.vue')['default']
DialogConfirmEdit: typeof import('./components/dialog/ConfirmEdit.vue')['default']
DialogFileNamePicker: typeof import('./components/dialog/FileNamePicker.vue')['default']
EmptyStateNetworkError: typeof import('./components/EmptyStateNetworkError.vue')['default']
FormattedMessage: typeof import('./components/FormattedMessage.ts')['default']
FragmentApikeyGenerateDialog: typeof import('./fragments/fragment/apikey/GenerateDialog.vue')['default']
@ -22,6 +24,7 @@ declare module 'vue' {
FragmentBuildVersion: typeof import('./fragments/fragment/BuildVersion.vue')['default']
FragmentDialogConfirm: typeof import('./fragments/fragment/dialog/Confirm.vue')['default']
FragmentDialogConfirmEdit: typeof import('./fragments/fragment/dialog/ConfirmEdit.vue')['default']
FragmentDialogSeriesPicker: typeof import('./fragments/fragment/dialog/SeriesPicker.vue')['default']
FragmentHistoryExpandBookConverted: typeof import('./fragments/fragment/history/expand/BookConverted.vue')['default']
FragmentHistoryExpandBookFileDeleted: typeof import('./fragments/fragment/history/expand/BookFileDeleted.vue')['default']
FragmentHistoryExpandBookImported: typeof import('./fragments/fragment/history/expand/BookImported.vue')['default']
@ -29,13 +32,16 @@ declare module 'vue' {
FragmentHistoryExpandSeriesDirectoryDeleted: typeof import('./fragments/fragment/history/expand/SeriesDirectoryDeleted.vue')['default']
FragmentHistoryExpandTable: typeof import('./fragments/fragment/history/expand/Table.vue')['default']
FragmentHistoryTable: typeof import('./fragments/fragment/history/Table.vue')['default']
FragmentImportBooksTransientBooksTable: typeof import('./fragments/fragment/import/books/TransientBooksTable.vue')['default']
FragmentLocaleSelector: typeof import('./fragments/fragment/LocaleSelector.vue')['default']
FragmentRemoteFileList: typeof import('./fragments/fragment/RemoteFileList.vue')['default']
FragmentSnackQueue: typeof import('./fragments/fragment/SnackQueue.vue')['default']
FragmentThemeSelector: typeof import('./fragments/fragment/ThemeSelector.vue')['default']
FragmentUserAuthenticationActivityTable: typeof import('./fragments/fragment/user/AuthenticationActivityTable.vue')['default']
FragmentUserFormCreateEdit: typeof import('./fragments/fragment/user/form/CreateEdit.vue')['default']
FragmentUserTable: typeof import('./fragments/fragment/user/Table.vue')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
ImportBooksDirectorySelection: typeof import('./components/import/books/DirectorySelection.vue')['default']
LayoutAppBar: typeof import('./fragments/layout/app/Bar.vue')['default']
LayoutAppDrawer: typeof import('./fragments/layout/app/drawer/Drawer.vue')['default']
LayoutAppDrawerFooter: typeof import('./fragments/layout/app/drawer/Footer.vue')['default']

View file

@ -0,0 +1,9 @@
import { Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './BookPicker.stories';
<Meta of={Stories} />
# DialogBookPicker
Pick book from the provided selection. Can also be filtered.

View file

@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BookPicker from './BookPicker.vue'
import { fn } from 'storybook/test'
import { mockBooks } from '@/mocks/api/handlers/books'
const meta = {
component: BookPicker,
render: (args: object) => ({
components: { BookPicker },
setup() {
return { args }
},
template: '<BookPicker v-bind="args"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {
dialog: true,
onSelectedBook: fn(),
books: mockBooks(5),
},
} satisfies Meta<typeof BookPicker>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
...meta.args,
},
}
export const PresetFilter: Story = {
args: {
filter: '3',
...meta.args,
},
}
export const LargeList: Story = {
args: {
...meta.args,
books: mockBooks(500),
},
}

View file

@ -0,0 +1,174 @@
<template>
<v-dialog
v-model="showDialog"
:activator="activator"
:fullscreen="fullscreen"
:transition="fullscreen ? 'dialog-bottom-transition' : undefined"
max-width="600px"
scrollable
:aria-label="$formatMessage(titleMessage)"
@after-leave="reset()"
>
<template #default="{ isActive }">
<v-card :title="$formatMessage(titleMessage)">
<template #append>
<v-icon
icon="i-mdi:close"
@click="isActive.value = false"
/>
</template>
<v-card-text>
<v-text-field
v-model="filterRef"
:placeholder="
$formatMessage({
description: 'Book picker dialog: filter field label',
defaultMessage: 'Search',
id: 'G25PY/',
})
"
variant="outlined"
flat
hide-details
autofocus
prepend-inner-icon="i-mdi:magnify"
/>
<v-divider class="my-2" />
<v-data-table
:items="books"
:headers="bookTableHeaders"
:search="filterRef"
:custom-filter="filterFn"
fixed-header
fixed-footer
style="height: 90%"
>
<template #top>
<v-toolbar
flat
:title="
$formatMessage({
description: 'Book picker dialog: series books table header',
defaultMessage: 'Series books',
id: 'DK4PsD',
})
"
>
</v-toolbar>
</template>
<template #[`item.metadata.title`]="{ item }">
<v-list-item
class="px-0 cursor-pointer"
@click="pick(item)"
>
<template #prepend>
<v-img
width="52"
height="75"
contain
:src="bookThumbnailUrl(item.id)"
lazy-src="@/assets/cover.svg"
class="me-2"
/>
</template>
<v-list-item-title>{{ item.metadata.title }}</v-list-item-title>
<v-list-item-subtitle v-if="item.metadata.releaseDate"
>{{
$formatDate(item.metadata.releaseDate, { dateStyle: 'long', timeZone: 'UTC' })
}}
</v-list-item-subtitle>
</v-list-item>
</template>
</v-data-table>
</v-card-text>
</v-card>
</template>
</v-dialog>
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
import { bookThumbnailUrl } from '@/api/images'
import { useIntl } from 'vue-intl'
const intl = useIntl()
const showDialog = defineModel<boolean>('dialog', { required: false })
const {
filter = '',
books = [],
fullscreen = undefined,
activator = undefined,
} = defineProps<{
filter?: string
books?: components['schemas']['BookDto'][]
fullscreen?: boolean
activator?: Element | string
}>()
const emit = defineEmits<{
selectedBook: [book: components['schemas']['BookDto']]
}>()
const filterRef = ref<string>(filter)
// sync the ref if the prop changes
watch(
() => filter,
(it) => (filterRef.value = it),
)
function pick(selectedBook: components['schemas']['BookDto']) {
emit('selectedBook', selectedBook)
showDialog.value = false
}
function reset() {
filterRef.value = filter
}
const titleMessage = {
description: 'Book picker dialog: title',
defaultMessage: 'Select book',
id: 'ycrpqO',
}
function filterFn(
value: string,
query: string,
item?: { raw: components['schemas']['BookDto'] },
): boolean | number | [number, number] | [number, number][] {
return (
item?.raw.metadata.title.includes(query) ||
item?.raw.metadata.number.includes(query) ||
item?.raw.metadata.releaseDate?.includes(query) ||
false
)
}
const bookTableHeaders = [
{
title: intl.formatMessage({
description: 'Book picker dialog: series books table header: number',
defaultMessage: 'Number',
id: 'EIYfj+',
}),
key: 'metadata.number',
},
{
title: intl.formatMessage({
description: 'Book picker dialog: series books table header: book details',
defaultMessage: 'Details',
id: 'yrE0Rx',
}),
key: 'metadata.title',
},
]
</script>
<script setup lang="ts"></script>

View file

@ -0,0 +1,9 @@
import { Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './FileNamePicker.stories';
<Meta of={Stories} />
# DialogFileNamePicker
Displays file names for series books for easy picking.

View file

@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import FileNamePicker from './FileNamePicker.vue'
import { fn } from 'storybook/test'
import { mockBooks } from '@/mocks/api/handlers/books'
const meta = {
component: FileNamePicker,
render: (args: object) => ({
components: { FileNamePicker },
setup() {
return { args }
},
template: '<FileNamePicker v-bind="args"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {
dialog: true,
existingName: 'existing filename.cbz',
onSelectedName: fn(),
},
} satisfies Meta<typeof FileNamePicker>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
seriesBooks: mockBooks(5),
},
}
export const LargeList: Story = {
args: {
seriesBooks: mockBooks(1000),
},
}
export const NoBooks: Story = {
args: {},
}

View file

@ -0,0 +1,193 @@
<template>
<v-dialog
v-model="showDialog"
:activator="activator"
:fullscreen="fullscreen"
scrollable
:transition="fullscreen ? 'dialog-bottom-transition' : undefined"
max-width="600px"
:aria-label="$formatMessage(titleMessage)"
@after-leave="reset()"
>
<template #default="{ isActive }">
<v-card :title="$formatMessage(titleMessage)">
<template #append>
<v-icon
icon="i-mdi:close"
@click="isActive.value = false"
/>
</template>
<v-card-text>
<v-container fluid>
<v-row>
<v-col>
<div class="text-subtitle-2">
{{
$formatMessage({
description: 'File name picker dialog: source file name field label',
defaultMessage: 'Source file name',
id: 'lSlhp0',
})
}}
</div>
<div>
{{ existingName }}
</div>
</v-col>
</v-row>
<v-row align="center">
<v-col>
<v-text-field
v-model="newName"
autofocus
:label="
$formatMessage({
description: 'File name picker dialog: destination file name field label',
defaultMessage: 'Destination file name',
id: 'bFoCHJ',
})
"
variant="underlined"
append-inner-icon="i-mdi:restore"
hide-details
@click:append-inner="newName = existingName"
@keydown.enter="choose"
/>
</v-col>
<v-col cols="auto">
<v-btn
:disabled="!newName"
:text="
$formatMessage({
description: 'File name picker dialog: confirmation button',
defaultMessage: 'Choose',
id: 'd2J/J/',
})
"
@click="choose"
/>
</v-col>
</v-row>
</v-container>
<v-divider class="my-2" />
<v-data-table
v-if="seriesBooks"
:items="seriesBooks"
:headers="bookTableHeaders"
fixed-header
fixed-footer
style="height: 80%"
>
<template #top>
<v-toolbar
flat
:title="
$formatMessage({
description: 'File name picker dialog: series books table header',
defaultMessage: 'Series books',
id: 'Vpmsx0',
})
"
></v-toolbar>
</template>
<template #[`item.name`]="{ value }">
<span
class="cursor-pointer"
@click="newName = value"
>{{ value }}</span
>
</template>
</v-data-table>
<v-alert
v-else
type="info"
variant="tonal"
:text="
$formatMessage({
description:
'File name picker dialog: info text shown when there are no existing series books to show',
defaultMessage: 'Select a series to see its books',
id: 'FSJoDl',
})
"
/>
</v-card-text>
</v-card>
</template>
</v-dialog>
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
import { useIntl } from 'vue-intl'
const intl = useIntl()
const showDialog = defineModel<boolean>('dialog', { required: false })
const {
fullscreen = undefined,
activator = undefined,
existingName = '',
seriesBooks,
} = defineProps<{
fullscreen?: boolean
activator?: Element | string
existingName?: string
seriesBooks?: components['schemas']['BookDto'][]
}>()
const emit = defineEmits<{
selectedName: [name: string]
}>()
const newName = ref<string>(existingName)
// sync the ref if the prop changes
watch(
() => existingName,
(it) => (newName.value = it),
)
function choose() {
emit('selectedName', newName.value)
showDialog.value = false
}
function reset() {
newName.value = existingName
}
const titleMessage = {
description: 'Filename picker dialog: title',
defaultMessage: 'Destination filename',
id: 'ycrpqO',
}
const bookTableHeaders = [
{
title: intl.formatMessage({
description: 'File name picker dialog: series books table header: order',
defaultMessage: 'Order',
id: 'rhtmLf',
}),
key: 'number',
},
{
title: intl.formatMessage({
description: 'File name picker dialog: series books table header: existing file name',
defaultMessage: 'Existing file',
id: 'IEUgyy',
}),
key: 'name',
},
]
</script>
<script setup lang="ts"></script>

View file

@ -0,0 +1,11 @@
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './DirectorySelection.stories';
<Meta of={Stories} />
# ImportBookDirectorySelection
Directory can be selected using the *Browse* button, or input directly.
<Canvas of={Stories.Default} />

View file

@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import DirectorySelection from './DirectorySelection.vue'
import DialogConfirmEdit from '@/fragments/fragment/dialog/ConfirmEdit.vue'
import { useAppStore } from '@/stores/app'
import { fn } from 'storybook/test'
const meta = {
component: DirectorySelection,
render: (args: object) => ({
components: { DirectorySelection, DialogConfirmEdit },
setup() {
return { args }
},
template: '<DirectorySelection v-bind="args"/><DialogConfirmEdit/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {
onScan: fn(),
},
} satisfies Meta<typeof DirectorySelection>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const PresetPath: Story = {
play: () => {
const appStore = useAppStore()
appStore.importBooksPath = '/comics'
},
}

View file

@ -0,0 +1,97 @@
<template>
<v-container fluid>
<v-row align="center">
<v-col
cols="12"
sm=""
>
<v-text-field
v-model="appStore.importBooksPath"
hide-details
clearable
:label="
$formatMessage({
description: 'Import books directory selection: directory text field label',
defaultMessage: 'Import from directory',
id: 'z8b1Xe',
})
"
/>
</v-col>
<v-col cols="auto">
<v-btn
color=""
@click="browse"
@mouseenter="dialogConfirmEdit.activator = $event.currentTarget"
>{{
$formatMessage({
description: 'Import books directory selection: file browser button label',
defaultMessage: 'Browse',
id: 'Usohru',
})
}}
</v-btn>
</v-col>
<v-col cols="auto">
<v-btn
:disabled="!appStore.importBooksPath || loading"
:text="
$formatMessage({
description: 'Import books: scan button label',
defaultMessage: 'Scan',
id: 'uwFE74',
})
"
@click="emit('scan', appStore.importBooksPath)"
/>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useDialogsStore } from '@/stores/dialogs'
import FragmentRemoteFileList from '@/fragments/fragment/RemoteFileList.vue'
import { useDisplay } from 'vuetify'
import { useAppStore } from '@/stores/app'
import { useIntl } from 'vue-intl'
const intl = useIntl()
const display = useDisplay()
const appStore = useAppStore()
const { confirmEdit: dialogConfirmEdit } = storeToRefs(useDialogsStore())
const { loading = false } = defineProps<{
loading?: boolean
}>()
const emit = defineEmits<{
scan: [directory: string]
}>()
function browse() {
dialogConfirmEdit.value.dialogProps = {
title: intl.formatMessage({
description: 'Import books: directory selection dialog title',
defaultMessage: 'Import from directory',
id: '8Om/o/',
}),
maxWidth: 600,
closeOnSave: true,
scrollable: true,
fullscreen: display.xs.value,
}
dialogConfirmEdit.value.slot = {
component: markRaw(FragmentRemoteFileList),
props: {},
}
dialogConfirmEdit.value.record = appStore.importBooksPath || '' // workaround for https://github.com/vuetifyjs/vuetify/issues/4144
dialogConfirmEdit.value.callback = () => {
appStore.importBooksPath = dialogConfirmEdit.value.record as string
}
}
</script>

View file

@ -0,0 +1,18 @@
import { useIntl } from 'vue-intl'
import { errorCodeMessages } from '@/utils/i18n/enum/error-codes'
export function useErrorCodeFormatter() {
const intl = useIntl()
function convertErrorCodes(message: string): string {
const match = message.match(/ERR_\d{4}/g)
let r = message
match?.forEach((errorCode) => {
if (errorCodeMessages[errorCode])
r = r.replace(errorCode, intl.formatMessage(errorCodeMessages[errorCode]))
})
return r
}
return { convertErrorCodes }
}

View file

@ -0,0 +1,11 @@
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './RemoteFileList.stories';
<Meta of={Stories} />
# FragmentRemoteFileList
A remote directory browser, to browse directories on a remote server.
<Canvas of={Stories.Default} />

View file

@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { http, delay } from 'msw'
import RemoteFileList from './RemoteFileList.vue'
import { response401Unauthorized } from '@/mocks/api/handlers'
const meta = {
component: RemoteFileList,
render: (args: object) => ({
components: { RemoteFileList },
setup() {
return { args }
},
template: '<RemoteFileList v-bind="args"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof RemoteFileList>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const PresetPath: Story = {
args: {
modelValue: '/comics',
},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [http.all('*', async () => await delay(2_000))],
},
},
}
export const Error: Story = {
parameters: {
msw: {
handlers: [http.all('*', response401Unauthorized)],
},
},
}

View file

@ -0,0 +1,79 @@
<template>
<EmptyStateNetworkError v-if="error" />
<template v-else>
<v-list
:disabled="isLoading"
elevation="2"
>
<v-progress-linear
v-if="isLoading"
indeterminate
height="3"
class="position-absolute top-0"
/>
<v-text-field
v-model="selectedPath"
readonly
label="Selected path"
variant="solo"
flat
hide-details
/>
<v-divider class="my-2" />
<template v-if="directoryListing">
<!-- Parent directory -->
<v-list-item
v-if="directoryListing.parent || directoryListing.parent === ''"
:title="
$formatMessage({
description: 'File browser: parent directory',
defaultMessage: 'Parent',
id: 'B48EcS',
})
"
prepend-icon="i-mdi:arrow-left"
@click="selectedPath = directoryListing.parent"
/>
<!-- Directory listing -->
<v-list-item
v-for="(dir, index) in directoryListing.directories"
:key="index"
:title="dir.name"
prepend-icon="i-mdi:folder"
@click="selectedPath = dir.path"
/>
</template>
</v-list>
</template>
</template>
<script setup lang="ts">
import { useQuery } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
const selectedPath = defineModel<string>({ required: false, default: '' })
const {
data: directoryListing,
isLoading,
error,
} = useQuery({
key: () => ['filesystem', selectedPath.value],
query: () =>
komgaClient
.POST('/api/v1/filesystem', {
body: {
path: selectedPath.value,
showFiles: false,
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any
})
</script>

View file

@ -0,0 +1,9 @@
import { Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './SeriesPicker.stories';
<Meta of={Stories} />
# FragmentDialogSeriesPicker
Search and pick series.

View file

@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { http, delay } from 'msw'
import SeriesPicker from './SeriesPicker.vue'
import { response401Unauthorized } from '@/mocks/api/handlers'
import { fn } from 'storybook/test'
const meta = {
component: SeriesPicker,
render: (args: object) => ({
components: { SeriesPicker },
setup() {
return { args }
},
template: '<SeriesPicker v-bind="args"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {
dialog: true,
onSelectedSeries: fn(),
},
} satisfies Meta<typeof SeriesPicker>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const PresetSearch: Story = {
args: {
searchString: 'd',
},
}
export const NoResults: Story = {
args: {
searchString: 'not found',
},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [http.all('*', async () => await delay(2_000))],
},
},
}
export const Error: Story = {
args: {
searchString: 'd',
},
parameters: {
msw: {
handlers: [http.all('*', response401Unauthorized)],
},
},
}

View file

@ -0,0 +1,180 @@
<template>
<v-dialog
v-model="showDialog"
:activator="activator"
:fullscreen="fullscreen"
:transition="fullscreen ? 'dialog-bottom-transition' : undefined"
max-width="600px"
:aria-label="$formatMessage(titleMessage)"
@after-leave="reset()"
>
<template #default="{ isActive }">
<v-card :title="$formatMessage(titleMessage)">
<template #append>
<v-icon
icon="i-mdi:close"
@click="isActive.value = false"
/>
</template>
<v-card-text>
<v-list
:disabled="isLoading"
elevation="2"
>
<v-progress-linear
v-if="isLoading"
indeterminate
height="3"
class="position-absolute top-0"
/>
<v-text-field
v-model="searchStringRef"
:label="
$formatMessage({
description: 'Series picker dialog: search field label',
defaultMessage: 'Search series',
id: 'tPD6YO',
})
"
variant="solo"
flat
hide-details
autofocus
clearable
/>
<template v-if="searchStringRef">
<v-divider class="my-2" />
<template v-if="series?.content">
<v-list-item
v-for="(s, index) in series.content"
:key="index"
@click="pick(s)"
>
<template #prepend>
<v-img
width="52"
height="75"
contain
:src="seriesThumbnailUrl(s.id)"
lazy-src="@/assets/cover.svg"
class="me-2"
/>
</template>
<v-list-item-title>{{ s.metadata.title }}</v-list-item-title>
<v-list-item-subtitle
>{{ libraries?.find((it) => it.id === s.libraryId)?.name }}
</v-list-item-subtitle>
<v-list-item-subtitle v-if="s.booksMetadata.releaseDate"
>{{
$formatDate(s.booksMetadata.releaseDate, { year: 'numeric', timeZone: 'UTC' })
}}
</v-list-item-subtitle>
</v-list-item>
</template>
<v-empty-state
v-if="series?.content?.length == 0"
icon="i-mdi:magnify"
:title="
$formatMessage({
description: 'Series picker dialog: no results empty state - title',
defaultMessage: 'No series found',
id: '4g2M9O',
})
"
:text="
$formatMessage({
description: 'Series picker dialog: no results empty state - text',
defaultMessage: 'Try searching for something else',
id: 'lzHPYD',
})
"
color="secondary"
/>
<EmptyStateNetworkError v-if="error" />
</template>
</v-list>
</v-card-text>
</v-card>
</template>
</v-dialog>
</template>
<script setup lang="ts">
import { useQuery } from '@pinia/colada'
import { seriesListQuery } from '@/colada/series'
import type { components } from '@/generated/openapi/komga'
import { seriesThumbnailUrl } from '@/api/images'
import { refDebounced } from '@vueuse/core'
import { useLibraries } from '@/colada/libraries'
const showDialog = defineModel<boolean>('dialog', { required: false })
const {
includeOneShots = true,
searchString = '',
fullscreen = undefined,
activator = undefined,
} = defineProps<{
includeOneShots?: boolean
searchString?: string
fullscreen?: boolean
activator?: Element | string
}>()
const emit = defineEmits<{
selectedSeries: [series: components['schemas']['SeriesDto']]
}>()
const searchStringRef = ref<string>(searchString)
// sync the ref if the prop changes
watch(
() => searchString,
(it) => (searchStringRef.value = it),
)
const searchStringDebounced = refDebounced(searchStringRef, 500)
const {
data: series,
isLoading,
error,
} = useQuery(seriesListQuery, () => {
const search: components['schemas']['SeriesSearch'] = {
fullTextSearch: searchStringDebounced.value,
...(!includeOneShots && {
condition: {
oneShot: { operator: 'IsFalse' },
},
}),
}
return {
search: search,
pause: !searchStringDebounced.value,
}
})
const { data: libraries } = useLibraries()
function pick(selectedSeries: components['schemas']['SeriesDto']) {
emit('selectedSeries', selectedSeries)
showDialog.value = false
}
function reset() {
searchStringRef.value = searchString
}
const titleMessage = {
description: 'Series picker dialog: title',
defaultMessage: 'Select series',
id: 'ycrpqO',
}
</script>
<script setup lang="ts"></script>

View file

@ -0,0 +1,15 @@
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './TransientBooksTable.stories';
<Meta of={Stories} />
# FragmentImportBooksTransientBooksTable
Data table displaying books to import.
- When books come into view, they will be analyzed.
- Only `READY` books can be selected.
- If a book has a `seriesId` returned by the server, it will be automatically selected.
- If a book has a book number returned by the server, it will be automatically selected for upgrade.
<Canvas of={Stories.Default} />

View file

@ -0,0 +1,58 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { http, delay } from 'msw'
import TransientBooksTable from './TransientBooksTable.vue'
import { response401Unauthorized } from '@/mocks/api/handlers'
import { scanned } from '@/mocks/api/handlers/transient-books'
import SnackQueue from '@/fragments/fragment/SnackQueue.vue'
const meta = {
component: TransientBooksTable,
subcomponents: { SnackQueue },
render: (args: object) => ({
components: { TransientBooksTable, SnackQueue },
setup() {
return { args }
},
template: '<TransientBooksTable v-bind="args"/><SnackQueue/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof TransientBooksTable>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
books: scanned,
},
}
export const Empty: Story = {
args: {},
}
export const Loading: Story = {
args: {
books: scanned,
},
parameters: {
msw: {
handlers: [http.all('*', async () => await delay(2_000))],
},
},
}
export const ErrorOnImport: Story = {
args: {
books: scanned,
},
parameters: {
msw: {
handlers: [http.all('*/v1/books/import', response401Unauthorized)],
},
},
}

View file

@ -0,0 +1,559 @@
<template>
<v-data-table
v-model="selectedBookIds"
v-model:items-per-page="itemsPerPage"
:loading="importing || loading"
:items="importBooks"
item-value="transientBook.id"
:headers="headers"
:items-per-page-options="itemsPerPageOptions"
:hide-default-footer="hideFooter"
fixed-header
fixed-footer
show-select
item-selectable="selectable"
select-strategy="page"
mobile-breakpoint="md"
@update:current-items="onDisplayedItems"
>
<template #no-data>
<v-empty-state
icon="i-mdi:book-search"
:title="
$formatMessage({
description: 'Import books table: shown when table has no data - title',
defaultMessage: 'No books found',
id: 'VvjZEl',
})
"
:text="
$formatMessage({
description: 'Import books table: shown when table has no data - subtitle',
defaultMessage: 'Try scanning another directory',
id: '9VuyZU',
})
"
/>
</template>
<template #[`header.analysisStatus`]>
<v-icon icon="i-mdi:file-check-outline" />
</template>
<template #[`item.analysisStatus`]="{ item }: { item: BookImport }">
<v-progress-circular
v-if="item.transientBook.status === MediaStatus.UNKNOWN.valueOf()"
indeterminate
color="primary"
:size="20"
:width="2"
/>
<v-icon
v-if="
item.transientBook.status === MediaStatus.ERROR.valueOf() ||
item.transientBook.status === MediaStatus.UNSUPPORTED.valueOf()
"
v-tooltip="convertErrorCodes(item.transientBook.comment)"
icon="i-mdi:alert-circle"
color="error"
/>
<v-icon
v-if="item.transientBook.status === MediaStatus.READY.valueOf()"
icon="i-mdi:check-circle"
color="success"
/>
</template>
<template #[`item.transientBook.name`]="{ value, item }">
<span :class="item.imported ? 'text-disabled' : undefined">{{ value }}</span>
</template>
<template #[`item.series`]="{ item, internalItem, isSelected }">
<div
:class="item.selectable ? 'cursor-pointer' : 'cursor-not-allowed'"
@mouseenter="
item.selectable
? (dialogSeriesPickerActivator = $event.currentTarget as Element)
: (dialogSeriesPickerActivator = undefined)
"
@click="item.selectable ? (currentActionedItem = [item]) : undefined"
>
<span
v-if="item.series"
:class="item.imported ? 'text-disabled' : undefined"
>{{ item.series?.metadata.title }}</span
>
<template v-else>
<div
style="height: 2em"
:class="isSelected(internalItem) ? 'missing' : ''"
/>
</template>
</div>
</template>
<template #[`item.upgradeBook`]="{ item }">
<div
@mouseenter="
item.upgradable
? (dialogBookPickerActivator = $event.currentTarget as Element)
: undefined
"
@click="item.upgradable ? (currentActionedItem = [item]) : undefined"
>
<v-chip
v-if="item.upgradeBook"
variant="text"
closable
class="cursor-pointer"
:disabled="!item.upgradable"
@click:close="unassignBook(item)"
>
{{ item.upgradeBook.metadata.number }} - {{ item.upgradeBook.metadata.title }}
</v-chip>
<v-btn
v-else
color=""
size="small"
:disabled="!item.upgradable"
prepend-icon="i-mdi:file-replace-outline"
:text="
$formatMessage({
description: 'Import books table: book upgrade button',
defaultMessage: 'Upgrade',
id: 'hrh5Rn',
})
"
/>
</div>
</template>
<template #[`item.destinationName`]="{ item }">
<div
:class="
(item.selectable ? 'cursor-pointer' : 'cursor-not-allowed') +
' ' +
(item.imported ? 'text-disabled' : undefined)
"
@mouseenter="
item.selectable
? (dialogFileNamePickerActivator = $event.currentTarget as Element)
: (dialogFileNamePickerActivator = undefined)
"
@click="item.selectable ? (currentActionedItem = [item]) : undefined"
>
{{ item.destinationName }}
</div>
</template>
<template #[`header.statusMessage`]>
<v-icon icon="i-mdi:alert-circle-outline" />
</template>
<template #[`item.statusMessage`]="{ item, value, internalItem, isSelected }">
<v-icon
v-if="item.imported"
v-tooltip="value"
icon="i-mdi:import"
color="info"
/>
<template v-else-if="isSelected(internalItem)">
<v-icon
v-if="item.upgradeBook"
v-tooltip="value"
icon="i-mdi:file-replace"
color="warning"
/>
<v-icon
v-else-if="value"
v-tooltip="value"
icon="i-mdi:alert-circle"
color="error"
/>
</template>
</template>
</v-data-table>
<v-container fluid>
<v-row
justify="space-between"
align="center"
>
<v-col cols="auto">
<v-select
v-model="copyMode"
:label="
$formatMessage({
description: 'Import books table: bottom bar: import mode selection dropdown label',
defaultMessage: 'Import mode',
id: '14/Uh8',
})
"
hide-details
:items="copyOptions"
variant="outlined"
min-width="250"
></v-select>
</v-col>
<v-col>
<v-btn
:text="
$formatMessage({
description: 'Import books table: bottom bar: select series button',
defaultMessage: 'Select series',
id: 'SqZoei',
})
"
color=""
:disabled="loading || selectedBookIds.length == 0"
@mouseenter="dialogSeriesPickerActivator = $event.currentTarget as Element"
@click="currentActionedItem = selectedBooks"
/>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
:text="
$formatMessage({
description: 'Import books table: bottom bar: import button',
defaultMessage: 'Import',
id: 'RHJo8j',
})
"
:disabled="importing || loading || importBatch.books.length == 0"
@click="doImportBooks"
/>
</v-col>
</v-row>
</v-container>
<FragmentDialogSeriesPicker
:activator="dialogSeriesPickerActivator"
:fullscreen="display.xs.value"
@selected-series="(series) => seriesPicked(series)"
/>
<DialogBookPicker
:activator="dialogBookPickerActivator"
:fullscreen="display.xs.value"
:books="currentActionedItem?.at(0)?.seriesBooks"
@selected-book="(book) => bookPicked(book)"
/>
<DialogFileNamePicker
:activator="dialogFileNamePickerActivator"
:fullscreen="display.xs.value"
:existing-name="currentActionedItem?.at(0)?.destinationName"
:series-books="currentActionedItem?.at(0)?.seriesBooks"
@selected-name="(name) => fileNamePicked(name)"
/>
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
import { useIntl } from 'vue-intl'
import { MediaStatus } from '@/types/MediaStatus'
import { useErrorCodeFormatter } from '@/composables/errorCodeFormatter'
import { syncRefs, useArrayFilter, useArrayMap } from '@vueuse/core'
import { useDisplay } from 'vuetify'
import { useMutation, useQuery } from '@pinia/colada'
import { seriesDetailQuery } from '@/colada/series'
import FileNamePickerDialog from '@/components/dialog/FileNamePicker.vue'
import { bookListQuery } from '@/colada/books'
import { transientBookAnalyze } from '@/colada/transient-books'
import BookPickerDialog from '@/components/dialog/BookPicker.vue'
import { type ErrorCause, komgaClient } from '@/api/komga-client'
import { commonMessages } from '@/utils/i18n/common-messages'
import { useMessagesStore } from '@/stores/messages'
class BookImport {
transientBook: components['schemas']['TransientBookDto']
destinationName: string
series?: components['schemas']['SeriesDto']
seriesBooks?: components['schemas']['BookDto'][]
upgradeBook?: components['schemas']['BookDto']
imported: boolean
constructor(transientBook: components['schemas']['TransientBookDto']) {
this.transientBook = transientBook
this.destinationName = transientBook.name
this.imported = false
}
/**
* Whether the book is selectable.
* Only books in READY status and not yet imported can be selected
*/
public get selectable(): boolean {
return this.transientBook.status === MediaStatus.READY.valueOf() && !this.imported
}
public get upgradable(): boolean {
return this.selectable && !!this.series && !!this.seriesBooks
}
public get importable(): boolean {
return this.selectable && !!this.series
}
public get statusMessage(): string {
switch (this.transientBook.status) {
case MediaStatus.UNKNOWN.valueOf():
return 'Book needs to be analyzed first'
case MediaStatus.UNSUPPORTED.valueOf():
return 'Book format is not supported'
case MediaStatus.ERROR.valueOf():
return 'Book could not be analyzed'
}
if (!this.series) return 'Choose a series'
if (this.imported) return 'Import requested'
if (this.upgradeBook) return 'Book will be upgraded'
return ''
}
}
const messagesStore = useMessagesStore()
const display = useDisplay()
const intl = useIntl()
const { convertErrorCodes } = useErrorCodeFormatter()
const { books = [], loading = false } = defineProps<{
books?: components['schemas']['TransientBookDto'][]
loading?: boolean
}>()
// a read-only array of BookImport, kept in sync with the books prop
const importBooksRO = useArrayMap(
toRef(() => books),
(it) => new BookImport(it),
)
// read-write array of BookImport, will be modified by the different actions
const importBooks = ref<BookImport[]>([])
// 1-way sync from importBooksRO to importBooks
syncRefs(importBooksRO, importBooks)
// the current items being acted upon, used for dialog callback
const currentActionedItem = ref<BookImport[]>()
// the selected book IDs, used to programmatically select items
const selectedBookIds = ref<string[]>([])
// the selected books
const selectedBooks = useArrayFilter(importBooks, (it) =>
selectedBookIds.value.includes(it.transientBook.id),
)
const selectedImportableBooks = useArrayFilter(
importBooks,
(it) => selectedBookIds.value.includes(it.transientBook.id) && it.importable,
)
const importBatch = computed(
() =>
({
copyMode: copyMode.value,
books: selectedImportableBooks.value.map((it) => ({
destinationName: it.destinationName,
seriesId: it.series?.id,
sourceFile: it.transientBook.url,
upgradeBookId: it.upgradeBook?.id,
})),
}) as components['schemas']['BookImportBatchDto'],
)
// only analyze books that are shown
function onDisplayedItems(items: { key: string }[]) {
importBooks.value
.filter((b) => items.map((it) => it.key).includes(b.transientBook.id))
.forEach((b) => {
if (b.transientBook.status === MediaStatus.UNKNOWN.valueOf()) {
analyzeBook(b)
}
})
}
// Table setup
const itemsPerPage = ref<number>(display.smAndDown.value ? 1 : 10)
const itemsPerPageOptions = computed(() => (display.smAndDown.value ? [1, 5] : [10, 25, 50]))
const hideFooter = computed(() => importBooks.value.length < itemsPerPage.value)
const headers = [
{
title: intl.formatMessage({
description: 'Import books table header: analysis status',
defaultMessage: 'Analysis status',
id: 'f1fW81',
}),
key: 'analysisStatus',
align: 'end',
},
{
title: intl.formatMessage({
description: 'Import books table header: file name',
defaultMessage: 'File name',
id: 'kYDPt1',
}),
key: 'transientBook.name',
},
{
title: intl.formatMessage({
description: 'Import books table header: series',
defaultMessage: 'Series',
id: '3OgH93',
}),
key: 'series',
},
{
title: intl.formatMessage({
description: 'Import books table header: book',
defaultMessage: 'Book',
id: 'Kie8HQ',
}),
key: 'upgradeBook',
},
{
title: intl.formatMessage({
description: 'Import books table header: destination name',
defaultMessage: 'Destination name',
id: 'bSoeY6',
}),
key: 'destinationName',
},
{
title: intl.formatMessage({
description: 'Import books table header: status message',
defaultMessage: 'Status',
id: 'knm6Z+',
}),
key: 'statusMessage',
align: 'end',
},
] as const // workaround for https://github.com/vuetifyjs/vuetify/issues/18901
const copyOptions = [
{
title: 'Hardlink/Copy files',
value: 'HARDLINK',
},
{
title: 'Move files',
value: 'MOVE',
},
]
const copyMode = ref<string>(copyOptions[0]!.value)
// Series Picker Dialog
const dialogSeriesPickerActivator = ref<Element | undefined>(undefined)
function seriesPicked(series: components['schemas']['SeriesDto']) {
if (currentActionedItem.value) {
currentActionedItem.value.forEach((it) => assignSeries(it, series))
}
}
// Book Picker Dialog
const dialogBookPickerActivator = ref<Element | undefined>(undefined)
function bookPicked(book: components['schemas']['BookDto']) {
if (currentActionedItem.value) {
currentActionedItem.value.forEach((it) => assignBookNumber(it, book.metadata.numberSort))
}
}
// File Name Picker dialog
const dialogFileNamePickerActivator = ref<Element | undefined>(undefined)
function fileNamePicked(name: string) {
if (currentActionedItem.value) {
currentActionedItem.value.forEach((it) => (it.destinationName = name))
}
}
function analyzeBook(book: BookImport) {
const { refresh } = useQuery(transientBookAnalyze, () => ({
transientBookId: book.transientBook.id,
}))
void refresh().then(({ data }) => {
if (data) {
book.transientBook = data
if (book.transientBook.seriesId && book.transientBook.seriesId !== book.series?.id)
fetchSeries(book)
}
})
}
function fetchSeries(book: BookImport) {
const { refresh } = useQuery(seriesDetailQuery, () => ({
seriesId: book.transientBook.seriesId!,
}))
void refresh().then(({ data }) => {
if (data) {
assignSeries(book, data)
}
})
}
function fetchBooks(book: BookImport) {
const { refresh } = useQuery(bookListQuery, () => ({
search: {
condition: {
seriesId: { operator: 'Is', value: book.series!.id },
},
} as components['schemas']['BookSearch'],
}))
void refresh().then(({ data }) => {
if (data) {
book.seriesBooks = data.content
if (book.transientBook.number) assignBookNumber(book, book.transientBook.number)
}
})
}
function assignSeries(book: BookImport, series: components['schemas']['SeriesDto']) {
book.series = series
fetchBooks(book)
if (book.importable && !selectedBookIds.value.includes(book.transientBook.id))
selectedBookIds.value.push(book.transientBook.id)
}
function assignBookNumber(book: BookImport, number: number) {
book.upgradeBook = book.seriesBooks?.find((b) => b.metadata.numberSort === number)
}
function unassignBook(book: BookImport) {
book.upgradeBook = undefined
}
const importing = ref<boolean>(false)
function doImportBooks() {
importing.value = true
const { mutateAsync } = useMutation({
mutation: () => komgaClient.POST('/api/v1/books/import', { body: importBatch.value }),
})
mutateAsync()
.then(() => {
selectedImportableBooks.value.forEach((it) => {
it.imported = true
// remove imported books from selection
if (selectedBookIds.value.includes(it.transientBook.id))
selectedBookIds.value.splice(selectedBookIds.value.indexOf(it.transientBook.id), 1)
})
})
.catch((error) => {
messagesStore.messages.push({
text:
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
})
})
.finally(() => {
importing.value = false
})
}
</script>
<style scoped>
.missing {
border: 2px dashed red;
}
</style>

View file

@ -10,21 +10,28 @@ import { claimHandlers } from '@/mocks/api/handlers/claim'
import { historyHandlers } from '@/mocks/api/handlers/history'
import { seriesHandlers } from '@/mocks/api/handlers/series'
import { booksHandlers } from '@/mocks/api/handlers/books'
import { filesystemHandlers } from '@/mocks/api/handlers/filesystem'
import { transientBooksHandlers } from '@/mocks/api/handlers/transient-books'
export const handlers = [
...actuatorHandlers,
...announcementHandlers,
...booksHandlers,
...claimHandlers,
...filesystemHandlers,
...historyHandlers,
...librariesHandlers,
...referentialHandlers,
...releasesHandlers,
...seriesHandlers,
...settingsHandlers,
...transientBooksHandlers,
...usersHandlers,
]
export const response400BadRequest = () =>
HttpResponse.json({ error: 'Bad Request' }, { status: 400 })
export const response404NotFound = () => HttpResponse.json({ error: 'NotFound' }, { status: 404 })
export const response401Unauthorized = () =>

View file

@ -1,4 +1,8 @@
import { httpTyped } from '@/mocks/api/httpTyped'
import { mockPage } from '@/mocks/api/pageable'
import { PageRequest } from '@/types/PageRequest'
import { http, HttpResponse } from 'msw'
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
const book = {
id: '05RKH8CC8B4RW',
@ -57,11 +61,50 @@ const book = {
oneshot: false,
}
export function mockBooks(count: number) {
return [...Array(count).keys()].map((index) =>
Object.assign({}, book, {
id: `BOOK${index + 1}`,
name: `Book ${index + 1}`,
number: index + 1,
metadata: {
title: `Book ${index + 1}`,
number: `${index + 1}`,
numberSort: index + 1,
...(index % 2 === 0 && {
releaseDate: `19${String(index).slice(-2).padStart(2, '0')}-05-10`,
}),
},
}),
)
}
export const booksHandlers = [
httpTyped.post('/api/v1/books/list', ({ query, response }) => {
return response(200).json(
mockPage(
mockBooks(50),
new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')),
),
)
}),
httpTyped.get('/api/v1/books/{bookId}', ({ params, response }) => {
if (params.bookId === '404') return response(404).empty()
return response(200).json(
Object.assign({}, book, { metadata: { title: `Book ${params.bookId}` } }),
)
}),
httpTyped.post('/api/v1/books/import', ({ response }) => {
return response(202).empty()
}),
http.get('*/api/v1/books/*/thumbnail', async () => {
// Get an ArrayBuffer from reading the file from disk or fetching it.
const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer())
return HttpResponse.arrayBuffer(buffer, {
headers: {
'content-type': 'image/jpg',
},
})
}),
]

View file

@ -0,0 +1,61 @@
import { httpTyped } from '@/mocks/api/httpTyped'
import { response400BadRequest } from '@/mocks/api/handlers'
const emptyPath = { directories: [{ type: 'directory', name: '/', path: '/' }], files: [] }
const rootSlash = {
parent: '',
directories: [
{ type: 'directory', name: 'Applications', path: '/Applications' },
{ type: 'directory', name: 'bin', path: '/bin' },
{ type: 'directory', name: 'cores', path: '/cores' },
{ type: 'directory', name: 'comics', path: '/comics' },
{ type: 'directory', name: 'dev', path: '/dev' },
{ type: 'directory', name: 'etc', path: '/etc' },
{ type: 'directory', name: 'home', path: '/home' },
{ type: 'directory', name: 'Library', path: '/Library' },
{ type: 'directory', name: 'opt', path: '/opt' },
{ type: 'directory', name: 'private', path: '/private' },
{ type: 'directory', name: 'sbin', path: '/sbin' },
{ type: 'directory', name: 'System', path: '/System' },
{ type: 'directory', name: 'tmp', path: '/tmp' },
{ type: 'directory', name: 'Users', path: '/Users' },
{ type: 'directory', name: 'usr', path: '/usr' },
{ type: 'directory', name: 'var', path: '/var' },
{ type: 'directory', name: 'Volumes', path: '/Volumes' },
],
files: [],
}
const comics = {
parent: '/',
directories: [
{ type: 'directory', name: '_oneshots', path: '/comics/_oneshots' },
{ type: 'directory', name: 'Golden Age', path: '/comics/Golden Age' },
{ type: 'directory', name: 'Wika', path: '/comics/Wika' },
{ type: 'directory', name: 'Zorro', path: '/comics/Zorro' },
],
files: [],
}
const empty = { parent: '/', directories: [], files: [] }
export const filesystemHandlers = [
httpTyped.post('/api/v1/filesystem', async ({ request, response }) => {
const data = await request.json()
if (data?.path === '') {
return response(200).json(emptyPath)
} else if (data?.path === '/') {
return response(200).json(rootSlash)
} else if (data?.path === '/comics') {
return response(200).json(comics)
} else if (
[...rootSlash.directories, ...comics.directories].some((it) => it.path === data?.path)
) {
return response(200).json(empty)
}
return response.untyped(response400BadRequest())
}),
]

View file

@ -2,7 +2,7 @@ import { httpTyped } from '@/mocks/api/httpTyped'
import { mockPage } from '@/mocks/api/pageable'
import { PageRequest } from '@/types/PageRequest'
import { http, HttpResponse } from 'msw'
import logoUrl from '@/assets/logo.svg'
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
export const historyBookImported = {
id: 'H1',
@ -114,11 +114,11 @@ export const historyHandlers = [
),
http.get('*/api/v1/page-hashes/*/thumbnail', async () => {
// Get an ArrayBuffer from reading the file from disk or fetching it.
const buffer = await fetch(logoUrl).then((response) => response.arrayBuffer())
const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer())
return HttpResponse.arrayBuffer(buffer, {
headers: {
'content-type': 'image/svg+xml',
'content-type': 'image/jpg',
},
})
}),

View file

@ -1,8 +1,12 @@
import { httpTyped } from '@/mocks/api/httpTyped'
import { http, HttpResponse } from 'msw'
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
import { PageRequest } from '@/types/PageRequest'
import { mockPage } from '@/mocks/api/pageable'
const series = {
const series1 = {
id: '57',
libraryId: '56',
libraryId: '1',
name: 'Super Duck',
url: '/books/Super Duck',
created: new Date('2020-07-05T12:11:50Z'),
@ -56,11 +60,94 @@ const series = {
oneshot: false,
}
const series2 = {
id: '63',
libraryId: '2',
name: 'Space Adventures',
url: '/books/Space Adventures',
created: new Date('2020-07-05T12:11:50Z'),
lastModified: new Date('2020-07-05T12:11:50Z'),
fileLastModified: new Date('2020-03-05T11:57:31Z'),
booksCount: 4,
booksReadCount: 0,
booksUnreadCount: 3,
booksInProgressCount: 1,
metadata: {
status: 'ENDED',
statusLock: true,
title: 'Space Adventures',
titleLock: false,
titleSort: 'Space Adventures',
titleSortLock: false,
summary:
'Supersophisticated androids that can pass for human? Robots that turn on their creators to take control of their world? Strange alien armies secretly infiltrating the earth? Men rocketing through the galaxy as easily as taking an average Sunday drive in the country? Come on, that stuff is just a bunch of science fiction, right?\n\nYou bet it is! Published every two months, Charlton Comics presented a new collection of short stories about mankinds long-dreamed-of exploration of the rest of the solar system…and beyond!\n\nThis series is notable for its many stories by Steve Ditko (creator of The Amazing Spider-Man), and for the first appearance of Captain Atom.',
summaryLock: true,
readingDirection: 'LEFT_TO_RIGHT',
readingDirectionLock: true,
publisher: 'Charlton',
publisherLock: true,
ageRatingLock: false,
language: 'en',
languageLock: true,
genres: ['science fiction'],
genresLock: true,
tags: [],
tagsLock: false,
totalBookCount: 70,
totalBookCountLock: true,
sharingLabels: [],
sharingLabelsLock: false,
links: [],
linksLock: false,
alternateTitles: [],
alternateTitlesLock: false,
created: new Date('2020-07-05T12:11:50Z'),
lastModified: new Date('2023-07-22T11:14:45Z'),
},
booksMetadata: {
authors: [],
tags: [],
releaseDate: '2018-07-10',
summary: '',
summaryNumber: '',
created: new Date('2021-01-11T09:59:23Z'),
lastModified: new Date('2025-04-08T02:55:19Z'),
},
deleted: false,
oneshot: false,
}
const series = [series1, series2]
export const seriesHandlers = [
httpTyped.post('/api/v1/series/list', async ({ query, request, response }) => {
const body = await request.json()
const selectedSeries = body.fullTextSearch
? series.filter((it) => !!it.metadata.title.match(new RegExp(body.fullTextSearch!, 'i')))
: series
return response(200).json(
mockPage(
selectedSeries,
new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')),
),
)
}),
httpTyped.get('/api/v1/series/{seriesId}', ({ params, response }) => {
if (params.seriesId === '404') return response(404).empty()
return response(200).json(
Object.assign({}, series, { metadata: { title: `Series ${params.seriesId}` } }),
Object.assign({}, series1, { metadata: { title: `Series ${params.seriesId}` } }),
)
}),
http.get('*/api/v1/series/*/thumbnail', async () => {
// Get an ArrayBuffer from reading the file from disk or fetching it.
const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer())
return HttpResponse.arrayBuffer(buffer, {
headers: {
'content-type': 'image/jpg',
},
})
}),
]

File diff suppressed because it is too large Load diff

View file

@ -23,7 +23,7 @@ export function mockPage<T>(data: T[], pageRequest: PageRequest) {
paged: !unpaged,
},
last: false,
totalPages: data.length / size,
totalPages: Math.ceil(data.length / size),
totalElements: data.length,
first: false,
size: size,

View file

@ -0,0 +1,13 @@
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './books.stories';
<Meta of={Stories} />
# Import Books
Book import works in 2 steps:
1. Browse for or input the import directory.
2. Scanned books are shown in the table, allowing to fine-tune the import.
<Canvas of={Stories.Default} />

View file

@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ImportBooks from './books.vue'
import DialogConfirmEdit from '@/fragments/fragment/dialog/ConfirmEdit.vue'
import { delay, http } from 'msw'
const meta = {
component: ImportBooks,
render: (args: object) => ({
components: { ImportBooks, DialogConfirmEdit },
setup() {
return { args }
},
template: '<ImportBooks /><DialogConfirmEdit/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof ImportBooks>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [http.all('*', async () => await delay(2_000))],
},
},
}

View file

@ -1,9 +1,33 @@
<template>
<h1>Import Books</h1>
<ImportBooksDirectorySelection
:loading="isLoading"
@scan="(directory) => doScan(directory)"
/>
<FragmentImportBooksTransientBooksTable
v-if="transientBooks"
:loading="isLoading"
:books="transientBooks"
/>
</template>
<script lang="ts" setup>
//
import { transientBooksScan } from '@/colada/transient-books'
import { useQuery } from '@pinia/colada'
const scanDirectory = ref<string>('')
const {
data: transientBooks,
isLoading,
refetch,
} = useQuery(transientBooksScan, () => ({
path: scanDirectory.value,
}))
function doScan(directory: string) {
scanDirectory.value = directory
void refetch()
}
</script>
<route lang="yaml">

View file

@ -7,6 +7,7 @@ export const useAppStore = defineStore('app', {
drawer: !useDisplay().mobile.value.valueOf(),
theme: 'system',
rememberMe: false,
importBooksPath: '',
}),
persist: true,
})

View file

@ -0,0 +1,37 @@
import { defineMessages } from 'vue-intl'
export enum MediaStatus {
UNKNOWN = 'UNKNOWN',
ERROR = 'ERROR',
READY = 'READY',
UNSUPPORTED = 'UNSUPPORTED',
OUTDATED = 'OUTDATED',
}
export const mediaStatusMessages = defineMessages({
[MediaStatus.UNKNOWN]: {
description: 'Media status: unknown',
defaultMessage: 'Unknown',
id: 'vBi53Y',
},
[MediaStatus.ERROR]: {
description: 'Media status: error',
defaultMessage: 'Error',
id: 'G49aNP',
},
[MediaStatus.READY]: {
description: 'Media status: ready',
defaultMessage: 'Ready',
id: 'k0XIsB',
},
[MediaStatus.UNSUPPORTED]: {
description: 'Media status: unsupported',
defaultMessage: 'Unsupported',
id: '7iAvhC',
},
[MediaStatus.OUTDATED]: {
description: 'Media status: outdated',
defaultMessage: 'Outdated',
id: 'xba3Ob',
},
})

View file

@ -0,0 +1,179 @@
import { defineMessage, type MessageDescriptor } from 'vue-intl'
export const errorCodeMessages: Record<string, MessageDescriptor> = {
ERR_1000: defineMessage({
description: 'Error code: ERR_1000',
defaultMessage: 'File could not be accessed during analysis',
id: 'app.error.ERR_1000',
}),
ERR_1001: defineMessage({
description: 'Error code: ERR_1001',
defaultMessage: 'Media type is not supported',
id: 'app.error.ERR_1001',
}),
ERR_1002: defineMessage({
description: 'Error code: ERR_1002',
defaultMessage: 'Encrypted RAR archives are not supported',
id: 'app.error.ERR_1002',
}),
ERR_1003: defineMessage({
description: 'Error code: ERR_1003',
defaultMessage: 'Solid RAR archives are not supported',
id: 'app.error.ERR_1003',
}),
ERR_1004: defineMessage({
description: 'Error code: ERR_1004',
defaultMessage: 'Multi-Volume RAR archives are not supported',
id: 'app.error.ERR_1004',
}),
ERR_1005: defineMessage({
description: 'Error code: ERR_1005',
defaultMessage: 'Unknown error while analyzing book',
id: 'app.error.ERR_1005',
}),
ERR_1006: defineMessage({
description: 'Error code: ERR_1006',
defaultMessage: 'Book does not contain any page',
id: 'app.error.ERR_1006',
}),
ERR_1007: defineMessage({
description: 'Error code: ERR_1007',
defaultMessage: 'Some entries could not be analyzed',
id: 'app.error.ERR_1007',
}),
ERR_1008: defineMessage({
description: 'Error code: ERR_1008',
defaultMessage: "Unknown error while getting book's entries",
id: 'app.error.ERR_1008',
}),
ERR_1009: defineMessage({
description: 'Error code: ERR_1009',
defaultMessage: 'A read list with that name already exists',
id: 'app.error.ERR_1009',
}),
ERR_1015: defineMessage({
description: 'Error code: ERR_1015',
defaultMessage: 'Error while deserializing ComicRack CBL',
id: 'app.error.ERR_1015',
}),
ERR_1016: defineMessage({
description: 'Error code: ERR_1016',
defaultMessage: 'Directory not accessible or not a directory',
id: 'app.error.ERR_1016',
}),
ERR_1017: defineMessage({
description: 'Error code: ERR_1017',
defaultMessage: 'Cannot scan folder that is part of an existing library',
id: 'app.error.ERR_1017',
}),
ERR_1018: defineMessage({
description: 'Error code: ERR_1018',
defaultMessage: 'File not found',
id: 'app.error.ERR_1018',
}),
ERR_1019: defineMessage({
description: 'Error code: ERR_1019',
defaultMessage: 'Cannot import file that is part of an existing library',
id: 'app.error.ERR_1019',
}),
ERR_1020: defineMessage({
description: 'Error code: ERR_1020',
defaultMessage: 'Book to upgrade does not belong to provided series',
id: 'app.error.ERR_1020',
}),
ERR_1021: defineMessage({
description: 'Error code: ERR_1021',
defaultMessage: 'Destination file already exists',
id: 'app.error.ERR_1021',
}),
ERR_1022: defineMessage({
description: 'Error code: ERR_1022',
defaultMessage: 'Newly imported book could not be scanned',
id: 'app.error.ERR_1022',
}),
ERR_1023: defineMessage({
description: 'Error code: ERR_1023',
defaultMessage: 'Book already present in ReadingList',
id: 'app.error.ERR_1023',
}),
ERR_1024: defineMessage({
description: 'Error code: ERR_1024',
defaultMessage: 'OAuth2 login error: no email attribute',
id: 'app.error.ERR_1024',
}),
ERR_1025: defineMessage({
description: 'Error code: ERR_1025',
defaultMessage: 'OAuth2 login error: no local user exist with that email',
id: 'app.error.ERR_1025',
}),
ERR_1026: defineMessage({
description: 'Error code: ERR_1026',
defaultMessage: 'OpenID Connect login error: email not verified',
id: 'app.error.ERR_1026',
}),
ERR_1027: defineMessage({
description: 'Error code: ERR_1027',
defaultMessage: 'OpenID Connect login error: no email_verified attribute',
id: 'app.error.ERR_1027',
}),
ERR_1028: defineMessage({
description: 'Error code: ERR_1028',
defaultMessage: 'OpenID Connect login error: no email attribute',
id: 'app.error.ERR_1028',
}),
ERR_1029: defineMessage({
description: 'Error code: ERR_1029',
defaultMessage: 'ComicRack CBL does not contain any Book element',
id: 'app.error.ERR_1029',
}),
ERR_1030: defineMessage({
description: 'Error code: ERR_1030',
defaultMessage: 'ComicRack CBL has no Name element',
id: 'app.error.ERR_1030',
}),
ERR_1031: defineMessage({
description: 'Error code: ERR_1031',
defaultMessage: 'ComicRack CBL Book is missing series or number',
id: 'app.error.ERR_1031',
}),
ERR_1032: defineMessage({
description: 'Error code: ERR_1032',
defaultMessage: 'EPUB file has wrong media type',
id: 'app.error.ERR_1032',
}),
ERR_1033: defineMessage({
description: 'Error code: ERR_1033',
defaultMessage: 'Some entries are missing',
id: 'app.error.ERR_1033',
}),
ERR_1034: defineMessage({
description: 'Error code: ERR_1034',
defaultMessage: 'An API key with that comment already exists',
id: 'app.error.ERR_1034',
}),
ERR_1035: defineMessage({
description: 'Error code: ERR_1035',
defaultMessage: 'Error while getting EPUB TOC',
id: 'app.error.ERR_1035',
}),
ERR_1036: defineMessage({
description: 'Error code: ERR_1036',
defaultMessage: 'Error while getting EPUB Landmarks',
id: 'app.error.ERR_1036',
}),
ERR_1037: defineMessage({
description: 'Error code: ERR_1037',
defaultMessage: 'Error while getting EPUB page list',
id: 'app.error.ERR_1037',
}),
ERR_1038: defineMessage({
description: 'Error code: ERR_1038',
defaultMessage: 'Error while getting EPUB divina pages',
id: 'app.error.ERR_1038',
}),
ERR_1039: defineMessage({
description: 'Error code: ERR_1039',
defaultMessage: 'Error while getting EPUB positions',
id: 'app.error.ERR_1039',
}),
}