feat(webui): interactive readlist import

This commit is contained in:
Gauthier Roebroeck 2023-03-02 17:46:28 +08:00
parent 400f7baa53
commit 648ebb4b0d
11 changed files with 565 additions and 128 deletions

View file

@ -0,0 +1,159 @@
<template>
<tr v-if="match">
<slot/>
<td>{{ match.request.series }}</td>
<td>{{ match.request.number }}</td>
<!-- Series picker -->
<td @click="modalSeriesPicker = true" style="cursor: pointer">
<template v-if="selectedSeries">{{ selectedSeries.metadata.title }}</template>
<template v-else>
<div style="height: 2em" class="missing"></div>
</template>
<series-picker-dialog v-model="modalSeriesPicker" :series.sync="selectedSeries"></series-picker-dialog>
</td>
<!-- Book picker -->
<td @click="selectedSeries ? modalBookPicker = true : undefined" :style="selectedSeries ? 'cursor: pointer': ''">
<template v-if="selectedBook">
{{ selectedBook.metadata.number }} - {{ selectedBook.metadata.title }}
</template>
<template v-else-if="selectedSeries">
<div style="height: 2em" class="missing"></div>
</template>
<book-picker-dialog
v-model="modalBookPicker"
:books="seriesBooks"
:book.sync="selectedBook"
></book-picker-dialog>
</td>
<!-- Error -->
<td>
<template v-if="error">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon color="error" v-on="on">mdi-alert-circle</v-icon>
</template>
{{ error }}
</v-tooltip>
</template>
<template v-else-if="duplicate">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon color="warning" v-on="on">mdi-alert-circle</v-icon>
</template>
{{ $t('readlist_import.row.duplicate_book') }}
</v-tooltip>
</template>
</td>
</tr>
</template>
<script lang="ts">
import Vue, {PropType} from 'vue'
import {SeriesDto} from '@/types/komga-series'
import {BookDto} from '@/types/komga-books'
import SeriesPickerDialog from '@/components/dialogs/SeriesPickerDialog.vue'
import TransientBookDetailsDialog from '@/components/dialogs/TransientBookDetailsDialog.vue'
import TransientBookViewerDialog from '@/components/dialogs/TransientBookViewerDialog.vue'
import FileNameChooserDialog from '@/components/dialogs/FileNameChooserDialog.vue'
import {ReadListRequestBookMatchesDto} from '@/types/komga-readlists'
import BookPickerDialog from '@/components/dialogs/BookPickerDialog.vue'
export default Vue.extend({
name: 'ReadListMatchRow',
components: {
BookPickerDialog,
SeriesPickerDialog,
TransientBookDetailsDialog,
TransientBookViewerDialog,
FileNameChooserDialog,
},
props: {
match: {
type: Object as PropType<ReadListRequestBookMatchesDto>,
required: true,
},
duplicate: {
type: Boolean,
default: false,
},
bookId: {
type: String,
required: false,
},
},
watch: {
match: {
handler(val) {
this.processMatch(val)
},
immediate: true,
},
selectedSeries: {
handler(val, old) {
if (!old) {
this.getSeriesBooks(val)
} else if (val?.id !== old?.id) {
this.selectedBook = undefined
this.getSeriesBooks(val)
}
},
immediate: true,
},
selectedBook: {
handler(val) {
this.$emit('update:bookId', val?.id)
},
immediate: true,
},
},
data: () => ({
innerSelect: false,
selectedSeries: undefined as SeriesDto | undefined,
selectedBook: undefined as BookDto | undefined,
seriesBooks: [] as BookDto[],
modalSeriesPicker: false,
modalBookPicker: false,
}),
computed: {
existingFileNames(): string[] {
return this.seriesBooks.map(x => x.name)
},
error(): string {
if (!this.selectedSeries) return this.$t('book_import.row.error_choose_series').toString()
if (!this.selectedBook) return this.$t('readlist_import.row.error_choose_book').toString()
return ''
},
},
methods: {
async processMatch(match: ReadListRequestBookMatchesDto) {
let seriesId: string | undefined
if (match.matches.length === 1) {
seriesId = match.matches[0].seriesId
} else if (match.matches.length > 1) {
seriesId = match.matches.find((m) => m.bookIds.length > 1)?.seriesId
}
if (seriesId) {
this.selectedSeries = await this.$komgaSeries.getOneSeries(seriesId)
const bookId = match.matches.find((m) => m.seriesId === seriesId)?.bookIds.find(Boolean)
if (bookId) {
this.selectedBook = await this.$komgaBooks.getBook(bookId)
}
}
},
async getSeriesBooks(series: SeriesDto) {
if (series) {
this.seriesBooks = (await this.$komgaSeries.getBooks(series.id, {unpaged: true})).content
}
},
},
})
</script>
<style scoped>
.missing {
border: 2px dashed red;
}
</style>

View file

@ -105,6 +105,8 @@ export default Vue.extend({
onNotification(event: NotificationEvent) {
this.queue.push({
text: event.message,
text2: event.text2,
goTo: event.goTo,
})
},
async onBookImported(event: BookImportSseDto) {

View file

@ -0,0 +1,136 @@
<template>
<div>
<v-dialog v-model="modal"
max-width="600"
scrollable
>
<v-card>
<v-card-title>{{ $t('dialog.book_picker.title') }}</v-card-title>
<v-btn icon absolute top right @click="dialogClose">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-divider/>
<v-card-text style="height: 50%">
<v-container fluid>
<v-row align="center">
<v-col>
<v-text-field
v-model="filter"
autofocus
:label="$t('dialog.book_picker.filter')"
/>
</v-col>
</v-row>
<v-divider/>
<v-list v-if="filteredBooks.length > 0">
<v-list-item-group color="primary" v-model="selectedItem">
<v-list-item
v-for="(book, index) in filteredBooks"
:key="index"
@click="choose(book)"
>
<v-img :src="bookThumbnailUrl(book.id)"
height="50"
max-width="35"
class="my-1 mx-3"
contain
/>
<v-list-item-content>
<v-list-item-title>{{ book.metadata.title }}</v-list-item-title>
<v-list-item-subtitle>{{ book.metadata.number }}</v-list-item-subtitle>
<v-list-item-subtitle v-if="book.metadata.releaseDate">{{
new Intl.DateTimeFormat($i18n.locale, {
dateStyle: 'long',
timeZone: 'UTC'
}).format(new Date(book.metadata.releaseDate))
}}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</v-container>
</v-card-text>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import Vue, {PropType} from 'vue'
import {BookDto} from '@/types/komga-books'
import {bookThumbnailUrl} from '@/functions/urls'
export default Vue.extend({
name: 'BookPickerDialog',
data: () => {
return {
modal: false,
selectedItem: -1,
filter: '',
bookThumbnailUrl,
}
},
props: {
value: Boolean,
books: {
type: Array as PropType<BookDto[]>,
required: true,
},
book: {
type: Object as PropType<BookDto>,
required: false,
},
},
watch: {
value(val) {
this.modal = val
if (val) {
this.clear()
}
},
modal(val) {
!val && this.dialogClose()
},
books(val) {
this.selectBook(val, this.book)
},
book(val) {
this.selectBook(this.books, val)
},
},
computed: {
filteredBooks(): BookDto[] {
if (this.filter) {
return this.books.filter((b) => b.metadata.number.toLowerCase().includes(this.filter.toLowerCase()) || b.metadata.title.toLowerCase().includes(this.filter.toLowerCase()) || b.metadata.releaseDate?.includes(this.filter))
}
return this.books
},
},
methods: {
clear() {
this.filter = ''
},
choose(book: BookDto) {
this.$emit('update:book', book)
this.dialogClose()
},
dialogClose() {
this.$emit('input', false)
},
selectBook(books: BookDto[], book?: BookDto) {
this.selectedItem = this.$_.findIndex(books, (b) => b.id === book?.id)
},
},
})
</script>
<style scoped>
</style>

View file

@ -189,6 +189,7 @@
"common": {
"age": "Age",
"all_libraries": "All Libraries",
"book": "Book",
"books": "Books",
"books_n": "No book | 1 book | {count} books",
"books_total": "{count} / {total} books",
@ -227,6 +228,7 @@
"publisher": "Publisher",
"read": "Read",
"read_on": "Read on {date}",
"readlist": "Read List",
"readlists": "Read Lists",
"required": "Required",
"reset_filters": "Reset filters",
@ -254,10 +256,15 @@
"book_number": "Book number: {name}",
"book_series": "Series: {name}",
"button_import": "Import",
"button_match": "Match",
"comicrack_preambule_html": "You can import existing ComicRack Reading Lists in <code>.cbl</code> format.<br/>Komga will try to match the provided series and book number with series and books in your libraries.",
"field_file_label": "ComicRack Reading List (.cbl)",
"field_files_label": "ComicRack Reading Lists (.cbl)",
"import_read_lists": "Import Read Lists",
"imported_as": "Imported as {name}",
"readlist_created": "Read List created: {name}",
"requested_number": "Requested Number",
"requested_series": "Requested Series",
"results_preambule": "Result of the import is shown below. You can also check the unmatched books for each provided file.",
"size_limit": "Size should be less than {size} MB",
"tab_title": "Data Import"
@ -296,6 +303,10 @@
"button_confirm": "Analyze",
"title": "Analyze library"
},
"book_picker": {
"filter": "Filter by book number, title, or release date",
"title": "Select Book"
},
"delete_book": {
"button_confirm": "Delete",
"confirm_delete": "Yes, delete book \"{name}\" and its files",
@ -434,8 +445,8 @@
"button_cancel": "Cancel",
"button_confirm": "Save changes",
"dialog_title": "Edit read list",
"field_name": "Name",
"field_manual_ordering": "Manual ordering",
"field_name": "Name",
"field_summary": "Summary",
"label_ordering": "By default, books in a read list are ordered manually. You can disable manual ordering to sort books by release date.",
"tab_general": "General",
@ -525,6 +536,7 @@
},
"series_picker": {
"label_search_series": "Search Series",
"no_results": "No Series found",
"title": "Select Series"
},
"server_stop": {
@ -759,6 +771,12 @@
"less": "Read less",
"more": "Read more"
},
"readlist_import": {
"row": {
"duplicate_book": "Duplicate book",
"error_choose_book": "Choose a book"
}
},
"readlists_expansion_panel": {
"manage_readlist": "Manage read list",
"title": "{name} read list"

View file

@ -226,10 +226,10 @@ const router = new Router({
component: () => import(/* webpackChunkName: "import-books" */ './views/ImportBooks.vue'),
},
{
path: '/import/readlists',
name: 'import-readlists',
path: '/import/readlist',
name: 'import-readlist',
beforeEnter: adminGuard,
component: () => import(/* webpackChunkName: "import-readlists" */ './views/ImportReadLists.vue'),
component: () => import(/* webpackChunkName: "import-readlist" */ './views/ImportReadList.vue'),
},
],
},

View file

@ -1,5 +1,12 @@
import {AxiosInstance} from 'axios'
import {AuthorDto, BookDto} from '@/types/komga-books'
import {
ReadListCreationDto,
ReadListDto,
ReadListRequestMatchDto,
ReadListThumbnailDto,
ReadListUpdateDto,
} from '@/types/komga-readlists'
const qs = require('qs')
@ -55,17 +62,17 @@ export default class KomgaReadListsService {
}
}
async postReadListImport(files: any): Promise<ReadListRequestResultDto[]> {
async postReadListMatch(file: any): Promise<ReadListRequestMatchDto> {
try {
const formData = new FormData()
files.forEach((f: any) => formData.append('files', f))
return (await this.http.post(`${API_READLISTS}/import`, formData, {
formData.append('file', file)
return (await this.http.post(`${API_READLISTS}/match/comicrack`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})).data
} catch (e) {
let msg = 'An error occurred while trying to import readlists\''
let msg = 'An error occurred while trying to match readlist'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}

View file

@ -1,3 +1,5 @@
import {Route} from 'vue-router'
export const LIBRARY_ADDED = 'library-added'
export const LIBRARY_CHANGED = 'library-changed'
export const LIBRARY_DELETED = 'library-deleted'
@ -47,4 +49,9 @@ export interface ErrorEvent {
export interface NotificationEvent {
message: string,
text2?: string,
goTo?: {
text: string,
click: () => Promise<Route>
}
}

View file

@ -46,3 +46,24 @@ export interface ReadListThumbnailDto {
type: string,
selected: boolean
}
export interface ReadListRequestMatchDto {
readListMatch: ReadListMatchDto,
matches: ReadListRequestBookMatchesDto[],
errorCode: string,
}
export interface ReadListMatchDto {
name: string,
errorCode: string,
}
export interface ReadListRequestBookMatchesDto {
request: ReadListRequestBookDto,
matches: ReadListRequestBookMatchDto[],
}
export interface ReadListRequestBookMatchDto {
seriesId: string,
bookIds: string[],
}

View file

@ -2,7 +2,7 @@
<div>
<v-tabs grow>
<v-tab :to="{name: 'import-books'}">{{ $t('common.books') }}</v-tab>
<v-tab :to="{name: 'import-readlists'}">{{ $t('common.readlists') }}</v-tab>
<v-tab :to="{name: 'import-readlist'}">{{ $t('common.readlist') }}</v-tab>
</v-tabs>
<router-view/>
</div>

View file

@ -0,0 +1,206 @@
<template>
<v-container fluid class="pa-6">
<v-alert type="info" text class="body-2" dismissible>
<div v-html="$t('data_import.comicrack_preambule_html')"/>
</v-alert>
<v-form v-model="validMatch" ref="formMatch">
<v-row align="center">
<v-col cols="12" sm="">
<v-file-input
v-model="file"
:label="$t('data_import.field_file_label')"
prepend-icon="mdi-file-document-multiple"
accept=".cbl"
show-size
:rules="importRules"
/>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
:disabled="!validMatch"
@click="matchFile"
>{{ $t('data_import.button_match') }}
</v-btn>
</v-col>
</v-row>
</v-form>
<template v-if="result">
<v-divider/>
<v-simple-table>
<thead class="font-weight-medium">
<tr>
<td>#</td>
<td>{{ $t('data_import.requested_series') }}</td>
<td>{{ $t('data_import.requested_number') }}</td>
<td>{{ $t('common.series') }}</td>
<td>{{ $t('common.book') }}</td>
<td>
<v-icon>mdi-alert-circle-outline</v-icon>
</td>
</tr>
</thead>
<tbody
v-for="(match, i) in result.matches"
:key="i"
>
<read-list-match-row :match="match" :book-id.sync="form.bookIds[i]"
:duplicate="isDuplicateBook(form.bookIds[i])">
<td>{{ i + 1 }}</td>
</read-list-match-row>
</tbody>
</v-simple-table>
<form novalidate>
<v-row align="center" justify="end">
<v-col cols="3">
<v-text-field
:label="$t('dialog.edit_readlist.field_name')"
v-model="form.name"
clearable
:error-messages="nameErrors"
/>
</v-col>
<v-col cols="auto">
<v-checkbox
:label="$t('dialog.edit_readlist.field_manual_ordering')"
v-model="form.ordered"
/>
</v-col>
<v-col cols="5">
<v-textarea v-model="form.summary"
:label="$t('dialog.edit_readlist.field_summary')"
rows="1"
/>
</v-col>
<v-spacer/>
<v-col cols="auto">
<v-btn :color="creationFinished ? 'success': 'primary'"
@click="create"
:disabled="$v.$invalid"
>
<v-icon left v-if="creationFinished">mdi-check</v-icon>
{{ $t('common.create') }}
</v-btn>
</v-col>
</v-row>
</form>
</template>
</v-container>
</template>
<script lang="ts">
import Vue from 'vue'
import {convertErrorCodes} from '@/functions/error-codes'
import {ReadListCreationDto, ReadListDto, ReadListRequestMatchDto} from '@/types/komga-readlists'
import ReadListMatchRow from '@/components/ReadListMatchRow.vue'
import {ERROR, NOTIFICATION, NotificationEvent} from '@/types/events'
import {helpers, required} from 'vuelidate/lib/validators'
function validBookIds(this: any, value: string[]) {
return value.filter(Boolean).length === this.result.matches.length && value.filter(Boolean).length === [...new Set(value)].length
}
function validName(this: any, value: string) {
return !helpers.req(value) || !this.readLists.some((e: ReadListDto) => e.name.toLowerCase() === value.toLowerCase())
}
export default Vue.extend({
name: 'ImportReadLists',
components: {ReadListMatchRow},
data: () => ({
convertErrorCodes,
file: undefined,
result: undefined as unknown as ReadListRequestMatchDto,
validMatch: true,
validCreate: true,
form: {
name: '',
ordered: true,
summary: '',
bookIds: [] as string[],
},
readLists: [] as ReadListDto[],
creationFinished: false,
}),
validations: {
form: {
name: {required, validName},
ordered: {},
summary: {},
bookIds: {validBookIds},
},
},
computed: {
importRules(): any {
return [
(value: any) => {
if (value == null) return false
return value['size'] < 10_000_000 || this.$t('data_import.size_limit', {size: '10'}).toString()
},
]
},
nameErrors(): string[] {
const errors = [] as string[]
!this.$v?.form?.name?.required && errors.push(this.$t('common.required').toString())
!this.$v?.form?.name?.validName && errors.push(this.$t('dialog.add_to_readlist.field_search_create_error').toString())
return errors
},
},
async mounted() {
this.readLists = (await this.$komgaReadLists.getReadLists(undefined, {unpaged: true} as PageRequest)).content
},
methods: {
isDuplicateBook(bookId: string): boolean {
return this.form.bookIds.filter((b) => b === bookId).length > 1
},
async matchFile() {
this.result = await this.$komgaReadLists.postReadListMatch(this.file)
this.form.name = this.result.readListMatch.name
this.form.summary = ''
this.form.ordered = true
this.form.bookIds = []
this.creationFinished = false
},
validateForm(): ReadListCreationDto | undefined {
if (this.$v.$invalid) return undefined
return {
name: this.form.name,
bookIds: this.form.bookIds,
ordered: this.form.ordered,
summary: this.form.summary,
}
},
async create() {
if (!this.creationFinished) {
const toCreate = this.validateForm()
if (!toCreate) return
try {
const created = await this.$komgaReadLists.postReadList(toCreate)
this.$eventHub.$emit(NOTIFICATION, {
message: this.$t('data_import.readlist_created', {name: created.name}).toString(),
goTo: {
text: this.$t('common.go_to_readlist'),
click: () => this.$router.push({name: 'browse-readlist', params: {readListId: created.id}}),
},
} as NotificationEvent)
this.creationFinished = true
} catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
}
}
},
},
})
</script>
<style scoped>
</style>

View file

@ -1,119 +0,0 @@
<template>
<v-container fluid class="pa-6">
<v-alert type="info" text class="body-2">
<div v-html="$t('data_import.comicrack_preambule_html')"/>
</v-alert>
<v-form v-model="valid" ref="form">
<v-row align="center">
<v-col cols="12" sm="">
<v-file-input
v-model="files"
:label="$t('data_import.field_files_label')"
multiple
prepend-icon="mdi-file-document-multiple"
accept=".cbl"
show-size
:rules="rules"
/>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
:disabled="!valid"
@click="importFiles"
>{{ $t('data_import.button_import') }}
</v-btn>
</v-col>
</v-row>
</v-form>
<template v-if="results.length > 0">
<div class="mb-4 body-2">{{ $t('data_import.results_preambule') }}</div>
<v-expansion-panels>
<v-expansion-panel
v-for="result in results"
:key="result.requestName"
>
<v-expansion-panel-header>
<v-row no-gutters align="center">
<v-col cols="1">
<v-icon v-if="result.readList === null" color="error">mdi-alert</v-icon>
<v-icon v-if="result.readList && result.unmatchedBooks.length === 0" color="success">mdi-check</v-icon>
<v-icon v-if="result.readList && result.unmatchedBooks.length > 0" color="warning">mdi-alert-circle
</v-icon>
</v-col>
<v-col cols="3">
{{ result.requestName }}
</v-col>
<v-col
cols="8"
class="text--secondary"
>
<template v-if="result.readList">{{
$t('data_import.imported_as', {name: result.readList.name})
}}
</template>
<template v-else>{{ convertErrorCodes(result.errorCode) }}</template>
</v-col>
</v-row>
</v-expansion-panel-header>
<v-expansion-panel-content v-if="result.unmatchedBooks.length > 0">
<v-list elevation="1" dense>
<v-list-item
v-for="(book, i) in result.unmatchedBooks"
:key="i"
three-line
>
<v-list-item-content>
<v-list-item-title>{{ convertErrorCodes(book.errorCode) }}</v-list-item-title>
<v-list-item-subtitle>{{
$t('data_import.book_series', {name: book.book.series})
}}
</v-list-item-subtitle>
<v-list-item-subtitle>{{
$t('data_import.book_number', {name: book.book.number})
}}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</template>
</v-container>
</template>
<script lang="ts">
import Vue from 'vue'
import {convertErrorCodes} from '@/functions/error-codes'
export default Vue.extend({
name: 'ImportReadLists',
data: () => ({
convertErrorCodes,
files: [],
results: [] as ReadListRequestResultDto[],
valid: true,
}),
computed: {
rules(): any {
return [
(value: any) => !value || value.reduce((a: any, b: any) => a + (b['size'] || 0), 0) < 10_000_000 || this.$t('data_import.size_limit', {size: '10'}).toString(),
]
},
},
methods: {
async importFiles() {
this.results.splice(0, this.results.length, ...(await this.$komgaReadLists.postReadListImport(this.files)));
(this.$refs.form as any).reset()
},
},
})
</script>
<style scoped>
</style>