feat(webui): bulk edit book metadata

closes #476
This commit is contained in:
Gauthier Roebroeck 2021-08-20 15:00:08 +08:00
parent ae9a19af62
commit 4bab0c61c7
11 changed files with 475 additions and 1 deletions

View file

@ -60,6 +60,11 @@
:books="updateBooks"
/>
<bulk-edit-books-dialog
v-model="updateBulkBooksDialog"
:books="updateBulkBooks"
/>
<edit-series-dialog
v-model="updateSeriesDialog"
:series="updateSeries"
@ -82,10 +87,12 @@ import {SeriesDto} from '@/types/komga-series'
import {ERROR} from '@/types/events'
import ConfirmationDialog from '@/components/dialogs/ConfirmationDialog.vue'
import {LibraryDto} from '@/types/komga-libraries'
import BulkEditBooksDialog from '@/components/dialogs/BulkEditBooksDialog.vue'
export default Vue.extend({
name: 'Dialogs',
components: {
BulkEditBooksDialog,
ConfirmationDialog,
CollectionAddToDialog,
CollectionEditDialog,
@ -205,6 +212,18 @@ export default Vue.extend({
updateBooks(): BookDto | BookDto[] {
return this.$store.state.updateBooks
},
// books bulk
updateBulkBooksDialog: {
get(): boolean {
return this.$store.state.updateBulkBooksDialog
},
set(val) {
this.$store.dispatch('dialogUpdateBulkBooksDisplay', val)
},
},
updateBulkBooks(): BookDto[] {
return this.$store.state.updateBulkBooks
},
// series
updateSeriesDialog: {
get(): boolean {

View file

@ -61,6 +61,15 @@
</v-tooltip>
</v-btn>
<v-btn icon @click="bulkEdit" v-if="isAdmin && kind === 'books'">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-table-edit</v-icon>
</template>
<span>{{ $t('menu.bulk_edit_metadata') }}</span>
</v-tooltip>
</v-btn>
<v-btn icon @click="edit" v-if="isAdmin && (kind === 'books' || kind === 'series')">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
@ -137,6 +146,9 @@ export default Vue.extend({
edit () {
this.$emit('edit')
},
bulkEdit () {
this.$emit('bulk-edit')
},
doDelete () {
this.$emit('delete')
},

View file

@ -0,0 +1,382 @@
<template>
<v-dialog v-model="modal"
:fullscreen="$vuetify.breakpoint.xsOnly"
scrollable
@keydown.esc="dialogCancel"
>
<v-form ref="form">
<v-card>
<v-toolbar class="hidden-sm-and-up">
<v-btn icon @click="dialogCancel">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>{{ dialogTitle }}</v-toolbar-title>
<v-spacer/>
<v-toolbar-items>
<v-btn text color="primary" @click="dialogConfirm">{{ $t('dialog.edit_books.button_confirm') }}</v-btn>
</v-toolbar-items>
</v-toolbar>
<v-card-title class="hidden-xs-only">
<v-icon class="mx-4">mdi-pencil</v-icon>
{{ dialogTitle }}
</v-card-title>
<v-card-subtitle>
<v-container fluid>
<v-row>
<v-col
v-for="(prop, i) in headerRow"
:key="i"
:cols="prop.cols ? prop.cols : undefined"
>
<v-btn icon @click="changeAllLock(prop.prop, lockStatus(prop.prop) !== 2)">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on" :color="lockStatus(prop.prop) === 2 ? 'secondary' : ''">
{{ lockStatus(prop.prop) !== 0 ? 'mdi-lock' : 'mdi-lock-open' }}
</v-icon>
</template>
<span>{{ lockStatus(prop.prop) !== 2 ? $t('common.lock_all') : $t('common.unlock_all') }}</span>
</v-tooltip>
</v-btn>
<span class="subtitle-1">{{ prop.label }}</span>
</v-col>
</v-row>
</v-container>
</v-card-subtitle>
<v-card-text>
<v-container fluid>
<div v-for="(book, i) in books"
:key="book.id"
class="pa-2"
>
<div class="subtitle-2 mb-2">{{ bookDisplayName(book) }}</div>
<!-- Title -->
<v-row>
<v-col>
<v-text-field v-model="form[book.id].title"
dense
validate-on-blur
:rules="[validateRequired]"
@change="form[book.id].titleLock = true"
>
<template v-slot:prepend>
<v-icon :color="form[book.id].titleLock ? 'secondary' : ''"
@click="form[book.id].titleLock = !form[book.id].titleLock"
>
{{ form[book.id].titleLock ? 'mdi-lock' : 'mdi-lock-open' }}
</v-icon>
</template>
</v-text-field>
</v-col>
<!-- Number -->
<v-col cols="2">
<v-text-field v-model="form[book.id].number"
dense
validate-on-blur
:rules="[validateRequired]"
@change="form[book.id].numberLock = true"
>
<template v-slot:prepend>
<v-icon :color="form[book.id].numberLock ? 'secondary' : ''"
@click="form[book.id].numberLock = !form[book.id].numberLock"
>
{{ form[book.id].numberLock ? 'mdi-lock' : 'mdi-lock-open' }}
</v-icon>
</template>
</v-text-field>
</v-col>
<!-- Sort Number -->
<v-col cols="2">
<v-text-field v-model="form[book.id].numberSort"
type="number"
step="0.1"
dense
validate-on-blur
:rules="[validateRequired]"
@change="form[book.id].numberSortLock = true"
>
<template v-slot:prepend>
<v-icon :color="form[book.id].numberSortLock ? 'secondary' : ''"
@click="form[book.id].numberSortLock = !form[book.id].numberSortLock"
>
{{ form[book.id].numberSortLock ? 'mdi-lock' : 'mdi-lock-open' }}
</v-icon>
</template>
</v-text-field>
</v-col>
<!-- Release Date -->
<v-col cols="2">
<v-text-field v-model="form[book.id].releaseDate"
dense
placeholder="YYYY-MM-DD"
clearable
validate-on-blur
:rules="[validateReleaseDate]"
@change="form[book.id].releaseDateLock = true"
>
<template v-slot:prepend>
<v-icon :color="form[book.id].releaseDateLock ? 'secondary' : ''"
@click="form[book.id].releaseDateLock = !form[book.id].releaseDateLock"
>
{{ form[book.id].releaseDateLock ? 'mdi-lock' : 'mdi-lock-open' }}
</v-icon>
</template>
</v-text-field>
</v-col>
<!-- ISBN -->
<v-col cols="2">
<v-text-field v-model.trim="form[book.id].isbn"
dense
placeholder="978-2-20-504375-4"
clearable
validate-on-blur
:rules="[validateIsbn]"
@change="form[book.id].isbnLock = true"
>
<template v-slot:prepend>
<v-icon :color="form[book.id].isbnLock ? 'secondary' : ''"
@click="form[book.id].isbnLock = !form[book.id].isbnLock"
>
{{ form[book.id].isbnLock ? 'mdi-lock' : 'mdi-lock-open' }}
</v-icon>
</template>
</v-text-field>
</v-col>
</v-row>
<v-divider v-if="i !== (books.length - 1)"/>
</div>
</v-container>
</v-card-text>
<v-card-actions class="hidden-xs-only">
<v-spacer/>
<v-btn text @click="dialogCancel">{{ $t('dialog.edit_books.button_cancel') }}</v-btn>
<v-btn color="primary" @click="dialogConfirm">{{ $t('dialog.edit_books.button_confirm') }}</v-btn>
</v-card-actions>
</v-card>
</v-form>
</v-dialog>
</template>
<script lang="ts">
import Vue from 'vue'
import {BookDto} from '@/types/komga-books'
import IsbnVerify from '@saekitominaga/isbn-verify'
import {isMatch} from 'date-fns'
import {ERROR} from '@/types/events'
export default Vue.extend({
name: 'BulkEditBooksDialog',
data: () => {
return {
modal: false,
form: {
'sampleId': {
title: '',
titleLock: false,
number: '',
numberLock: false,
numberSort: 0,
numberSortLock: false,
releaseDate: '',
releaseDateLock: false,
isbn: '',
isbnLock: false,
},
} as any,
}
},
props: {
value: Boolean,
books: {
type: Array as () => BookDto[],
required: true,
},
},
watch: {
value(val) {
this.modal = val
},
modal(val) {
!val && this.dialogCancel()
},
books: {
immediate: true,
handler(val) {
this.dialogReset(val)
},
},
},
computed: {
dialogTitle(): string {
return this.$tc('dialog.edit_books.dialog_title_multiple', this.books.length)
},
headerRow(): object[] {
return [
{
prop: 'title',
label: this.$t('dialog.edit_books.field_title'),
},
{
prop: 'number',
label: this.$t('dialog.edit_books.field_number'),
cols: 2,
}, {
prop: 'numberSort',
label: this.$t('dialog.edit_books.field_number_sort'),
cols: 2,
}, {
prop: 'releaseDate',
label: this.$t('dialog.edit_books.field_release_date'),
cols: 2,
}, {
prop: 'isbn',
label: this.$t('dialog.edit_books.field_isbn'),
cols: 2,
}]
},
},
methods: {
validateIsbn(isbn: string): string | boolean {
return isbn && !new IsbnVerify(isbn).isIsbn13({check_digit: true}) ? this.$t('dialog.edit_books.field_isbn_error').toString() : true
},
validateRequired(value: string): string | boolean {
return !value ? this.$t('common.required').toString() : true
},
validateReleaseDate(date: string): string | boolean {
return date && !isMatch(date, 'yyyy-MM-dd') ? this.$t('dialog.edit_books.field_release_date_error').toString() : true
},
bookDisplayName(book: BookDto):string {
const parts = book.url.split('/')
return parts[parts.length - 2] + '/' + parts[parts.length - 1]
},
lockStatus(prop: string): number {
const propLock = `${prop}Lock`
let count = 0
for (const book of this.books) {
if (this.form[book.id][propLock]) count++
}
if (count === 0) return 0
if (count === this.books.length) return 2
return 1
},
changeAllLock(prop: string, lock: boolean) {
const propLock = `${prop}Lock`
for (const book of this.books) {
this.form[book.id][propLock] = lock
}
},
dialogReset(books: BookDto[]) {
(this.$refs.form as any)?.resetValidation()
this.form = books.reduce((accum, current) => {
accum[current.id] = {
title: current.metadata.title,
titleLock: current.metadata.titleLock,
number: current.metadata.number,
numberLock: current.metadata.numberLock,
numberSort: current.metadata.numberSort,
numberSortLock: current.metadata.numberSortLock,
releaseDate: current.metadata.releaseDate,
releaseDateLock: current.metadata.releaseDateLock,
isbn: current.metadata.isbn,
isbnLock: current.metadata.isbnLock,
}
return accum
}, {} as any)
},
dialogCancel() {
this.$emit('input', false)
this.dialogReset(this.books)
},
async dialogConfirm() {
if (await this.editBooks()) {
this.$emit('input', false)
}
},
validateForm(): any {
if ((this.$refs.form as any).validate()) {
// const metadata = {
// authorsLock: this.form.authorsLock,
// tagsLock: this.form.tagsLock,
// }
//
// if (this.$v.form?.authors?.$dirty) {
// this.$_.merge(metadata, {
// authors: this.$_.keys(this.form.authors).flatMap((role: string) =>
// this.$_.get(this.form.authors, role).map((name: string) => ({name: name, role: role})),
// ),
// })
// }
//
// if (this.$v.form?.tags?.$dirty) {
// this.$_.merge(metadata, {tags: this.form.tags})
// }
//
// this.$_.merge(metadata, {
// titleLock: this.form.titleLock,
// numberLock: this.form.numberLock,
// numberSortLock: this.form.numberSortLock,
// summaryLock: this.form.summaryLock,
// releaseDateLock: this.form.releaseDateLock,
// isbnLock: this.form.isbnLock,
// })
//
// if (this.$v.form?.title?.$dirty) {
// this.$_.merge(metadata, {title: this.form.title})
// }
//
// if (this.$v.form?.number?.$dirty) {
// this.$_.merge(metadata, {number: this.form.number})
// }
//
// if (this.$v.form?.numberSort?.$dirty) {
// this.$_.merge(metadata, {numberSort: this.form.numberSort})
// }
//
// if (this.$v.form?.summary?.$dirty) {
// this.$_.merge(metadata, {summary: this.form.summary})
// }
//
// if (this.$v.form?.releaseDate?.$dirty) {
// this.$_.merge(metadata, {releaseDate: this.form.releaseDate ? this.form.releaseDate : null})
// }
//
// if (this.$v.form?.isbn?.$dirty) {
// this.$_.merge(metadata, {isbn: this.form.isbn})
// }
return this.form
}
return null
},
async editBooks(): Promise<boolean> {
const metadata = this.validateForm()
if (metadata) {
try {
await this.$komgaBooks.updateMetadataBatch(metadata)
} catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
}
return true
} else return false
},
},
})
</script>
<style lang="sass" scoped>
@import '../../styles/tabbed-dialog'
</style>

View file

@ -191,6 +191,7 @@
"go_to_series": "Go to series",
"locale_name": "English",
"locale_rtl": "false",
"lock_all": "Lock all",
"n_selected": "{count} selected",
"nothing_to_show": "Nothing to show",
"outdated": "Outdated",
@ -207,6 +208,7 @@
"series": "Series",
"tags": "Tags",
"unavailable": "Unavailable",
"unlock_all": "Unlock all",
"use_filter_panel_to_change_filter": "Use the filter panel to change the active filter",
"year": "year"
},
@ -581,6 +583,7 @@
"add_to_collection": "Add to collection",
"add_to_readlist": "Add to read list",
"analyze": "Analyze",
"bulk_edit_metadata": "Bulk edit metadata",
"delete": "Delete",
"deselect_all": "Deselect all",
"download_series": "Download series",

View file

@ -1,5 +1,12 @@
import {AxiosInstance} from 'axios'
import {BookDto, BookImportBatchDto, BookMetadataUpdateDto, PageDto, ReadProgressUpdateDto} from '@/types/komga-books'
import {
BookDto,
BookImportBatchDto,
BookMetadataUpdateBatchDto,
BookMetadataUpdateDto,
PageDto,
ReadProgressUpdateDto,
} from '@/types/komga-books'
import {formatISO} from 'date-fns'
const qs = require('qs')
@ -158,6 +165,18 @@ export default class KomgaBooksService {
}
}
async updateMetadataBatch(batch: BookMetadataUpdateBatchDto) {
try {
await this.http.patch(`${API_BOOKS}/metadata`, batch)
} catch (e) {
let msg = 'An error occurred while trying to update book metadata in batch'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async updateReadProgress(bookId: string, readProgress: ReadProgressUpdateDto) {
try {
await this.http.patch(`${API_BOOKS}/${bookId}/read-progress`, readProgress)

View file

@ -36,6 +36,10 @@ export default new Vuex.Store({
// books
updateBooks: {} as BookDto | BookDto[],
updateBooksDialog: false,
// books bulk
updateBulkBooks: [] as BookDto[],
updateBulkBooksDialog: false,
// series
updateSeries: {} as SeriesDto | SeriesDto[],
updateSeriesDialog: false,
@ -101,6 +105,13 @@ export default new Vuex.Store({
setUpdateBooksDialog(state, dialog) {
state.updateBooksDialog = dialog
},
// Books bulk
setUpdateBulkBooks(state, books) {
state.updateBulkBooks = books
},
setUpdateBulkBooksDialog(state, dialog) {
state.updateBulkBooksDialog = dialog
},
// Series
setUpdateSeries(state, series) {
state.updateSeries = series
@ -184,6 +195,14 @@ export default new Vuex.Store({
dialogUpdateBooksDisplay({commit}, value) {
commit('setUpdateBooksDialog', value)
},
// books bulk
dialogUpdateBulkBooks({commit}, books) {
commit('setUpdateBulkBooks', books)
commit('setUpdateBulkBooksDialog', true)
},
dialogUpdateBulkBooksDisplay({commit}, value) {
commit('setUpdateBulkBooksDialog', value)
},
// series
dialogUpdateSeries({commit}, series) {
commit('setUpdateSeries', series)

View file

@ -91,6 +91,10 @@ export interface BookMetadataUpdateDto {
isbnLock?: boolean
}
export interface BookMetadataUpdateBatchDto {
[bookId: string]: BookMetadataUpdateBatchDto
}
export interface AuthorDto {
name: string,
role: string

View file

@ -49,6 +49,7 @@
@mark-unread="markSelectedUnread"
@add-to-readlist="addToReadList"
@edit="editMultipleBooks"
@bulk-edit="bulkEditMultipleBooks"
/>
<!-- Edit elements sticky bar -->
@ -356,6 +357,9 @@ export default Vue.extend({
editMultipleBooks() {
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
},
bulkEditMultipleBooks() {
this.$store.dispatch('dialogUpdateBulkBooks', this.selectedBooks)
},
async markSelectedRead() {
await Promise.all(this.selectedBooks.map(b =>
this.$komgaBooks.updateReadProgress(b.id, {completed: true} as ReadProgressUpdateDto),

View file

@ -47,6 +47,7 @@
@mark-read="markSelectedRead"
@mark-unread="markSelectedUnread"
@add-to-readlist="addToReadList"
@bulk-edit="bulkEditMultipleBooks"
@edit="editMultipleBooks"
/>
@ -804,6 +805,9 @@ export default Vue.extend({
editMultipleBooks() {
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
},
bulkEditMultipleBooks() {
this.$store.dispatch('dialogUpdateBulkBooks', this.$_.sortBy(this.selectedBooks, ['metadata.numberSort']))
},
addToReadList() {
this.$store.dispatch('dialogAddBooksToReadList', this.selectedBooks)
},

View file

@ -38,6 +38,7 @@
@mark-unread="markSelectedBooksUnread"
@add-to-readlist="addToReadList"
@edit="editMultipleBooks"
@bulk-edit="bulkEditMultipleBooks"
/>
<v-container fluid>
@ -375,6 +376,9 @@ export default Vue.extend({
editMultipleBooks() {
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
},
bulkEditMultipleBooks() {
this.$store.dispatch('dialogUpdateBulkBooks', this.selectedBooks)
},
addToReadList() {
this.$store.dispatch('dialogAddBooksToReadList', this.selectedBooks)
this.selectedBooks = []

View file

@ -24,6 +24,7 @@
@mark-unread="markSelectedBooksUnread"
@add-to-readlist="addToReadList"
@edit="editMultipleBooks"
@bulk-edit="bulkEditMultipleBooks"
/>
<multi-select-bar
@ -303,6 +304,9 @@ export default Vue.extend({
editMultipleBooks () {
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
},
bulkEditMultipleBooks() {
this.$store.dispatch('dialogUpdateBulkBooks', this.selectedBooks)
},
deleteCollections () {
this.$store.dispatch('dialogDeleteCollection', this.selectedCollections)
},