finalize readlist import

This commit is contained in:
Gauthier Roebroeck 2025-10-14 13:59:23 +08:00
parent 550fd63836
commit 3d8c47b305
9 changed files with 470 additions and 218 deletions

View file

@ -1,11 +1,40 @@
import { defineMutation, useMutation } from '@pinia/colada'
import { defineMutation, defineQueryOptions, useMutation } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import type { components } from '@/generated/openapi/komga'
import type { PageRequest } from '@/types/PageRequest'
export const QUERY_KEYS_READLIST = {
root: ['readlists'] as const,
bySearch: (request: object) => [...QUERY_KEYS_READLIST.root, JSON.stringify(request)] as const,
}
export const useListReadLists = defineQueryOptions(
({
search,
libraryId,
pageable,
}: {
search?: string
libraryId?: string
pageable?: PageRequest
}) => ({
key: QUERY_KEYS_READLIST.bySearch({ search: search, libraryId: libraryId, pageable: pageable }),
query: () =>
komgaClient
.GET('/api/v1/readlists', {
params: {
query: {
search: search,
libraryId: libraryId,
...pageable,
},
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
}),
)
export const useCreateReadList = defineMutation(() => {
return useMutation({
mutation: (readList: components['schemas']['ReadListCreationDto']) =>

View file

@ -50,6 +50,7 @@ const singleMatch = {
},
],
}
export const Created: Story = {
args: {
match: singleMatch,
@ -68,6 +69,12 @@ export const Created: Story = {
}),
}
export const DuplicateName: Story = {
args: {
match: { ...singleMatch, readListMatch: { name: 'Elfes', errorCode: '' } },
},
}
export const Empty: Story = {
args: {
match: { ...matchCbl, requests: [] },

View file

@ -1,220 +1,223 @@
<template>
<v-data-table
v-model="selectedIndices"
:loading="creating || loading"
:items="readListEntries"
item-value="index"
:headers="headers"
:search="filterRef"
:custom-filter="filterFn"
:hide-default-footer="hideFooter"
fixed-header
fixed-footer
show-select
item-selectable="selectable"
select-strategy="page"
mobile-breakpoint="md"
>
<template #no-data>
<v-empty-state
icon="i-mdi:filter"
:title="
$formatMessage({
description:
'Import reading list table: shown when table has no data because of the selected filter - title',
defaultMessage: 'No data',
id: 'AJa6Tq',
})
"
:text="
$formatMessage({
description:
'Import reading list table: shown when table has no data because of the selected filter - subtitle',
defaultMessage: 'Try adjusting the filters',
id: 'NpjqFA',
})
"
/>
</template>
<template #top>
<v-toolbar flat>
<v-spacer />
<v-chip-group
v-model="filterSelect"
multiple
class="ms-2"
:disabled="finishedState"
>
<v-chip
v-for="f in filterOptions"
:key="f.value"
:value="f.value"
:text="f.title"
filter
rounded
color="primary"
/>
</v-chip-group>
</v-toolbar>
</template>
<template #[`item.request.request.series`]="{ value }">
<div
v-for="s in value"
:key="s"
>
{{ s }}
</div>
</template>
<template #[`item.series`]="{ item, internalItem, isSelected, value }">
<div
:class="finishedState ? undefined : 'cursor-pointer'"
@mouseenter="
finishedState
? undefined
: (dialogSeriesPickerActivator = $event.currentTarget as Element)
"
@click="finishedState ? undefined : (currentActionedItem = item)"
>
<template v-if="value">
<div>{{ value?.title }}</div>
<div v-if="value?.releaseDate">
{{ $formatDate(value?.releaseDate, { year: 'numeric', timeZone: 'UTC' }) }}
</div>
</template>
<div
v-else
style="height: 2em"
:class="isSelected(internalItem) ? 'missing' : ''"
/>
</div>
</template>
<template #[`item.book`]="{ item, internalItem, isSelected, value }">
<div
:class="finishedState || !item?.series ? undefined : 'cursor-pointer'"
@mouseenter="
finishedState || !item?.series
? undefined
: (dialogBookPickerActivator = $event.currentTarget as Element)
"
@click="finishedState || !item?.series ? undefined : (currentActionedItem = item)"
>
<span v-if="value">{{ value.number }} - {{ value.title }}</span>
<div
v-else
style="height: 2em"
:class="isSelected(internalItem) && item?.series ? 'missing' : ''"
/>
</div>
</template>
<template #[`header.statusMessage`]>
<v-icon icon="i-mdi:alert-circle-outline" />
</template>
<template #[`item.statusMessage`]="{ item, value, internalItem, isSelected }">
<template v-if="isSelected(internalItem)">
<v-icon
v-if="duplicateBookIds?.includes(item.book?.bookId)"
v-tooltip="
<div>
<v-data-table
v-model="selectedIndices"
:loading="creating || loading"
:items="readListEntries"
item-value="index"
:headers="headers"
:search="filterRef"
:custom-filter="filterFn"
:hide-default-footer="hideFooter"
fixed-header
fixed-footer
show-select
item-selectable="selectable"
select-strategy="page"
mobile-breakpoint="md"
>
<template #no-data>
<v-empty-state
icon="i-mdi:filter"
:title="
$formatMessage({
description: 'Import reading list table: tooltip for status - duplicate book',
defaultMessage: 'Duplicate book',
id: '1MAL38',
description:
'Import reading list table: shown when table has no data because of the selected filter - title',
defaultMessage: 'No data',
id: 'AJa6Tq',
})
"
icon="i-mdi:alert-circle"
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="end"
>
<v-col
cols="12"
sm=""
>
<v-row>
<v-col>
<v-text-field
v-model="readListName"
:rules="['required']"
:disabled="finishedState"
clearable
:label="
$formatMessage({
description: 'Import reading list: bottom bar: reading list name',
defaultMessage: 'Name',
id: 'rrF/Z2',
})
"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-textarea
v-model="readListSummary"
rows="2"
hide-details
:disabled="finishedState"
clearable
:label="
$formatMessage({
description: 'Import reading list: bottom bar: reading list summary',
defaultMessage: 'Summary',
id: 'uW+6XG',
})
"
/>
</v-col>
</v-row>
</v-col>
<v-col cols="auto">
<v-btn
:color="finishedState ? 'success' : 'primary'"
:text="
$formatMessage({
description: 'Import reading list: bottom bar: create button',
defaultMessage: 'Create',
id: 'dipMGb',
description:
'Import reading list table: shown when table has no data because of the selected filter - subtitle',
defaultMessage: 'Try adjusting the filters',
id: 'NpjqFA',
})
"
:disabled="!isFormValid || creating || loading || finishedState"
:prepend-icon="finishedState ? 'i-mdi:check' : undefined"
@click="doCreateReadList"
/>
</v-col>
</v-row>
</v-container>
</template>
<DialogSeriesPicker
:activator="dialogSeriesPickerActivator"
:fullscreen="display.xs.value"
@selected-series="(series) => seriesPicked(series)"
/>
<template #top>
<v-toolbar flat>
<v-spacer />
<v-chip-group
v-model="filterSelect"
multiple
class="ms-2"
:disabled="finishedState"
>
<v-chip
v-for="f in filterOptions"
:key="f.value"
:value="f.value"
:text="f.title"
filter
rounded
color="primary"
/>
</v-chip-group>
</v-toolbar>
</template>
<DialogBookPicker
:activator="dialogBookPickerActivator"
:fullscreen="display.xs.value"
:books="dialogBookPickerBooks"
@selected-book="(book) => bookPicked(book)"
/>
<template #[`item.request.request.series`]="{ value }">
<div
v-for="s in value"
:key="s"
>
{{ s }}
</div>
</template>
<template #[`item.series`]="{ item, internalItem, isSelected, value }">
<div
:class="finishedState ? undefined : 'cursor-pointer'"
@mouseenter="
finishedState
? undefined
: (dialogSeriesPickerActivator = $event.currentTarget as Element)
"
@click="finishedState ? undefined : (currentActionedItem = item)"
>
<template v-if="value">
<div>{{ value?.title }}</div>
<div v-if="value?.releaseDate">
{{ $formatDate(value?.releaseDate, { year: 'numeric', timeZone: 'UTC' }) }}
</div>
</template>
<div
v-else
style="height: 2em"
:class="isSelected(internalItem) ? 'missing' : ''"
/>
</div>
</template>
<template #[`item.book`]="{ item, internalItem, isSelected, value }">
<div
:class="finishedState || !item?.series ? undefined : 'cursor-pointer'"
@mouseenter="
finishedState || !item?.series
? undefined
: (dialogBookPickerActivator = $event.currentTarget as Element)
"
@click="finishedState || !item?.series ? undefined : (currentActionedItem = item)"
>
<span v-if="value">{{ value.number }} - {{ value.title }}</span>
<div
v-else
style="height: 2em"
:class="isSelected(internalItem) && item?.series ? 'missing' : ''"
/>
</div>
</template>
<template #[`header.statusMessage`]>
<v-icon icon="i-mdi:alert-circle-outline" />
</template>
<template #[`item.statusMessage`]="{ item, value, internalItem, isSelected }">
<template v-if="isSelected(internalItem)">
<v-icon
v-if="duplicateBookIds?.includes(item.book?.bookId)"
v-tooltip="
$formatMessage({
description: 'Import reading list table: tooltip for status - duplicate book',
defaultMessage: 'Duplicate book',
id: '1MAL38',
})
"
icon="i-mdi:alert-circle"
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="end"
>
<v-col
cols="12"
sm=""
>
<v-row>
<v-col>
<v-text-field
v-model="readListName"
:rules="['required']"
:disabled="finishedState"
clearable
:label="
$formatMessage({
description: 'Import reading list: bottom bar: reading list name',
defaultMessage: 'Name',
id: 'rrF/Z2',
})
"
:error-messages="readListNameAlreadyExists ? duplicateNameMessage : undefined"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col>
<v-textarea
v-model="readListSummary"
rows="2"
hide-details
:disabled="finishedState"
clearable
:label="
$formatMessage({
description: 'Import reading list: bottom bar: reading list summary',
defaultMessage: 'Summary',
id: 'uW+6XG',
})
"
/>
</v-col>
</v-row>
</v-col>
<v-col cols="auto">
<v-btn
:color="finishedState ? 'success' : 'primary'"
:text="
$formatMessage({
description: 'Import reading list: bottom bar: create button',
defaultMessage: 'Create',
id: 'dipMGb',
})
"
:disabled="!isFormValid || creating || loading || finishedState"
:prepend-icon="finishedState ? 'i-mdi:check' : undefined"
@click="doCreateReadList"
/>
</v-col>
</v-row>
</v-container>
<DialogSeriesPicker
:activator="dialogSeriesPickerActivator"
:fullscreen="display.xs.value"
@selected-series="(series) => seriesPicked(series)"
/>
<DialogBookPicker
:activator="dialogBookPickerActivator"
:fullscreen="display.xs.value"
:books="dialogBookPickerBooks"
@selected-book="(book) => bookPicked(book)"
/>
</div>
</template>
<script setup lang="ts">
@ -231,10 +234,11 @@ import {
import { useDisplay } from 'vuetify'
import { useQuery } from '@pinia/colada'
import { bookListQuery } from '@/colada/books'
import { useCreateReadList } from '@/colada/readlists'
import { useCreateReadList, useListReadLists } from '@/colada/readlists'
import { useMessagesStore } from '@/stores/messages'
import type { ErrorCause } from '@/api/komga-client'
import { commonMessages } from '@/utils/i18n/common-messages'
import { PageRequest } from '@/types/PageRequest'
class ReadListEntry {
index: number
@ -257,7 +261,7 @@ class ReadListEntry {
}
public get importable(): boolean {
return !!this.series && !!this.book
return !!this.book
}
public get statusMessage(): string {
@ -311,7 +315,7 @@ const selectedBooks = useArrayFilter(readListEntries, (it) =>
)
const duplicateBookIds = useArrayFilter(
useArrayMap(readListEntries, (it) => it.book?.bookId),
useArrayMap(selectedBooks, (it) => it.book?.bookId),
(it, index, array) => !!it && array.indexOf(it) !== index,
)
@ -328,6 +332,18 @@ watchImmediate(
},
)
const { data: allReadLists } = useQuery(useListReadLists({ pageable: PageRequest.Unpaged() }))
const readListNameAlreadyExists = computed(() =>
allReadLists.value?.content?.some(
(it) => it.name.localeCompare(readListName.value, undefined, { sensitivity: 'accent' }) == 0,
),
)
const duplicateNameMessage = intl.formatMessage({
description: 'Import reading list: error message if read list name already exists',
defaultMessage: 'A read list with that name already exists',
id: 'LjqS9+',
})
const finishedState = computed<boolean>(() => !!readListCreated.value)
// Table setup
@ -415,13 +431,20 @@ function filterFn(
// Series Picker Dialog
const dialogSeriesPickerActivator = ref<Element | undefined>(undefined)
function seriesPicked(series: components['schemas']['SeriesDto']) {
async function seriesPicked(series: components['schemas']['SeriesDto']) {
if (currentActionedItem.value) {
currentActionedItem.value.series = {
seriesId: series.id,
title: series.metadata.title,
releaseDate: series.booksMetadata?.releaseDate,
}
const requestedNumber = currentActionedItem.value.request.request.number
const seriesBooks = await getSeriesBooks(series.id)
if (seriesBooks) {
const matchedBook = seriesBooks.content?.find((b) => b.metadata.number === requestedNumber)
if (matchedBook) bookPicked(matchedBook)
}
}
}
@ -455,14 +478,16 @@ const getSeriesBooks = useMemoize(async (seriesId: string) =>
.then(({ data }) => data),
)
const isFormValid = computed<boolean>(() => !!readListName.value)
const isFormValid = computed<boolean>(
() => !!readListName.value && !readListNameAlreadyExists.value,
)
const createPayload = computed(
() =>
({
name: readListName.value,
summary: readListSummary.value,
ordered: true,
bookIds: [],
bookIds: selectedBooks.value.map((it) => it.book?.bookId),
}) as components['schemas']['ReadListCreationDto'],
)
@ -485,7 +510,7 @@ function doCreateReadList() {
postReadList(createPayload.value)
.then(({ data }) => {
readListCreated.value = data
//TODO: add link
//TODO: add link to created readlist
messagesStore.messages.push({
text: 'Readlist created',
})

View file

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

View file

@ -1,7 +1,7 @@
import { actuatorHandlers } from '@/mocks/api/handlers/actuator'
import { announcementHandlers } from '@/mocks/api/handlers/announcements'
import { releasesHandlers } from '@/mocks/api/handlers/releases'
import { HttpResponse } from 'msw'
import { HttpResponse, type JsonBodyType } from 'msw'
import { librariesHandlers } from '@/mocks/api/handlers/libraries'
import { referentialHandlers } from '@/mocks/api/handlers/referential'
import { usersHandlers } from '@/mocks/api/handlers/users'
@ -34,6 +34,8 @@ export const handlers = [
export const response400BadRequest = () =>
HttpResponse.json({ error: 'Bad Request' }, { status: 400 })
export const response400 = (body: JsonBodyType) => HttpResponse.json(body, { status: 400 })
export const response404NotFound = () => HttpResponse.json({ error: 'NotFound' }, { status: 404 })
export const response401Unauthorized = () =>

View file

@ -1,4 +1,6 @@
import { httpTyped } from '@/mocks/api/httpTyped'
import { mockPage } from '@/mocks/api/pageable'
import { PageRequest } from '@/types/PageRequest'
export const matchCbl = {
readListMatch: { name: "Jupiter's Legacy", errorCode: '' },
@ -134,7 +136,58 @@ export const matchCbl = {
errorCode: '',
}
export const emptyCbl = {
error: 'Bad Request',
message: 'ERR_1029',
}
export const garbledCbl = {
error: 'Bad Request',
message: 'ERR_1015',
}
const rl1 = {
id: '02AQZYKBS00J8',
name: 'Readlist example',
summary: 'An example read list to show off how it works in Komga.',
ordered: true,
bookIds: ['BOOK1', 'BOOK2'],
createdDate: new Date('2020-08-20T05:45:38Z'),
lastModifiedDate: new Date('2021-08-09T08:42:38Z'),
filtered: false,
}
const rl2 = {
id: '02AQZYKBS00J8',
name: 'Elfes',
summary: 'Elfes readlist',
ordered: false,
bookIds: ['BOOK3', 'BOOK4'],
createdDate: new Date('2020-08-20T05:45:38Z'),
lastModifiedDate: new Date('2021-08-09T08:42:38Z'),
filtered: false,
}
const readlists = [rl1, rl2]
export const readListsHandlers = [
httpTyped.get('/api/v1/readlists', ({ query, response }) => {
const selectedReadLists = query.get('search')
? readlists.filter((it) => !!it.name.match(new RegExp(query.get('search')!, 'i')))
: readlists
return response(200).json(
mockPage(
selectedReadLists,
new PageRequest(
Number(query.get('page')),
Number(query.get('size')),
undefined,
Boolean(query.get('unpaged')),
),
),
)
}),
httpTyped.post('/api/v1/readlists', async ({ request, response }) => {
const body = await request.json()
return response(200).json({

View file

@ -0,0 +1,54 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ImportReadList from './readlist.vue'
import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
import { delay, http } from 'msw'
import SnackQueue from '@/components/SnackQueue.vue'
import { emptyCbl, garbledCbl } from '@/mocks/api/handlers/readlists'
import { response400 } from '@/mocks/api/handlers'
const meta = {
component: ImportReadList,
render: (args: object) => ({
components: { ImportReadList, DialogConfirmEditInstance, SnackQueue },
setup() {
return { args }
},
template: '<ImportReadList /><DialogConfirmEditInstance/><SnackQueue/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof ImportReadList>
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))],
},
},
}
export const ErrorNoBooks: Story = {
parameters: {
msw: {
handlers: [http.post('*/api/v1/readlists/match/comicrack', () => response400(emptyCbl))],
},
},
}
export const ErrorInvalidFile: Story = {
parameters: {
msw: {
handlers: [http.post('*/api/v1/readlists/match/comicrack', () => response400(garbledCbl))],
},
},
}

View file

@ -1,9 +1,88 @@
<template>
<h1>Import Read List</h1>
<v-container
fluid
class="pa-0 pa-sm-4 h-100 h-sm-auto"
>
<v-file-upload
v-model="fileToUpload"
:disabled="isLoading"
density="compact"
filter-by-type=".cbl"
@rejected="handleReject()"
>
<template #item />
</v-file-upload>
<v-progress-linear
v-if="isLoading"
indeterminate
class="mt-2"
/>
<ImportReadlistTable
v-if="match"
:match="match"
:loading="isLoading"
class="mt-4"
/>
</v-container>
</template>
<script lang="ts" setup>
//
import { useMessagesStore } from '@/stores/messages'
import { useMutation } from '@pinia/colada'
import { type ErrorCause, komgaClient } from '@/api/komga-client'
import { useErrorCodeFormatter } from '@/composables/errorCodeFormatter'
import { commonMessages } from '@/utils/i18n/common-messages'
import { useIntl } from 'vue-intl'
const intl = useIntl()
const messagesStore = useMessagesStore()
const { convertErrorCodes } = useErrorCodeFormatter()
const fileToUpload = ref<File>()
function handleReject() {
messagesStore.messages.push(unsupportedFileTypeMessage)
}
const {
data: match,
mutate: matchCbl,
isLoading,
} = useMutation({
mutation: (file: File) =>
komgaClient
.POST('/api/v1/readlists/match/comicrack', {
body: {
file: file,
},
bodySerializer() {
const fd = new FormData()
fd.append('file', file)
return fd
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data)
.catch((error) => {
messagesStore.messages.push({
text:
convertErrorCodes((error?.cause as ErrorCause)?.message) ||
intl.formatMessage(commonMessages.networkError),
})
}),
})
watch(fileToUpload, (file) => {
if (file) matchCbl(file)
})
const unsupportedFileTypeMessage = intl.formatMessage({
description: 'Import readlist view: error message when trying to upload an unsupported file type',
defaultMessage: 'File type not supported',
id: 'CxuwFR',
})
</script>
<route lang="yaml">

View file

@ -13,6 +13,7 @@ import { createVuetify } from 'vuetify'
import { md3 } from 'vuetify/blueprints'
// Labs
import { VFileUpload } from 'vuetify/labs/VFileUpload'
import { VIconBtn } from 'vuetify/labs/components'
import { createRulesPlugin } from 'vuetify/labs/rules'
@ -69,6 +70,7 @@ export const vuetify = createVuetify({
},
blueprint: md3,
components: {
VFileUpload,
VIconBtn,
},
})