mirror of
https://github.com/gotson/komga.git
synced 2025-12-21 07:56:57 +01:00
parent
f69f73df08
commit
6a53e8fd6b
27 changed files with 1843 additions and 162 deletions
|
|
@ -1,42 +1,72 @@
|
|||
<template>
|
||||
<v-card :width="width"
|
||||
:to="{name:'browse-book', params: {bookId: book.id}}"
|
||||
>
|
||||
<v-img
|
||||
:src="thumbnailUrl"
|
||||
lazy-src="../assets/cover.svg"
|
||||
aspect-ratio="0.7071"
|
||||
>
|
||||
<span class="white--text pa-1 px-2 subtitle-2"
|
||||
:style="{background: format.color, position: 'absolute', right: 0}"
|
||||
<v-hover :disabled="!overlay">
|
||||
<template v-slot:default="{ hover }">
|
||||
<v-card :width="width"
|
||||
:ripple="false"
|
||||
@click="cardClick"
|
||||
>
|
||||
{{ format.type }}
|
||||
</span>
|
||||
<v-img
|
||||
:src="thumbnailUrl"
|
||||
lazy-src="../assets/cover.svg"
|
||||
aspect-ratio="0.7071"
|
||||
>
|
||||
<span class="white--text pa-1 px-2 subtitle-2"
|
||||
:style="{background: format.color, position: 'absolute', right: 0}"
|
||||
>
|
||||
{{ format.type }}
|
||||
</span>
|
||||
|
||||
<span v-if="book.media.status !== 'READY'"
|
||||
class="white--text pa-1 px-2 subtitle-2"
|
||||
:style="{background: statusColor, position: 'absolute', bottom: 0, width: `${width}px`}"
|
||||
>
|
||||
{{ book.media.status }}
|
||||
</span>
|
||||
</v-img>
|
||||
<span v-if="book.media.status !== 'READY'"
|
||||
class="white--text pa-1 px-2 subtitle-2"
|
||||
:style="{background: statusColor, position: 'absolute', bottom: 0, width: `${width}px`}"
|
||||
>
|
||||
{{ book.media.status }}
|
||||
</span>
|
||||
|
||||
<v-card-subtitle class="pa-2 pb-1 text--primary"
|
||||
v-line-clamp="2"
|
||||
style="word-break: normal !important; height: 4em"
|
||||
:title="book.name"
|
||||
>
|
||||
#{{ book.number }} - {{ book.name }}
|
||||
</v-card-subtitle>
|
||||
<v-fade-transition>
|
||||
<v-overlay
|
||||
v-if="hover || selected || preSelect"
|
||||
absolute
|
||||
:opacity="hover ? 0.3 : 0"
|
||||
:class="`${hover ? 'item-border-darken' : selected ? 'item-border' : 'item-border-transparent'} overlay-full`"
|
||||
>
|
||||
<v-icon v-if="select"
|
||||
:color="selected ? 'secondary' : ''"
|
||||
style="position: absolute; top: 5px; left: 10px"
|
||||
@click.stop="selectItem"
|
||||
>
|
||||
{{ selected || (preSelect && hover) ? 'mdi-checkbox-marked-circle' : 'mdi-checkbox-blank-circle-outline'
|
||||
}}
|
||||
</v-icon>
|
||||
|
||||
<v-card-text class="px-2"
|
||||
>
|
||||
<div>{{ book.size }}</div>
|
||||
<div v-if="book.media.pagesCount === 1">{{ book.media.pagesCount }} page</div>
|
||||
<div v-else>{{ book.media.pagesCount }} pages</div>
|
||||
</v-card-text>
|
||||
<v-icon v-if="!selected && !preSelect && edit"
|
||||
style="position: absolute; bottom: 10px; left: 10px"
|
||||
@click.stop="editItem"
|
||||
>
|
||||
mdi-pencil
|
||||
</v-icon>
|
||||
</v-overlay>
|
||||
</v-fade-transition>
|
||||
</v-img>
|
||||
|
||||
</v-card>
|
||||
<v-card-subtitle class="pa-2 pb-1 text--primary"
|
||||
v-line-clamp="2"
|
||||
style="word-break: normal !important; height: 4em"
|
||||
:title="book.name"
|
||||
>
|
||||
#{{ book.number }} - {{ book.name }}
|
||||
</v-card-subtitle>
|
||||
|
||||
<v-card-text class="px-2"
|
||||
>
|
||||
<div>{{ book.size }}</div>
|
||||
<div v-if="book.media.pagesCount === 1">{{ book.media.pagesCount }} page</div>
|
||||
<div v-else>{{ book.media.pagesCount }} pages</div>
|
||||
</v-card-text>
|
||||
|
||||
</v-card>
|
||||
</template>
|
||||
</v-hover>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
@ -55,6 +85,22 @@ export default Vue.extend({
|
|||
type: [String, Number],
|
||||
required: false,
|
||||
default: 150
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
preSelect: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
select: {
|
||||
type: Function,
|
||||
required: false
|
||||
},
|
||||
edit: {
|
||||
type: Function,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -75,10 +121,48 @@ export default Vue.extend({
|
|||
default:
|
||||
return 'black'
|
||||
}
|
||||
},
|
||||
overlay (): boolean {
|
||||
return this.edit !== undefined || this.select !== undefined
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectItem () {
|
||||
if (this.select !== undefined) {
|
||||
this.select()
|
||||
}
|
||||
},
|
||||
editItem () {
|
||||
if (this.edit !== undefined) {
|
||||
this.edit(this.book)
|
||||
}
|
||||
},
|
||||
cardClick () {
|
||||
if (this.preSelect && this.select !== undefined) {
|
||||
this.select()
|
||||
} else {
|
||||
this.$router.push({ name: 'browse-book', params: { bookId: this.book.id.toString() } })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
.item-border {
|
||||
border: 3px solid var(--v-secondary-base);
|
||||
}
|
||||
|
||||
.item-border-transparent {
|
||||
border: 3px solid transparent;
|
||||
}
|
||||
|
||||
.item-border-darken {
|
||||
border: 3px solid var(--v-secondary-darken2);
|
||||
}
|
||||
|
||||
.overlay-full .v-overlay__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
585
komga-webui/src/components/EditBooksDialog.vue
Normal file
585
komga-webui/src/components/EditBooksDialog.vue
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-dialog v-model="modal"
|
||||
:fullscreen="$vuetify.breakpoint.xsOnly"
|
||||
max-width="800"
|
||||
@keydown.esc="dialogCancel"
|
||||
>
|
||||
<form novalidate>
|
||||
<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">Save changes</v-btn>
|
||||
</v-toolbar-items>
|
||||
</v-toolbar>
|
||||
|
||||
<v-card-title class="hidden-xs-only">
|
||||
<v-icon class="mr-4">mdi-pencil</v-icon>
|
||||
{{ dialogTitle }}
|
||||
</v-card-title>
|
||||
|
||||
<v-tabs :vertical="$vuetify.breakpoint.smAndUp">
|
||||
<v-tab class="justify-start">
|
||||
<v-icon left class="hidden-xs-only">mdi-format-align-center</v-icon>
|
||||
General
|
||||
</v-tab>
|
||||
<v-tab class="justify-start">
|
||||
<v-icon left class="hidden-xs-only">mdi-account-multiple</v-icon>
|
||||
Authors
|
||||
</v-tab>
|
||||
|
||||
<!-- Tab: General -->
|
||||
<v-tab-item
|
||||
:style="$vuetify.breakpoint.smAndUp ? `max-height: ${$vuetify.breakpoint.height * .5}px;overflow-y: scroll` : ''">
|
||||
<v-card flat>
|
||||
<v-container fluid>
|
||||
|
||||
<!-- Title -->
|
||||
<v-row v-if="single">
|
||||
<v-col cols="12">
|
||||
<v-text-field v-model="form.title"
|
||||
label="Title"
|
||||
filled
|
||||
dense
|
||||
:error-messages="requiredErrors('title')"
|
||||
@input="$v.form.title.$touch()"
|
||||
@blur="$v.form.title.$touch()"
|
||||
@change="form.titleLock = true"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="form.titleLock ? 'secondary' : ''"
|
||||
@click="form.titleLock = !form.titleLock"
|
||||
>
|
||||
{{ form.titleLock ? 'mdi-lock' : 'mdi-lock-open' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Number -->
|
||||
<v-row v-if="single">
|
||||
<v-col cols="6">
|
||||
<v-text-field v-model="form.number"
|
||||
label="Number"
|
||||
filled
|
||||
dense
|
||||
:error-messages="requiredErrors('number')"
|
||||
@input="$v.form.number.$touch()"
|
||||
@blur="$v.form.number.$touch()"
|
||||
@change="form.numberLock = true"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="form.numberLock ? 'secondary' : ''"
|
||||
@click="form.numberLock = !form.numberLock"
|
||||
>
|
||||
{{ form.numberLock ? 'mdi-lock' : 'mdi-lock-open' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
|
||||
<!-- Sort Number -->
|
||||
<v-col cols="6">
|
||||
<v-text-field v-model="form.numberSort"
|
||||
type="number"
|
||||
step="0.1"
|
||||
label="Sort Number"
|
||||
filled
|
||||
dense
|
||||
hint="You can use decimal numbers"
|
||||
persistent-hint
|
||||
:error-messages="requiredErrors('numberSort')"
|
||||
@input="$v.form.numberSort.$touch()"
|
||||
@blur="$v.form.numberSort.$touch()"
|
||||
@change="form.numberSortLock = true"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="form.numberSortLock ? 'secondary' : ''"
|
||||
@click="form.numberSortLock = !form.numberSortLock"
|
||||
>
|
||||
{{ form.numberSortLock ? 'mdi-lock' : 'mdi-lock-open' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Summary -->
|
||||
<v-row v-if="single">
|
||||
<v-col cols="12">
|
||||
<v-textarea v-model="form.summary"
|
||||
label="Summary"
|
||||
filled
|
||||
dense
|
||||
@input="$v.form.summary.$touch()"
|
||||
@change="form.summaryLock = true"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="form.summaryLock ? 'secondary' : ''"
|
||||
@click="form.summaryLock = !form.summaryLock"
|
||||
>
|
||||
{{ form.summaryLock ? 'mdi-lock' : 'mdi-lock-open' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-textarea>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Publisher -->
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<v-text-field v-model="form.publisher"
|
||||
label="Publisher"
|
||||
filled
|
||||
dense
|
||||
:placeholder="mixed.publisher ? 'MIXED' : ''"
|
||||
@input="$v.form.publisher.$touch()"
|
||||
@change="form.publisherLock = true"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="form.publisherLock ? 'secondary' : ''"
|
||||
@click="form.publisherLock = !form.publisherLock"
|
||||
>
|
||||
{{ form.publisherLock ? 'mdi-lock' : 'mdi-lock-open' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
|
||||
<!-- Age Rating -->
|
||||
<v-col cols="6">
|
||||
<v-text-field v-model="form.ageRating"
|
||||
label="Age Rating"
|
||||
clearable
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
:placeholder="mixed.ageRating ? 'MIXED' : ''"
|
||||
:error-messages="ageRatingErrors"
|
||||
@input="$v.form.ageRating.$touch()"
|
||||
@blur="$v.form.ageRating.$touch()"
|
||||
@change="form.ageRatingLock = true"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="form.ageRatingLock ? 'secondary' : ''"
|
||||
@click="form.ageRatingLock = !form.ageRatingLock"
|
||||
>
|
||||
{{ form.ageRatingLock ? 'mdi-lock' : 'mdi-lock-open' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Release Date -->
|
||||
<v-row v-if="single">
|
||||
<v-col cols="12">
|
||||
<v-text-field v-model="form.releaseDate"
|
||||
label="Release Date"
|
||||
filled
|
||||
dense
|
||||
placeholder="YYYY-MM-DD"
|
||||
clearable
|
||||
:error-messages="releaseDateErrors"
|
||||
@blur="$v.form.releaseDate.$touch()"
|
||||
@change="form.releaseDateLock = true"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="form.releaseDateLock ? 'secondary' : ''"
|
||||
@click="form.releaseDateLock = !form.releaseDateLock"
|
||||
>
|
||||
{{ form.releaseDateLock ? 'mdi-lock' : 'mdi-lock-open' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Reading Direction -->
|
||||
<v-row>
|
||||
<v-col cols="">
|
||||
<v-select v-model="form.readingDirection"
|
||||
:items="readingDirections"
|
||||
label="Reading Direction"
|
||||
clearable
|
||||
filled
|
||||
:placeholder="mixed.readingDirection ? 'MIXED' : ''"
|
||||
@input="$v.form.readingDirection.$touch()"
|
||||
@change="form.readingDirectionLock = true"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="form.readingDirectionLock ? 'secondary' : ''"
|
||||
@click="form.readingDirectionLock = !form.readingDirectionLock"
|
||||
>
|
||||
{{ form.readingDirectionLock ? 'mdi-lock' : 'mdi-lock-open' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-container>
|
||||
</v-card>
|
||||
</v-tab-item>
|
||||
|
||||
<!-- Tab: Authors -->
|
||||
<v-tab-item
|
||||
:style="$vuetify.breakpoint.smAndUp ? `max-height: ${$vuetify.breakpoint.height * .5}px;overflow-y: scroll` : ''">
|
||||
<v-card flat>
|
||||
<v-container fluid>
|
||||
<v-alert v-if="!single"
|
||||
type="warning"
|
||||
outlined
|
||||
dense
|
||||
>
|
||||
You are editing authors for multiple books. This will override existing authors of each book.
|
||||
</v-alert>
|
||||
<v-row v-for="(role, i) in authorRoles"
|
||||
:key="i"
|
||||
>
|
||||
<v-col cols="12">
|
||||
<span class="body-2">{{ $_.capitalize(role.plural) }}</span>
|
||||
<v-combobox v-model="form.authors[role.role]"
|
||||
:items="authorSearchResultsFull"
|
||||
:search-input.sync="authorSearch[i]"
|
||||
@keydown.esc="authorSearch[i] = null"
|
||||
@input="$v.form.authors.$touch()"
|
||||
@change="form.authorsLock = true"
|
||||
hide-selected
|
||||
chips
|
||||
deletable-chips
|
||||
multiple
|
||||
filled
|
||||
dense
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="form.authorsLock ? 'secondary' : ''"
|
||||
@click="form.authorsLock = !form.authorsLock"
|
||||
>
|
||||
{{ form.authorsLock ? 'mdi-lock' : 'mdi-lock-open' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-combobox>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card>
|
||||
</v-tab-item>
|
||||
|
||||
</v-tabs>
|
||||
|
||||
<v-card-actions class="hidden-xs-only">
|
||||
<v-spacer/>
|
||||
<v-btn text @click="dialogCancel">Cancel</v-btn>
|
||||
<v-btn text class="primary--text" @click="dialogConfirm">Save changes</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</form>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
bottom
|
||||
color="error"
|
||||
>
|
||||
{{ snackText }}
|
||||
<v-btn
|
||||
text
|
||||
@click="snackbar = false"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { helpers, minValue, requiredIf } from 'vuelidate/lib/validators'
|
||||
import { ReadingDirection } from '@/types/enum-books'
|
||||
import moment from 'moment'
|
||||
import { authorRoles } from '@/types/author-roles'
|
||||
import { groupAuthorsByRole } from '@/functions/authors'
|
||||
|
||||
const validDate = (value: any) => !helpers.req(value) || moment(value, 'YYYY-MM-DD', true).isValid()
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'EditBooksDialog',
|
||||
data: () => {
|
||||
return {
|
||||
modal: false,
|
||||
snackbar: false,
|
||||
snackText: '',
|
||||
form: {
|
||||
title: '',
|
||||
titleLock: false,
|
||||
summary: '',
|
||||
summaryLock: false,
|
||||
number: '',
|
||||
numberLock: false,
|
||||
numberSort: 0,
|
||||
numberSortLock: false,
|
||||
readingDirection: '',
|
||||
readingDirectionLock: false,
|
||||
publisher: '',
|
||||
publisherLock: false,
|
||||
ageRating: undefined as unknown as number,
|
||||
ageRatingLock: false,
|
||||
releaseDate: '',
|
||||
releaseDateLock: false,
|
||||
authors: {},
|
||||
authorsLock: false
|
||||
},
|
||||
mixed: {
|
||||
readingDirection: false,
|
||||
publisher: false,
|
||||
ageRating: false
|
||||
},
|
||||
authorRoles,
|
||||
authorSearch: [],
|
||||
authorSearchResults: [] as string[]
|
||||
}
|
||||
},
|
||||
props: {
|
||||
value: Boolean,
|
||||
books: {
|
||||
type: [Object as () => BookDto, Array as () => BookDto[]],
|
||||
required: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value (val) {
|
||||
this.modal = val
|
||||
},
|
||||
modal (val) {
|
||||
!val && this.dialogCancel()
|
||||
},
|
||||
books: {
|
||||
immediate: true,
|
||||
handler (val) {
|
||||
this.dialogReset(val)
|
||||
}
|
||||
},
|
||||
authorSearch: {
|
||||
deep: true,
|
||||
async handler (val: []) {
|
||||
const index = val.findIndex(x => x !== null)
|
||||
this.authorSearchResults = await this.$komgaReferential.getAuthors(val[index])
|
||||
}
|
||||
}
|
||||
},
|
||||
validations: {
|
||||
form: {
|
||||
title: {
|
||||
required: requiredIf(function (this: any, model: any) {
|
||||
return this.single
|
||||
})
|
||||
},
|
||||
number: {
|
||||
required: requiredIf(function (this: any, model: any) {
|
||||
return this.single
|
||||
})
|
||||
},
|
||||
numberSort: {
|
||||
required: requiredIf(function (this: any, model: any) {
|
||||
return this.single
|
||||
})
|
||||
},
|
||||
ageRating: { minValue: minValue(0) },
|
||||
releaseDate: { validDate },
|
||||
summary: {},
|
||||
readingDirection: {},
|
||||
publisher: {},
|
||||
authors: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
single (): boolean {
|
||||
return !Array.isArray(this.books)
|
||||
},
|
||||
readingDirections (): any[] {
|
||||
return Object.keys(ReadingDirection).map(x => (
|
||||
{
|
||||
text: this.$_.capitalize(x.replace(/_/g, ' ')),
|
||||
value: x
|
||||
})
|
||||
)
|
||||
},
|
||||
authorSearchResultsFull (): string[] {
|
||||
// merge local values with server search, so that already input value is available
|
||||
const local = (this.$_.values(this.form.authors).flat()) as unknown as string[]
|
||||
return this.$_.sortBy(this.$_.union(local, this.authorSearchResults), x => x.toLowerCase())
|
||||
},
|
||||
dialogTitle (): string {
|
||||
return this.single
|
||||
? `Edit ${this.$_.get(this.books, 'metadata.title')}`
|
||||
: `Edit ${(this.books as BookDto[]).length} books`
|
||||
},
|
||||
ageRatingErrors (): string[] {
|
||||
const errors = [] as string[]
|
||||
if (!this.$v.form?.ageRating?.$dirty) return errors
|
||||
!this.$v?.form?.ageRating?.minValue && errors.push('Age rating must be 0 or more')
|
||||
return errors
|
||||
},
|
||||
releaseDateErrors (): string[] {
|
||||
const errors = [] as string[]
|
||||
if (!this.$v.form?.releaseDate?.$dirty) return errors
|
||||
!this.$v?.form?.releaseDate?.validDate && errors.push('Must be a valid date in YYYY-MM-DD format')
|
||||
return errors
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
requiredErrors (fieldName: string): string[] {
|
||||
const errors = [] as string[]
|
||||
const formField = this.$v.form!![fieldName] as any
|
||||
if (!formField.$dirty) return errors
|
||||
!formField.required && errors.push('Required')
|
||||
return errors
|
||||
},
|
||||
dialogReset (books: BookDto | BookDto[]) {
|
||||
this.$v.$reset()
|
||||
if (Array.isArray(books) && books.length === 0) return
|
||||
else if (this.$_.isEmpty(books)) return
|
||||
if (Array.isArray(books) && books.length > 0) {
|
||||
const readingDirection = this.$_.uniq(books.map(x => x.metadata.readingDirection))
|
||||
this.form.readingDirection = readingDirection.length > 1 ? '' : readingDirection[0]
|
||||
this.mixed.readingDirection = readingDirection.length > 1
|
||||
|
||||
const readingDirectionLock = this.$_.uniq(books.map(x => x.metadata.readingDirectionLock))
|
||||
this.form.readingDirectionLock = readingDirectionLock.length > 1 ? false : readingDirectionLock[0]
|
||||
|
||||
const ageRating = this.$_.uniq(books.map(x => x.metadata.ageRating))
|
||||
this.form.ageRating = ageRating.length > 1 ? undefined as unknown as number : ageRating[0]
|
||||
this.mixed.ageRating = ageRating.length > 1
|
||||
|
||||
const ageRatingLock = this.$_.uniq(books.map(x => x.metadata.ageRatingLock))
|
||||
this.form.ageRatingLock = ageRatingLock.length > 1 ? false : ageRatingLock[0]
|
||||
|
||||
const publisher = this.$_.uniq(books.map(x => x.metadata.publisher))
|
||||
this.form.publisher = publisher.length > 1 ? '' : publisher[0]
|
||||
this.mixed.publisher = publisher.length > 1
|
||||
|
||||
const publisherLock = this.$_.uniq(books.map(x => x.metadata.publisherLock))
|
||||
this.form.publisherLock = publisherLock.length > 1 ? false : publisherLock[0]
|
||||
|
||||
this.form.authors = {}
|
||||
|
||||
const authorsLock = this.$_.uniq(books.map(x => x.metadata.authorsLock))
|
||||
this.form.authorsLock = authorsLock.length > 1 ? false : authorsLock[0]
|
||||
} else {
|
||||
const book = books as BookDto
|
||||
this.$_.merge(this.form, book.metadata)
|
||||
this.form.authors = groupAuthorsByRole(book.metadata.authors)
|
||||
}
|
||||
},
|
||||
dialogCancel () {
|
||||
this.$emit('input', false)
|
||||
this.dialogReset(this.books)
|
||||
},
|
||||
async dialogConfirm () {
|
||||
if (await this.editBooks()) {
|
||||
this.$emit('input', false)
|
||||
}
|
||||
},
|
||||
showSnack (message: string) {
|
||||
this.snackText = message
|
||||
this.snackbar = true
|
||||
},
|
||||
validateForm (): any {
|
||||
if (!this.$v.$invalid) {
|
||||
const metadata = {
|
||||
readingDirectionLock: this.form.readingDirectionLock,
|
||||
ageRatingLock: this.form.ageRatingLock,
|
||||
publisherLock: this.form.publisherLock,
|
||||
authorsLock: this.form.authorsLock
|
||||
}
|
||||
|
||||
if (this.$v.form?.readingDirection?.$dirty) {
|
||||
this.$_.merge(metadata, { readingDirection: this.form.readingDirection ? this.form.readingDirection : null })
|
||||
}
|
||||
|
||||
if (this.$v.form?.ageRating?.$dirty) {
|
||||
this.$_.merge(metadata, { ageRating: this.form.ageRating })
|
||||
}
|
||||
|
||||
if (this.$v.form?.publisher?.$dirty) {
|
||||
this.$_.merge(metadata, { publisher: this.form.publisher })
|
||||
}
|
||||
|
||||
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.single) {
|
||||
this.$_.merge(metadata, {
|
||||
titleLock: this.form.titleLock,
|
||||
numberLock: this.form.numberLock,
|
||||
numberSortLock: this.form.numberSortLock,
|
||||
summaryLock: this.form.summaryLock,
|
||||
releaseDateLock: this.form.releaseDateLock
|
||||
})
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
return null
|
||||
},
|
||||
async editBooks ()
|
||||
:
|
||||
Promise<boolean> {
|
||||
const metadata = this.validateForm()
|
||||
if (metadata) {
|
||||
console.log(metadata)
|
||||
const updated = [] as BookDto[]
|
||||
const toUpdate = (this.single ? [this.books] : this.books) as BookDto[]
|
||||
for (const b of toUpdate) {
|
||||
try {
|
||||
const updatedBooks = await this.$komgaBooks.updateMetadata(b.id, metadata)
|
||||
updated.push(updatedBooks)
|
||||
} catch (e) {
|
||||
this.showSnack(e.message)
|
||||
updated.push(b)
|
||||
}
|
||||
}
|
||||
console.log(updated)
|
||||
this.$emit('update:books', this.single ? updated[0] : updated)
|
||||
return true
|
||||
} else return false
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
31
komga-webui/src/functions/authors.ts
Normal file
31
komga-webui/src/functions/authors.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { get, groupBy, mapKeys, mapValues } from 'lodash'
|
||||
import { authorRoles } from '@/types/author-roles'
|
||||
|
||||
// return an object where keys are roles, and values are string[]
|
||||
export function groupAuthorsByRole (authors: AuthorDto[]): any {
|
||||
return mapValues(groupBy(authors, 'role'),
|
||||
authors => authors.map((author: AuthorDto) => author.name))
|
||||
}
|
||||
|
||||
// return an object where keys are roles (plural form), and values are string[]
|
||||
export function groupAuthorsByRolePlural (authors: AuthorDto[]): any {
|
||||
const r = mapKeys(groupAuthorsByRole(authors),
|
||||
(v, k) => get(authorRoles.find(x => x.role === k), 'plural', k)
|
||||
)
|
||||
|
||||
// sort object keys according to the order of keys in authorRoles
|
||||
// push unknown keys to the end of the array
|
||||
const roles = authorRoles.map(x => x.plural)
|
||||
const o = {} as any
|
||||
Object.keys(r)
|
||||
.sort((a, b) => {
|
||||
let index1 = roles.indexOf(a)
|
||||
let index2 = roles.indexOf(b)
|
||||
return (index1 === -1 ? Infinity : index1) - (index2 === -1 ? Infinity : index2)
|
||||
})
|
||||
.forEach((key: string) => {
|
||||
o[key] = r[key]
|
||||
})
|
||||
|
||||
return o
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import App from './App.vue'
|
|||
import actuator from './plugins/actuator.plugin'
|
||||
import httpPlugin from './plugins/http.plugin'
|
||||
import komgaBooks from './plugins/komga-books.plugin'
|
||||
import komgaReferential from './plugins/komga-referential.plugin'
|
||||
import komgaFileSystem from './plugins/komga-filesystem.plugin'
|
||||
import komgaLibraries from './plugins/komga-libraries.plugin'
|
||||
import komgaSeries from './plugins/komga-series.plugin'
|
||||
|
|
@ -27,6 +28,7 @@ Vue.use(httpPlugin)
|
|||
Vue.use(komgaFileSystem, { http: Vue.prototype.$http })
|
||||
Vue.use(komgaSeries, { http: Vue.prototype.$http })
|
||||
Vue.use(komgaBooks, { http: Vue.prototype.$http })
|
||||
Vue.use(komgaReferential, { http: Vue.prototype.$http })
|
||||
Vue.use(komgaUsers, { store: store, http: Vue.prototype.$http })
|
||||
Vue.use(komgaLibraries, { store: store, http: Vue.prototype.$http })
|
||||
Vue.use(actuator, { http: Vue.prototype.$http })
|
||||
|
|
|
|||
17
komga-webui/src/plugins/komga-referential.plugin.ts
Normal file
17
komga-webui/src/plugins/komga-referential.plugin.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import KomgaReferentialService from '@/services/komga-referential.service'
|
||||
import { AxiosInstance } from 'axios'
|
||||
import _Vue from 'vue'
|
||||
|
||||
export default {
|
||||
install (
|
||||
Vue: typeof _Vue,
|
||||
{ http }: { http: AxiosInstance }) {
|
||||
Vue.prototype.$komgaReferential = new KomgaReferentialService(http)
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'vue/types/vue' {
|
||||
interface Vue {
|
||||
$komgaReferential: KomgaReferentialService;
|
||||
}
|
||||
}
|
||||
|
|
@ -95,4 +95,16 @@ export default class KomgaBooksService {
|
|||
throw new Error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async updateMetadata (bookId: number, metadata: BookMetadataUpdateDto): Promise<BookDto> {
|
||||
try {
|
||||
return (await this.http.patch(`${API_BOOKS}/${bookId}/metadata`, metadata)).data
|
||||
} catch (e) {
|
||||
let msg = `An error occurred while trying to update book metadata`
|
||||
if (e.response.data.message) {
|
||||
msg += `: ${e.response.data.message}`
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
komga-webui/src/services/komga-referential.service.ts
Normal file
30
komga-webui/src/services/komga-referential.service.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { AxiosInstance } from 'axios'
|
||||
|
||||
const qs = require('qs')
|
||||
|
||||
export default class KomgaReferentialService {
|
||||
private http: AxiosInstance
|
||||
|
||||
constructor (http: AxiosInstance) {
|
||||
this.http = http
|
||||
}
|
||||
|
||||
async getAuthors (search?: string): Promise<string[]> {
|
||||
try {
|
||||
const params = {} as any
|
||||
if (search) {
|
||||
params.search = search
|
||||
}
|
||||
return (await this.http.get('/api/v1/authors', {
|
||||
params: params,
|
||||
paramsSerializer: params => qs.stringify(params, { indices: false })
|
||||
})).data
|
||||
} catch (e) {
|
||||
let msg = 'An error occurred while trying to retrieve authors'
|
||||
if (e.response.data.message) {
|
||||
msg += `: ${e.response.data.message}`
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
9
komga-webui/src/types/author-roles.ts
Normal file
9
komga-webui/src/types/author-roles.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const authorRoles = [
|
||||
{ role: 'writer', plural: 'writers' },
|
||||
{ role: 'penciller', plural: 'pencillers' },
|
||||
{ role: 'inker', plural: 'inkers' },
|
||||
{ role: 'colorist', plural: 'colorists' },
|
||||
{ role: 'letterer', plural: 'letterers' },
|
||||
{ role: 'cover', plural: 'cover' },
|
||||
{ role: 'editor', plural: 'editors' }
|
||||
]
|
||||
|
|
@ -7,7 +7,8 @@ interface BookDto {
|
|||
lastModified: string,
|
||||
sizeBytes: number,
|
||||
size: string,
|
||||
media: MediaDto
|
||||
media: MediaDto,
|
||||
metadata: BookMetadataDto
|
||||
}
|
||||
|
||||
interface MediaDto {
|
||||
|
|
@ -23,6 +24,55 @@ interface PageDto {
|
|||
mediaType: string
|
||||
}
|
||||
|
||||
interface BookMetadataDto {
|
||||
created: string,
|
||||
lastModified: string,
|
||||
title: string,
|
||||
titleLock: boolean,
|
||||
summary: string,
|
||||
summaryLock: boolean,
|
||||
number: string,
|
||||
numberLock: boolean,
|
||||
numberSort: number,
|
||||
numberSortLock: boolean,
|
||||
readingDirection: string,
|
||||
readingDirectionLock: boolean,
|
||||
publisher: string,
|
||||
publisherLock: boolean,
|
||||
ageRating: number,
|
||||
ageRatingLock: boolean,
|
||||
releaseDate: string,
|
||||
releaseDateLock: boolean,
|
||||
authors: AuthorDto[],
|
||||
authorsLock: boolean,
|
||||
}
|
||||
|
||||
interface BookMetadataUpdateDto {
|
||||
title?: string,
|
||||
titleLock?: boolean,
|
||||
summary?: string,
|
||||
summaryLock?: boolean,
|
||||
number?: string,
|
||||
numberLock?: boolean,
|
||||
numberSort?: number,
|
||||
numberSortLock?: boolean,
|
||||
readingDirection?: string,
|
||||
readingDirectionLock?: boolean,
|
||||
publisher?: string,
|
||||
publisherLock?: boolean,
|
||||
ageRating?: number,
|
||||
ageRatingLock?: boolean,
|
||||
releaseDate?: string,
|
||||
releaseDateLock?: boolean,
|
||||
authors?: AuthorDto[],
|
||||
authorsLock?: boolean,
|
||||
}
|
||||
|
||||
interface AuthorDto {
|
||||
name: string,
|
||||
role: string
|
||||
}
|
||||
|
||||
interface BookFormat {
|
||||
type: string,
|
||||
color: string
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
|
||||
<v-spacer/>
|
||||
|
||||
<v-btn icon @click="dialogEdit = true" v-if="isAdmin">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<!-- Action menu -->
|
||||
<v-menu offset-y v-if="isAdmin">
|
||||
<template v-slot:activator="{ on }">
|
||||
|
|
@ -56,9 +60,48 @@
|
|||
</v-hover>
|
||||
|
||||
</v-col>
|
||||
<v-col cols="8" sm="8" md="auto" lg="auto" xl="auto">
|
||||
<div class="headline">{{ book.name }}</div>
|
||||
<div>#{{ book.number }}</div>
|
||||
|
||||
<v-col cols="8">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<div class="headline">{{ book.metadata.title }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="body-2">
|
||||
<v-col>
|
||||
<span class="mr-3">#{{ book.metadata.number }}</span>
|
||||
<badge v-if="book.metadata.ageRating">{{ book.metadata.ageRating }}+</badge>
|
||||
</v-col>
|
||||
<v-col cols="auto" v-if="book.metadata.releaseDate">
|
||||
{{ book.metadata.releaseDate | moment('MMMM DD, YYYY') }}
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider/>
|
||||
|
||||
<v-row class="body-2" v-if="book.metadata.publisher">
|
||||
<v-col cols="6" sm="4" md="2">PUBLISHER</v-col>
|
||||
<v-col>{{ book.metadata.publisher }}</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="body-2"
|
||||
v-for="(names, key) in authorsByRole"
|
||||
:key="key"
|
||||
>
|
||||
<v-col cols="6" sm="4" md="2" class="py-1 text-uppercase">{{ key }}</v-col>
|
||||
<v-col class="py-1">
|
||||
<span v-for="(name, i) in names"
|
||||
:key="name"
|
||||
>{{ i === 0 ? '' : ', ' }}{{ name }}</span>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-3">
|
||||
<v-col>
|
||||
<div class="body-1">{{ book.metadata.summary }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
|
@ -88,13 +131,6 @@
|
|||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- <v-row>-->
|
||||
<!-- <v-col>-->
|
||||
<!-- <div class="body-1">Description will go here-->
|
||||
<!-- </div>-->
|
||||
<!-- </v-col>-->
|
||||
<!-- </v-row>-->
|
||||
|
||||
<v-row>
|
||||
<v-col cols="2" md="1" lg="1" xl="1" class="body-2">SIZE</v-col>
|
||||
<v-col cols="10" class="body-2">{{ book.size }}</v-col>
|
||||
|
|
@ -114,12 +150,21 @@
|
|||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="book.metadata.readingDirection">
|
||||
<v-col cols="2" md="1" lg="1" xl="1" class="body-2">READING DIRECTION</v-col>
|
||||
<v-col cols="10" class="body-2">{{ $_.capitalize(book.metadata.readingDirection.replace(/_/g, ' ')) }}</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row align="center">
|
||||
<v-col cols="2" md="1" lg="1" xl="1" class="body-2">FILE</v-col>
|
||||
<v-col cols="10" class="body-2">{{ book.url }}</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-container>
|
||||
|
||||
<edit-books-dialog v-model="dialogEdit"
|
||||
:books.sync="book"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -129,14 +174,18 @@ import { getBookFormatFromMediaType } from '@/functions/book-format'
|
|||
import { bookFileUrl, bookThumbnailUrl } from '@/functions/urls'
|
||||
import Vue from 'vue'
|
||||
import { getBookTitleCompact } from '@/functions/book-title'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
import EditBooksDialog from '@/components/EditBooksDialog.vue'
|
||||
import { groupAuthorsByRolePlural } from '@/functions/authors'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'BrowseBook',
|
||||
components: { ToolbarSticky },
|
||||
components: { ToolbarSticky, Badge, EditBooksDialog },
|
||||
data: () => {
|
||||
return {
|
||||
book: {} as BookDto,
|
||||
series: {} as SeriesDto
|
||||
series: {} as SeriesDto,
|
||||
dialogEdit: false
|
||||
}
|
||||
},
|
||||
async created () {
|
||||
|
|
@ -146,7 +195,7 @@ export default Vue.extend({
|
|||
async book (val) {
|
||||
if (this.$_.has(val, 'name')) {
|
||||
this.series = await this.$komgaSeries.getOneSeries(val.seriesId)
|
||||
document.title = `Komga - ${getBookTitleCompact(val.name, this.series.name)}`
|
||||
document.title = `Komga - ${getBookTitleCompact(val.metadata.title, this.series.name)}`
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -175,6 +224,9 @@ export default Vue.extend({
|
|||
},
|
||||
format (): BookFormat {
|
||||
return getBookFormatFromMediaType(this.book.media.mediaType)
|
||||
},
|
||||
authorsByRole (): any {
|
||||
return groupAuthorsByRolePlural(this.book.metadata.authors)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,31 @@
|
|||
/>
|
||||
</toolbar-sticky>
|
||||
|
||||
<v-scroll-y-transition hide-on-leave>
|
||||
<toolbar-sticky v-if="selected.length > 0" :elevation="5" color="white">
|
||||
<v-btn icon @click="selected=[]">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<v-toolbar-title>
|
||||
<span>{{ selected.length }} selected</span>
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer/>
|
||||
|
||||
<v-btn icon @click="dialogEditBooks = true" v-if="isAdmin">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
</toolbar-sticky>
|
||||
</v-scroll-y-transition>
|
||||
|
||||
<edit-books-dialog v-model="dialogEditBooks"
|
||||
:books.sync="selectedBooks"
|
||||
/>
|
||||
|
||||
<edit-books-dialog v-model="dialogEditBookSingle"
|
||||
:books.sync="editBookSingle"
|
||||
/>
|
||||
|
||||
<v-container fluid class="px-6">
|
||||
<v-row>
|
||||
<v-col cols="4" sm="4" md="auto" lg="auto" xl="auto">
|
||||
|
|
@ -69,23 +94,33 @@
|
|||
|
||||
<v-divider class="my-4"/>
|
||||
|
||||
<v-row justify="start" ref="content" v-resize="updateCardWidth">
|
||||
<v-item-group multiple v-model="selected">
|
||||
<v-row justify="start" ref="content" v-resize="updateCardWidth">
|
||||
|
||||
<v-skeleton-loader v-for="(b, i) in books"
|
||||
:key="i"
|
||||
:width="cardWidth"
|
||||
:height="cardWidth / .7071 + 116"
|
||||
justify-self="start"
|
||||
:loading="b === null"
|
||||
type="card, text"
|
||||
class="ma-3 mx-2"
|
||||
v-intersect="onElementIntersect"
|
||||
:data-index="i"
|
||||
>
|
||||
<card-book :book="b" :width="cardWidth"/>
|
||||
</v-skeleton-loader>
|
||||
<v-skeleton-loader v-for="(b, i) in books"
|
||||
:key="i"
|
||||
:width="cardWidth"
|
||||
:height="cardWidth / .7071 + 116"
|
||||
justify-self="start"
|
||||
:loading="b === null"
|
||||
type="card, text"
|
||||
class="ma-3 mx-2"
|
||||
v-intersect="onElementIntersect"
|
||||
:data-index="i"
|
||||
>
|
||||
<v-item v-slot:default="{ active, toggle }" :value="$_.get(b, 'id', 0)">
|
||||
<card-book :book="b"
|
||||
:width="cardWidth"
|
||||
:selected="active"
|
||||
:select="toggle"
|
||||
:preSelect="selected.length > 0"
|
||||
:edit="singleEdit"
|
||||
/>
|
||||
</v-item>
|
||||
</v-skeleton-loader>
|
||||
|
||||
</v-row>
|
||||
</v-row>
|
||||
</v-item-group>
|
||||
</v-container>
|
||||
|
||||
<edit-series-dialog v-model="dialogEdit"
|
||||
|
|
@ -96,7 +131,6 @@
|
|||
<script lang="ts">
|
||||
import Badge from '@/components/Badge.vue'
|
||||
import CardBook from '@/components/CardBook.vue'
|
||||
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
|
||||
import SortMenuButton from '@/components/SortMenuButton.vue'
|
||||
import ToolbarSticky from '@/components/ToolbarSticky.vue'
|
||||
import { computeCardWidth } from '@/functions/grid-utilities'
|
||||
|
|
@ -105,26 +139,33 @@ import { seriesThumbnailUrl } from '@/functions/urls'
|
|||
import VisibleElements from '@/mixins/VisibleElements'
|
||||
import { LoadState } from '@/types/common'
|
||||
import mixins from 'vue-typed-mixins'
|
||||
import EditBooksDialog from '@/components/EditBooksDialog.vue'
|
||||
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
|
||||
|
||||
export default mixins(VisibleElements).extend({
|
||||
name: 'BrowseSeries',
|
||||
components: { CardBook, ToolbarSticky, SortMenuButton, Badge, EditSeriesDialog },
|
||||
components: { CardBook, ToolbarSticky, SortMenuButton, Badge, EditSeriesDialog, EditBooksDialog },
|
||||
data: () => {
|
||||
return {
|
||||
series: {} as SeriesDto,
|
||||
books: [] as BookDto[],
|
||||
selectedBooks: [] as BookDto[],
|
||||
editBookSingle: {} as BookDto,
|
||||
pagesState: [] as LoadState[],
|
||||
pageSize: 20,
|
||||
totalElements: null as number | null,
|
||||
sortOptions: [{ name: 'Number', key: 'number' }, { name: 'Date added', key: 'createdDate' }, {
|
||||
sortOptions: [{ name: 'Number', key: 'metadata.numberSort' }, { name: 'Date added', key: 'createdDate' }, {
|
||||
name: 'File size',
|
||||
key: 'fileSize'
|
||||
}] as SortOption[],
|
||||
sortActive: {} as SortActive,
|
||||
sortDefault: { key: 'number', order: 'asc' } as SortActive,
|
||||
sortDefault: { key: 'metadata.numberSort', order: 'asc' } as SortActive,
|
||||
cardWidth: 150,
|
||||
dialogEdit: false,
|
||||
sortUnwatch: null as any
|
||||
sortUnwatch: null as any,
|
||||
selected: [],
|
||||
dialogEditBooks: false,
|
||||
dialogEditBookSingle: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -164,6 +205,24 @@ export default mixins(VisibleElements).extend({
|
|||
if (this.$_.has(val, 'name')) {
|
||||
document.title = `Komga - ${val.name}`
|
||||
}
|
||||
},
|
||||
selected (val: number[]) {
|
||||
this.selectedBooks = val.map(id => this.books.find(s => s.id === id))
|
||||
.filter(x => x !== undefined) as BookDto[]
|
||||
},
|
||||
selectedBooks (val: BookDto[]) {
|
||||
val.forEach(s => {
|
||||
const index = this.books.findIndex(x => x.id === s.id)
|
||||
if (index !== -1) {
|
||||
this.books.splice(index, 1, s)
|
||||
}
|
||||
})
|
||||
},
|
||||
editBookSingle (val: BookDto) {
|
||||
const index = this.books.findIndex(x => x.id === val.id)
|
||||
if (index !== -1) {
|
||||
this.books.splice(index, 1, val)
|
||||
}
|
||||
}
|
||||
},
|
||||
async created () {
|
||||
|
|
@ -270,6 +329,10 @@ export default mixins(VisibleElements).extend({
|
|||
},
|
||||
analyze () {
|
||||
this.$komgaSeries.analyzeSeries(this.series)
|
||||
},
|
||||
singleEdit (book: BookDto) {
|
||||
this.editBookSingle = book
|
||||
this.dialogEditBookSingle = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
<template>
|
||||
<div class="ma-3">
|
||||
|
||||
<edit-series-dialog v-model="dialogEditSingle"
|
||||
<edit-series-dialog v-model="dialogEditSeriesSingle"
|
||||
:series.sync="editSeriesSingle"
|
||||
/>
|
||||
|
||||
<edit-books-dialog v-model="dialogEditBookSingle"
|
||||
:books.sync="editBookSingle"
|
||||
/>
|
||||
|
||||
<horizontal-scroller>
|
||||
<template v-slot:prepend>
|
||||
<div class="title">Recently Added Series</div>
|
||||
|
|
@ -22,7 +26,7 @@
|
|||
<card-series v-else
|
||||
:series="s"
|
||||
class="ma-2 card"
|
||||
:edit="singleEdit"
|
||||
:edit="singleEditSeries"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -47,7 +51,7 @@
|
|||
<card-series v-else
|
||||
:series="s"
|
||||
class="ma-2 card"
|
||||
:edit="singleEdit"
|
||||
:edit="singleEditSeries"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -73,6 +77,7 @@
|
|||
<card-book v-else
|
||||
:book="b"
|
||||
class="ma-2 card"
|
||||
:edit="singleEditBook"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -87,10 +92,11 @@ import CardSeries from '@/components/CardSeries.vue'
|
|||
import HorizontalScroller from '@/components/HorizontalScroller.vue'
|
||||
import Vue from 'vue'
|
||||
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
|
||||
import EditBooksDialog from '@/components/EditBooksDialog.vue'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'Dashboard',
|
||||
components: { CardSeries, CardBook, HorizontalScroller, EditSeriesDialog },
|
||||
components: { CardSeries, CardBook, HorizontalScroller, EditSeriesDialog, EditBooksDialog },
|
||||
data: () => {
|
||||
const pageSize = 20
|
||||
return {
|
||||
|
|
@ -99,7 +105,9 @@ export default Vue.extend({
|
|||
books: Array(pageSize).fill(null) as BookDto[],
|
||||
pageSize: pageSize,
|
||||
editSeriesSingle: {} as SeriesDto,
|
||||
dialogEditSingle: false
|
||||
dialogEditSeriesSingle: false,
|
||||
editBookSingle: {} as BookDto,
|
||||
dialogEditBookSingle: false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
|
|
@ -121,6 +129,12 @@ export default Vue.extend({
|
|||
if (index !== -1) {
|
||||
this.updatedSeries.splice(index, 1, val)
|
||||
}
|
||||
},
|
||||
editBookSingle (val: BookDto) {
|
||||
let index = this.books.findIndex(x => x.id === val.id)
|
||||
if (index !== -1) {
|
||||
this.books.splice(index, 1, val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -138,9 +152,13 @@ export default Vue.extend({
|
|||
|
||||
this.books = (await this.$komgaBooks.getBooks(undefined, pageRequest)).content
|
||||
},
|
||||
singleEdit (series: SeriesDto) {
|
||||
singleEditSeries (series: SeriesDto) {
|
||||
this.editSeriesSingle = series
|
||||
this.dialogEditSingle = true
|
||||
this.dialogEditSeriesSingle = true
|
||||
},
|
||||
singleEditBook (book: BookDto) {
|
||||
this.editBookSingle = book
|
||||
this.dialogEditBookSingle = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package db.migration
|
||||
|
||||
import org.flywaydb.core.api.migration.BaseJavaMigration
|
||||
import org.flywaydb.core.api.migration.Context
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.jdbc.datasource.SingleConnectionDataSource
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class V20200306175848__create_book_metadata_from_book : BaseJavaMigration() {
|
||||
override fun migrate(context: Context) {
|
||||
val jdbcTemplate = JdbcTemplate(SingleConnectionDataSource(context.connection, true))
|
||||
|
||||
val books = jdbcTemplate.queryForList("SELECT id, name, number FROM book")
|
||||
|
||||
val now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss"))
|
||||
books.forEach { book ->
|
||||
val metadataId = jdbcTemplate.queryForObject("SELECT NEXTVAL('hibernate_sequence')", Int::class.java)
|
||||
jdbcTemplate.execute("INSERT INTO book_metadata (ID, CREATED_DATE, LAST_MODIFIED_DATE, TITLE, NUMBER, NUMBER_SORT) VALUES ($metadataId, '$now', '$now', '${book["name"]}', '${book["number"]}', '${book["number"]}')")
|
||||
jdbcTemplate.execute("UPDATE book SET metadata_id = $metadataId WHERE id = ${book["id"]}")
|
||||
}
|
||||
|
||||
jdbcTemplate.execute("alter table book alter column metadata_id set not null")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Embeddable
|
||||
import javax.validation.constraints.NotBlank
|
||||
|
||||
@Embeddable
|
||||
class Author {
|
||||
constructor(name: String, role: String) {
|
||||
this.name = name
|
||||
this.role = role
|
||||
}
|
||||
|
||||
@NotBlank
|
||||
@Column(name = "name", nullable = false)
|
||||
var name: String
|
||||
set(value) {
|
||||
require(value.isNotBlank()) { "name must not be blank" }
|
||||
field = value.trim()
|
||||
}
|
||||
|
||||
@NotBlank
|
||||
@Column(name = "role", nullable = false)
|
||||
var role: String
|
||||
set(value) {
|
||||
require(value.isNotBlank()) { "role must not be blank" }
|
||||
field = value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -27,18 +27,18 @@ import javax.validation.constraints.NotNull
|
|||
@Cacheable
|
||||
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.book")
|
||||
class Book(
|
||||
@NotBlank
|
||||
@Column(name = "name", nullable = false)
|
||||
var name: String,
|
||||
@NotBlank
|
||||
@Column(name = "name", nullable = false)
|
||||
var name: String,
|
||||
|
||||
@Column(name = "url", nullable = false)
|
||||
var url: URL,
|
||||
@Column(name = "url", nullable = false)
|
||||
var url: URL,
|
||||
|
||||
@Column(name = "file_last_modified", nullable = false)
|
||||
var fileLastModified: LocalDateTime,
|
||||
@Column(name = "file_last_modified", nullable = false)
|
||||
var fileLastModified: LocalDateTime,
|
||||
|
||||
@Column(name = "file_size", nullable = false)
|
||||
var fileSize: Long = 0
|
||||
@Column(name = "file_size", nullable = false)
|
||||
var fileSize: Long = 0
|
||||
) : AuditableEntity() {
|
||||
@Id
|
||||
@GeneratedValue
|
||||
|
|
@ -56,6 +56,20 @@ class Book(
|
|||
|
||||
@Column(name = "number", nullable = false)
|
||||
var number: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
if (!metadata.numberLock) metadata.number = value.toString()
|
||||
if (!metadata.numberSortLock) metadata.numberSort = value.toFloat()
|
||||
}
|
||||
|
||||
@OneToOne(optional = false, orphanRemoval = true, cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "metadata_id", nullable = false)
|
||||
var metadata: BookMetadata =
|
||||
BookMetadata(
|
||||
title = name,
|
||||
number = number.toString(),
|
||||
numberSort = number.toFloat()
|
||||
)
|
||||
|
||||
fun fileName(): String = FilenameUtils.getName(url.toString())
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
import org.hibernate.annotations.Cache
|
||||
import org.hibernate.annotations.CacheConcurrencyStrategy
|
||||
import java.time.LocalDate
|
||||
import javax.persistence.Cacheable
|
||||
import javax.persistence.CollectionTable
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.ElementCollection
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.GeneratedValue
|
||||
import javax.persistence.Id
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.Table
|
||||
import javax.validation.constraints.NotBlank
|
||||
import javax.validation.constraints.PositiveOrZero
|
||||
|
||||
@Entity
|
||||
@Table(name = "book_metadata")
|
||||
@Cacheable
|
||||
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.book_metadata")
|
||||
class BookMetadata : AuditableEntity {
|
||||
constructor(
|
||||
title: String,
|
||||
summary: String = "",
|
||||
number: String,
|
||||
numberSort: Float,
|
||||
readingDirection: ReadingDirection? = null,
|
||||
publisher: String = "",
|
||||
ageRating: Int? = null,
|
||||
releaseDate: LocalDate? = null,
|
||||
authors: MutableList<Author> = mutableListOf()
|
||||
) : super() {
|
||||
this.title = title
|
||||
this.summary = summary
|
||||
this.number = number
|
||||
this.numberSort = numberSort
|
||||
this.readingDirection = readingDirection
|
||||
this.publisher = publisher
|
||||
this.ageRating = ageRating
|
||||
this.releaseDate = releaseDate
|
||||
this.authors = authors
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue
|
||||
@Column(name = "id", nullable = false, unique = true)
|
||||
val id: Long = 0
|
||||
|
||||
@NotBlank
|
||||
@Column(name = "title", nullable = false)
|
||||
var title: String
|
||||
set(value) {
|
||||
require(value.isNotBlank()) { "title must not be blank" }
|
||||
field = value.trim()
|
||||
}
|
||||
|
||||
@Column(name = "summary", nullable = false)
|
||||
var summary: String
|
||||
set(value) {
|
||||
field = value.trim()
|
||||
}
|
||||
|
||||
@NotBlank
|
||||
@Column(name = "number", nullable = false)
|
||||
var number: String
|
||||
set(value) {
|
||||
require(value.isNotBlank()) { "number must not be blank" }
|
||||
field = value.trim()
|
||||
}
|
||||
|
||||
@Column(name = "number_sort", nullable = false, columnDefinition = "REAL")
|
||||
var numberSort: Float
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "reading_direction", nullable = true)
|
||||
var readingDirection: ReadingDirection?
|
||||
|
||||
@Column(name = "publisher", nullable = false)
|
||||
var publisher: String
|
||||
set(value) {
|
||||
field = value.trim()
|
||||
}
|
||||
|
||||
@PositiveOrZero
|
||||
@Column(name = "age_rating", nullable = true)
|
||||
var ageRating: Int?
|
||||
|
||||
@Column(name = "release_date", nullable = true)
|
||||
var releaseDate: LocalDate?
|
||||
|
||||
@ElementCollection(fetch = FetchType.LAZY)
|
||||
@CollectionTable(name = "book_metadata_author", joinColumns = [JoinColumn(name = "book_metadata_id")])
|
||||
var authors: MutableList<Author>
|
||||
|
||||
|
||||
@Column(name = "title_lock", nullable = false)
|
||||
var titleLock: Boolean = false
|
||||
|
||||
@Column(name = "summary_lock", nullable = false)
|
||||
var summaryLock: Boolean = false
|
||||
|
||||
@Column(name = "number_lock", nullable = false)
|
||||
var numberLock: Boolean = false
|
||||
|
||||
@Column(name = "number_sort_lock", nullable = false)
|
||||
var numberSortLock: Boolean = false
|
||||
|
||||
@Column(name = "reading_direction_lock", nullable = false)
|
||||
var readingDirectionLock: Boolean = false
|
||||
|
||||
@Column(name = "publisher_lock", nullable = false)
|
||||
var publisherLock: Boolean = false
|
||||
|
||||
@Column(name = "age_rating_lock", nullable = false)
|
||||
var ageRatingLock: Boolean = false
|
||||
|
||||
@Column(name = "release_date_lock", nullable = false)
|
||||
var releaseDateLock: Boolean = false
|
||||
|
||||
@Column(name = "authors_lock", nullable = false)
|
||||
var authorsLock: Boolean = false
|
||||
|
||||
enum class ReadingDirection {
|
||||
LEFT_TO_RIGHT,
|
||||
RIGHT_TO_LEFT,
|
||||
VERTICAL,
|
||||
WEBTOON
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package org.gotson.komga.domain.persistence
|
||||
|
||||
import org.gotson.komga.domain.model.BookMetadata
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface BookMetadataRepository : JpaRepository<BookMetadata, Long> {
|
||||
@Query(
|
||||
value = "select distinct a.name from BOOK_METADATA_AUTHOR a where a.name ilike CONCAT('%', :search, '%') order by a.name",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun findAuthorsByName(@Param("search") search: String): List<String>
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import com.github.klinq.jpaspec.toJoin
|
|||
import mu.KotlinLogging
|
||||
import org.gotson.komga.application.service.AsyncOrchestrator
|
||||
import org.gotson.komga.application.service.BookLifecycle
|
||||
import org.gotson.komga.domain.model.Author
|
||||
import org.gotson.komga.domain.model.Book
|
||||
import org.gotson.komga.domain.model.ImageConversionException
|
||||
import org.gotson.komga.domain.model.Library
|
||||
|
|
@ -16,6 +17,7 @@ import org.gotson.komga.domain.persistence.BookRepository
|
|||
import org.gotson.komga.infrastructure.image.ImageType
|
||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||
import org.gotson.komga.interfaces.rest.dto.BookDto
|
||||
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
|
||||
import org.gotson.komga.interfaces.rest.dto.PageDto
|
||||
import org.gotson.komga.interfaces.rest.dto.toDto
|
||||
import org.springframework.data.domain.Page
|
||||
|
|
@ -33,8 +35,10 @@ import org.springframework.http.ResponseEntity
|
|||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PatchMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
|
|
@ -48,6 +52,7 @@ import java.time.ZoneOffset
|
|||
import java.util.concurrent.RejectedExecutionException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.persistence.criteria.JoinType
|
||||
import javax.validation.Valid
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
|
|
@ -70,8 +75,8 @@ class BookController(
|
|||
val pageRequest = PageRequest.of(
|
||||
page.pageNumber,
|
||||
page.pageSize,
|
||||
if (page.sort.isSorted) page.sort
|
||||
else Sort.by(Sort.Order.asc("name").ignoreCase())
|
||||
if (page.sort.isSorted) Sort.by(page.sort.map { it.ignoreCase() }.toList())
|
||||
else Sort.by(Sort.Order.asc("metadata.title").ignoreCase())
|
||||
)
|
||||
|
||||
return mutableListOf<Specification<Book>>().let { specs ->
|
||||
|
|
@ -329,6 +334,42 @@ class BookController(
|
|||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
@PatchMapping("api/v1/books/{bookId}/metadata")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
fun updateMetadata(
|
||||
@PathVariable bookId: Long,
|
||||
@Valid @RequestBody newMetadata: BookMetadataUpdateDto
|
||||
): BookDto =
|
||||
bookRepository.findByIdOrNull(bookId)?.let { book ->
|
||||
with(newMetadata) {
|
||||
title?.let { book.metadata.title = it }
|
||||
titleLock?.let { book.metadata.titleLock = it }
|
||||
summary?.let { book.metadata.summary = it }
|
||||
summaryLock?.let { book.metadata.summaryLock = it }
|
||||
number?.let { book.metadata.number = it }
|
||||
numberLock?.let { book.metadata.numberLock = it }
|
||||
numberSort?.let { book.metadata.numberSort = it }
|
||||
numberSortLock?.let { book.metadata.numberSortLock = it }
|
||||
if (isSet("readingDirection")) book.metadata.readingDirection = newMetadata.readingDirection
|
||||
readingDirectionLock?.let { book.metadata.readingDirectionLock = it }
|
||||
publisher?.let { book.metadata.publisher = it }
|
||||
publisherLock?.let { book.metadata.publisherLock = it }
|
||||
if (isSet("ageRating")) book.metadata.ageRating = newMetadata.ageRating
|
||||
ageRatingLock?.let { book.metadata.ageRatingLock = it }
|
||||
if (isSet("releaseDate")) {
|
||||
book.metadata.releaseDate = newMetadata.releaseDate
|
||||
}
|
||||
releaseDateLock?.let { book.metadata.releaseDateLock = it }
|
||||
if (authors != null) {
|
||||
book.metadata.authors = authors!!.map {
|
||||
Author(it.name ?: "", it.role ?: "")
|
||||
}.toMutableList()
|
||||
} else book.metadata.authors = mutableListOf()
|
||||
authorsLock?.let { book.metadata.authorsLock = it }
|
||||
}
|
||||
bookRepository.save(book).toDto(includeFullUrl = true)
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
private fun ResponseEntity.BodyBuilder.setNotModified(book: Book) =
|
||||
this.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS)
|
||||
.cachePrivate()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
package org.gotson.komga.interfaces.rest
|
||||
|
||||
import org.gotson.komga.domain.persistence.BookMetadataRepository
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
|
||||
@RestController
|
||||
@RequestMapping("api/v1", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
class ReferentialController(
|
||||
private val bookMetadataRepository: BookMetadataRepository
|
||||
) {
|
||||
|
||||
@GetMapping("/authors")
|
||||
fun getAuthors(
|
||||
@RequestParam(name = "search", defaultValue = "") search: String
|
||||
): List<String> =
|
||||
bookMetadataRepository.findAuthorsByName(search)
|
||||
}
|
||||
|
|
@ -196,8 +196,8 @@ class SeriesController(
|
|||
val pageRequest = PageRequest.of(
|
||||
page.pageNumber,
|
||||
page.pageSize,
|
||||
if (page.sort.isSorted) page.sort
|
||||
else Sort.by(Sort.Order.asc("number"))
|
||||
if (page.sort.isSorted) Sort.by(page.sort.map { it.ignoreCase() }.toList())
|
||||
else Sort.by(Sort.Order.asc("metadata.numberSort"))
|
||||
)
|
||||
|
||||
return (if (!mediaStatus.isNullOrEmpty())
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ package org.gotson.komga.interfaces.rest.dto
|
|||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat
|
||||
import org.apache.commons.io.FilenameUtils
|
||||
import org.gotson.komga.domain.model.Author
|
||||
import org.gotson.komga.domain.model.Book
|
||||
import org.gotson.komga.domain.model.BookMetadata
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class BookDto(
|
||||
|
|
@ -19,7 +23,8 @@ data class BookDto(
|
|||
val fileLastModified: LocalDateTime,
|
||||
val sizeBytes: Long,
|
||||
val size: String,
|
||||
val media: MediaDto
|
||||
val media: MediaDto,
|
||||
val metadata: BookMetadataDto
|
||||
)
|
||||
|
||||
data class MediaDto(
|
||||
|
|
@ -29,6 +34,33 @@ data class MediaDto(
|
|||
val comment: String
|
||||
)
|
||||
|
||||
data class BookMetadataDto(
|
||||
val title: String,
|
||||
val titleLock: Boolean,
|
||||
val summary: String,
|
||||
val summaryLock: Boolean,
|
||||
val number: String,
|
||||
val numberLock: Boolean,
|
||||
val numberSort: Float,
|
||||
val numberSortLock: Boolean,
|
||||
val readingDirection: String,
|
||||
val readingDirectionLock: Boolean,
|
||||
val publisher: String,
|
||||
val publisherLock: Boolean,
|
||||
val ageRating: Int?,
|
||||
val ageRatingLock: Boolean,
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
val releaseDate: LocalDate?,
|
||||
val releaseDateLock: Boolean,
|
||||
val authors: List<AuthorDto>,
|
||||
val authorsLock: Boolean
|
||||
)
|
||||
|
||||
data class AuthorDto(
|
||||
val name: String,
|
||||
val role: String
|
||||
)
|
||||
|
||||
fun Book.toDto(includeFullUrl: Boolean) =
|
||||
BookDto(
|
||||
id = id,
|
||||
|
|
@ -41,10 +73,36 @@ fun Book.toDto(includeFullUrl: Boolean) =
|
|||
fileLastModified = fileLastModified.toUTC(),
|
||||
sizeBytes = fileSize,
|
||||
size = fileSizeHumanReadable(),
|
||||
media = MediaDto(
|
||||
status = media.status.toString(),
|
||||
mediaType = media.mediaType ?: "",
|
||||
pagesCount = media.pages.size,
|
||||
comment = media.comment ?: ""
|
||||
)
|
||||
media = media.toDto(),
|
||||
metadata = metadata.toDto()
|
||||
)
|
||||
|
||||
fun Media.toDto() = MediaDto(
|
||||
status = status.toString(),
|
||||
mediaType = mediaType ?: "",
|
||||
pagesCount = pages.size,
|
||||
comment = comment ?: ""
|
||||
)
|
||||
|
||||
fun BookMetadata.toDto() = BookMetadataDto(
|
||||
title = title,
|
||||
titleLock = titleLock,
|
||||
summary = summary,
|
||||
summaryLock = summaryLock,
|
||||
number = number,
|
||||
numberLock = numberLock,
|
||||
numberSort = numberSort,
|
||||
numberSortLock = numberSortLock,
|
||||
readingDirection = readingDirection?.name ?: "",
|
||||
readingDirectionLock = readingDirectionLock,
|
||||
publisher = publisher,
|
||||
publisherLock = publisherLock,
|
||||
ageRating = ageRating,
|
||||
ageRatingLock = ageRatingLock,
|
||||
releaseDate = releaseDate,
|
||||
releaseDateLock = releaseDateLock,
|
||||
authors = authors.map { it.toDto() },
|
||||
authorsLock = authorsLock
|
||||
)
|
||||
|
||||
fun Author.toDto() = AuthorDto(name = name, role = role)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
package org.gotson.komga.interfaces.rest.dto
|
||||
|
||||
import org.gotson.komga.domain.model.BookMetadata
|
||||
import org.gotson.komga.infrastructure.validation.NullOrNotBlank
|
||||
import java.time.LocalDate
|
||||
import javax.validation.Valid
|
||||
import javax.validation.constraints.NotBlank
|
||||
import javax.validation.constraints.PositiveOrZero
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
class BookMetadataUpdateDto {
|
||||
private val isSet = mutableMapOf<String, Boolean>()
|
||||
fun isSet(prop: String) = isSet.getOrDefault(prop, false)
|
||||
|
||||
@get:NullOrNotBlank
|
||||
var title: String? = null
|
||||
|
||||
var titleLock: Boolean? = null
|
||||
|
||||
var summary: String? = null
|
||||
|
||||
var summaryLock: Boolean? = null
|
||||
|
||||
@get:NullOrNotBlank
|
||||
var number: String? = null
|
||||
|
||||
var numberLock: Boolean? = null
|
||||
|
||||
var numberSort: Float? = null
|
||||
|
||||
var numberSortLock: Boolean? = null
|
||||
|
||||
var readingDirection: BookMetadata.ReadingDirection?
|
||||
by Delegates.observable<BookMetadata.ReadingDirection?>(null) { prop, _, _ ->
|
||||
isSet[prop.name] = true
|
||||
}
|
||||
|
||||
var readingDirectionLock: Boolean? = null
|
||||
|
||||
var publisher: String? = null
|
||||
|
||||
var publisherLock: Boolean? = null
|
||||
|
||||
@get:PositiveOrZero
|
||||
var ageRating: Int?
|
||||
by Delegates.observable<Int?>(null) { prop, _, _ ->
|
||||
isSet[prop.name] = true
|
||||
}
|
||||
|
||||
var ageRatingLock: Boolean? = null
|
||||
|
||||
var releaseDate: LocalDate?
|
||||
by Delegates.observable<LocalDate?>(null) { prop, _, _ ->
|
||||
isSet[prop.name] = true
|
||||
}
|
||||
|
||||
var releaseDateLock: Boolean? = null
|
||||
|
||||
@Valid
|
||||
var authors: List<AuthorUpdateDto>? = null
|
||||
|
||||
var authorsLock: Boolean? = null
|
||||
}
|
||||
|
||||
class AuthorUpdateDto {
|
||||
@get:NotBlank
|
||||
val name: String? = null
|
||||
|
||||
@get:NotBlank
|
||||
val role: String? = null
|
||||
}
|
||||
|
|
@ -76,6 +76,17 @@ caffeine.jcache {
|
|||
}
|
||||
}
|
||||
|
||||
cache.book_metadata {
|
||||
monitoring {
|
||||
statistics = true
|
||||
}
|
||||
policy {
|
||||
maximum {
|
||||
size = 500
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default-update-timestamps-region {
|
||||
monitoring {
|
||||
statistics = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
create table book_metadata
|
||||
(
|
||||
id bigint not null,
|
||||
created_date timestamp not null,
|
||||
last_modified_date timestamp not null,
|
||||
age_rating integer,
|
||||
age_rating_lock boolean not null default false,
|
||||
number varchar not null,
|
||||
number_lock boolean not null default false,
|
||||
number_sort float4 not null,
|
||||
number_sort_lock boolean not null default false,
|
||||
publisher varchar not null default '',
|
||||
publisher_lock boolean not null default false,
|
||||
reading_direction varchar,
|
||||
reading_direction_lock boolean not null default false,
|
||||
release_date date,
|
||||
release_date_lock boolean not null default false,
|
||||
summary varchar not null default '',
|
||||
summary_lock boolean not null default false,
|
||||
title varchar not null,
|
||||
title_lock boolean not null default false,
|
||||
authors_lock boolean not null default false,
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
create table book_metadata_author
|
||||
(
|
||||
book_metadata_id bigint not null,
|
||||
name varchar not null,
|
||||
role varchar not null
|
||||
);
|
||||
|
||||
alter table book
|
||||
add (metadata_id bigint);
|
||||
|
||||
alter table book_metadata_author
|
||||
add constraint fk_book_metadata_author_book_metadata_id foreign key (book_metadata_id) references book_metadata (id);
|
||||
|
||||
alter table book
|
||||
add constraint fk_book_book__metadata_metadata_id foreign key (metadata_id) references book_metadata (id);
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.catchThrowable
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AuthorTest {
|
||||
@Test
|
||||
fun `given blank parameters when creating object then IllegalArgumentException is thrown`() {
|
||||
val blankName = catchThrowable { Author(name = "", role = "role") }
|
||||
val blankRole = catchThrowable { Author(name = "name", role = "") }
|
||||
|
||||
assertThat(blankName).isInstanceOf(IllegalArgumentException::class.java)
|
||||
assertThat(blankRole).isInstanceOf(IllegalArgumentException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given blank parameters when setting fields then IllegalArgumentException is thrown`() {
|
||||
val author = Author(name = "name", role = "role")
|
||||
|
||||
val blankName = catchThrowable { author.name = " " }
|
||||
val blankRole = catchThrowable { author.role = " " }
|
||||
|
||||
assertThat(blankName).isInstanceOf(IllegalArgumentException::class.java)
|
||||
assertThat(blankRole).isInstanceOf(IllegalArgumentException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given untrimmed parameters when creating object then fields are trimmed and role is lowercase`() {
|
||||
val author = Author(name = " name ", role = " Role ")
|
||||
|
||||
assertThat(author.name).isEqualTo("name")
|
||||
assertThat(author.role).isEqualTo("role")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given untrimmed parameters when setting fields then fields are trimmed and role is lowercase`() {
|
||||
val author = Author(name = "name", role = "role")
|
||||
|
||||
author.name = " setName "
|
||||
author.role = " set Role "
|
||||
|
||||
assertThat(author.name).isEqualTo("setName")
|
||||
assertThat(author.role).isEqualTo("set role")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.catchThrowable
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BookMetadataTest {
|
||||
@Test
|
||||
fun `given blank parameters when creating object then IllegalArgumentException is thrown`() {
|
||||
val blankTitle = catchThrowable { BookMetadata(title = "", number = "1", numberSort = 1F) }
|
||||
val blankNumber = catchThrowable { BookMetadata(title = "title", number = "", numberSort = 1F) }
|
||||
|
||||
assertThat(blankTitle).isInstanceOf(IllegalArgumentException::class.java)
|
||||
assertThat(blankNumber).isInstanceOf(IllegalArgumentException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given blank parameters when setting fields then IllegalArgumentException is thrown`() {
|
||||
val metadata = BookMetadata(title = "title", number = "1", numberSort = 1F)
|
||||
|
||||
val blankTitle = catchThrowable { metadata.title = "" }
|
||||
val blankNumber = catchThrowable { metadata.number = "" }
|
||||
|
||||
|
||||
assertThat(blankTitle).isInstanceOf(IllegalArgumentException::class.java)
|
||||
assertThat(blankNumber).isInstanceOf(IllegalArgumentException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given untrimmed parameters when creating object then fields are trimmed`() {
|
||||
val metadata = BookMetadata(title = " title ", number = " number ", numberSort = 1F)
|
||||
|
||||
assertThat(metadata.title).isEqualTo("title")
|
||||
assertThat(metadata.number).isEqualTo("number")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given untrimmed parameters when setting fields then fields are trimmed`() {
|
||||
val metadata = BookMetadata(title = "title", number = "number", numberSort = 1F)
|
||||
|
||||
metadata.title = " setTitle "
|
||||
metadata.number = " setNumber "
|
||||
|
||||
assertThat(metadata.title).isEqualTo("setTitle")
|
||||
assertThat(metadata.number).isEqualTo("setNumber")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
package org.gotson.komga.interfaces.rest
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.tuple
|
||||
import org.gotson.komga.domain.model.Author
|
||||
import org.gotson.komga.domain.model.BookMetadata
|
||||
import org.gotson.komga.domain.model.BookPage
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.UserRoles
|
||||
import org.gotson.komga.domain.model.makeBook
|
||||
import org.gotson.komga.domain.model.makeLibrary
|
||||
import org.gotson.komga.domain.model.makeSeries
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.SeriesRepository
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
|
|
@ -21,11 +26,16 @@ import org.springframework.beans.factory.annotation.Autowired
|
|||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.MockMvcResultMatchersDsl
|
||||
import org.springframework.test.web.servlet.get
|
||||
import org.springframework.test.web.servlet.patch
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import javax.sql.DataSource
|
||||
|
|
@ -35,9 +45,10 @@ import javax.sql.DataSource
|
|||
@AutoConfigureTestDatabase
|
||||
@AutoConfigureMockMvc(printOnlyOnFailure = false)
|
||||
class BookControllerTest(
|
||||
@Autowired private val seriesRepository: SeriesRepository,
|
||||
@Autowired private val libraryRepository: LibraryRepository,
|
||||
@Autowired private val mockMvc: MockMvc
|
||||
@Autowired private val seriesRepository: SeriesRepository,
|
||||
@Autowired private val libraryRepository: LibraryRepository,
|
||||
@Autowired private val bookRepository: BookRepository,
|
||||
@Autowired private val mockMvc: MockMvc
|
||||
) {
|
||||
|
||||
lateinit var jdbcTemplate: JdbcTemplate
|
||||
|
|
@ -72,8 +83,8 @@ class BookControllerTest(
|
|||
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [1])
|
||||
fun `given user with access to a single library when getting books then only gets books from this library`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
|
||||
|
|
@ -81,17 +92,17 @@ class BookControllerTest(
|
|||
libraryRepository.save(otherLibrary)
|
||||
|
||||
val otherSeries = makeSeries(
|
||||
name = "otherSeries",
|
||||
books = listOf(makeBook("2"))
|
||||
name = "otherSeries",
|
||||
books = listOf(makeBook("2"))
|
||||
).also { it.library = otherLibrary }
|
||||
seriesRepository.save(otherSeries)
|
||||
|
||||
mockMvc.get("/api/v1/books")
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.content.length()") { value(1) }
|
||||
jsonPath("$.content[0].name") { value("1") }
|
||||
}
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.content.length()") { value(1) }
|
||||
jsonPath("$.content[0].name") { value("1") }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -102,70 +113,70 @@ class BookControllerTest(
|
|||
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [])
|
||||
fun `given user with no access to any library when getting specific book then returns unauthorized`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
val book = series.books.first()
|
||||
|
||||
mockMvc.get("/api/v1/books/${book.id}")
|
||||
.andExpect { status { isUnauthorized } }
|
||||
.andExpect { status { isUnauthorized } }
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [])
|
||||
fun `given user with no access to any library when getting specific book thumbnail then returns unauthorized`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
val book = series.books.first()
|
||||
|
||||
mockMvc.get("/api/v1/books/${book.id}/thumbnail")
|
||||
.andExpect { status { isUnauthorized } }
|
||||
.andExpect { status { isUnauthorized } }
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [])
|
||||
fun `given user with no access to any library when getting specific book file then returns unauthorized`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
val book = series.books.first()
|
||||
|
||||
mockMvc.get("/api/v1/books/${book.id}/file")
|
||||
.andExpect { status { isUnauthorized } }
|
||||
.andExpect { status { isUnauthorized } }
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [])
|
||||
fun `given user with no access to any library when getting specific book pages then returns unauthorized`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
val book = series.books.first()
|
||||
|
||||
mockMvc.get("/api/v1/books/${book.id}/pages")
|
||||
.andExpect { status { isUnauthorized } }
|
||||
.andExpect { status { isUnauthorized } }
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [])
|
||||
fun `given user with no access to any library when getting specific book page then returns unauthorized`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
val book = series.books.first()
|
||||
|
||||
mockMvc.get("/api/v1/books/${book.id}/pages/1")
|
||||
.andExpect { status { isUnauthorized } }
|
||||
.andExpect { status { isUnauthorized } }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,28 +186,28 @@ class BookControllerTest(
|
|||
@WithMockCustomUser
|
||||
fun `given book without thumbnail when getting book thumbnail then returns not found`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
val book = series.books.first()
|
||||
|
||||
mockMvc.get("/api/v1/books/${book.id}/thumbnail")
|
||||
.andExpect { status { isNotFound } }
|
||||
.andExpect { status { isNotFound } }
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser
|
||||
fun `given book without file when getting book file then returns not found`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
name = "series",
|
||||
books = listOf(makeBook("1"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
val book = series.books.first()
|
||||
|
||||
mockMvc.get("/api/v1/books/${book.id}/file")
|
||||
.andExpect { status { isNotFound } }
|
||||
.andExpect { status { isNotFound } }
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
|
|
@ -204,14 +215,14 @@ class BookControllerTest(
|
|||
@WithMockCustomUser
|
||||
fun `given book with media status not ready when getting book pages then returns not found`(status: Media.Status) {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1").also { it.media.status = status })
|
||||
name = "series",
|
||||
books = listOf(makeBook("1").also { it.media.status = status })
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
val book = series.books.first()
|
||||
|
||||
mockMvc.get("/api/v1/books/${book.id}/pages")
|
||||
.andExpect { status { isNotFound } }
|
||||
.andExpect { status { isNotFound } }
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
|
|
@ -219,14 +230,14 @@ class BookControllerTest(
|
|||
@WithMockCustomUser
|
||||
fun `given book with media status not ready when getting specific book page then returns not found`(status: Media.Status) {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1").also { it.media.status = status })
|
||||
name = "series",
|
||||
books = listOf(makeBook("1").also { it.media.status = status })
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
val book = series.books.first()
|
||||
|
||||
mockMvc.get("/api/v1/books/${book.id}/pages/1")
|
||||
.andExpect { status { isNotFound } }
|
||||
.andExpect { status { isNotFound } }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,17 +246,17 @@ class BookControllerTest(
|
|||
@WithMockCustomUser
|
||||
fun `given book with pages when getting non-existent page then returns bad request`(page: String) {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1").also {
|
||||
it.media.pages = listOf(BookPage("file", "image/jpeg"))
|
||||
it.media.status = Media.Status.READY
|
||||
})
|
||||
name = "series",
|
||||
books = listOf(makeBook("1").also {
|
||||
it.media.pages = listOf(BookPage("file", "image/jpeg"))
|
||||
it.media.status = Media.Status.READY
|
||||
})
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
val book = series.books.first()
|
||||
|
||||
mockMvc.get("/api/v1/books/${book.id}/pages/$page")
|
||||
.andExpect { status { isBadRequest } }
|
||||
.andExpect { status { isBadRequest } }
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
|
@ -254,8 +265,8 @@ class BookControllerTest(
|
|||
@WithMockCustomUser
|
||||
fun `given regular user when getting books then full url is hidden`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1.cbr"))
|
||||
name = "series",
|
||||
books = listOf(makeBook("1.cbr"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
|
||||
|
|
@ -265,27 +276,27 @@ class BookControllerTest(
|
|||
}
|
||||
|
||||
mockMvc.get("/api/v1/books")
|
||||
.andExpect(validation)
|
||||
.andExpect(validation)
|
||||
|
||||
mockMvc.get("/api/v1/books/latest")
|
||||
.andExpect(validation)
|
||||
.andExpect(validation)
|
||||
|
||||
mockMvc.get("/api/v1/series/${series.id}/books")
|
||||
.andExpect(validation)
|
||||
.andExpect(validation)
|
||||
|
||||
mockMvc.get("/api/v1/books/${series.books.first().id}")
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.url") { value("1.cbr") }
|
||||
}
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.url") { value("1.cbr") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(roles = [UserRoles.ADMIN])
|
||||
fun `given admin user when getting books then full url is available`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1.cbr"))
|
||||
name = "series",
|
||||
books = listOf(makeBook("1.cbr"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
|
||||
|
|
@ -296,19 +307,19 @@ class BookControllerTest(
|
|||
}
|
||||
|
||||
mockMvc.get("/api/v1/books")
|
||||
.andExpect(validation)
|
||||
.andExpect(validation)
|
||||
|
||||
mockMvc.get("/api/v1/books/latest")
|
||||
.andExpect(validation)
|
||||
.andExpect(validation)
|
||||
|
||||
mockMvc.get("/api/v1/series/${series.id}/books")
|
||||
.andExpect(validation)
|
||||
.andExpect(validation)
|
||||
|
||||
mockMvc.get("/api/v1/books/${series.books.first().id}")
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.url") { value(url) }
|
||||
}
|
||||
.andExpect {
|
||||
status { isOk }
|
||||
jsonPath("$.url") { value(url) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -318,8 +329,8 @@ class BookControllerTest(
|
|||
@WithMockCustomUser
|
||||
fun `given request with If-Modified-Since headers when getting thumbnail then returns 304 not modified`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1.cbr"))
|
||||
name = "series",
|
||||
books = listOf(makeBook("1.cbr"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
|
||||
|
|
@ -336,8 +347,8 @@ class BookControllerTest(
|
|||
@WithMockCustomUser
|
||||
fun `given request with If-Modified-Since headers when getting page then returns 304 not modified`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1.cbr"))
|
||||
name = "series",
|
||||
books = listOf(makeBook("1.cbr"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
|
||||
|
|
@ -350,4 +361,167 @@ class BookControllerTest(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class MetadataUpdate {
|
||||
@Test
|
||||
@WithMockCustomUser
|
||||
fun `given non-admin user when updating metadata then raise forbidden`() {
|
||||
mockMvc.patch("/api/v1/books/1/metadata") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = "{}"
|
||||
}.andExpect {
|
||||
status { isForbidden }
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = [
|
||||
"""{"title":""}""",
|
||||
"""{"number":""}""",
|
||||
"""{"authors":"[{"name":""}]"}""",
|
||||
"""{"ageRating":-1}"""
|
||||
])
|
||||
@WithMockCustomUser(roles = [UserRoles.ADMIN])
|
||||
fun `given invalid json when updating metadata then raise validation error`(jsonString: String) {
|
||||
mockMvc.patch("/api/v1/books/1/metadata") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isBadRequest }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Not part of the above @Nested class because @Transactional fails
|
||||
@Test
|
||||
@Transactional
|
||||
@WithMockCustomUser(roles = [UserRoles.ADMIN])
|
||||
fun `given valid json when updating metadata then fields are updated`() {
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1.cbr"))
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
val bookId = series.books.first().id
|
||||
|
||||
val jsonString = """
|
||||
{
|
||||
"title":"newTitle",
|
||||
"titleLock":true,
|
||||
"summary":"newSummary",
|
||||
"summaryLock":true,
|
||||
"number":"newNumber",
|
||||
"numberLock":true,
|
||||
"numberSort": 1.0,
|
||||
"numberSortLock":true,
|
||||
"readingDirection":"LEFT_TO_RIGHT",
|
||||
"readingDirectionLock":true,
|
||||
"publisher":"newPublisher",
|
||||
"publisherLock":true,
|
||||
"ageRating":12,
|
||||
"ageRatingLock":true,
|
||||
"releaseDate":"2020-01-01",
|
||||
"releaseDateLock":true,
|
||||
"authors":[
|
||||
{
|
||||
"name":"newAuthor",
|
||||
"role":"newAuthorRole"
|
||||
},
|
||||
{
|
||||
"name":"newAuthor2",
|
||||
"role":"newAuthorRole2"
|
||||
}
|
||||
],
|
||||
"authorsLock":true
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.patch("/api/v1/books/${bookId}/metadata") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isOk }
|
||||
}
|
||||
|
||||
val updatedBook = bookRepository.findByIdOrNull(bookId)
|
||||
with(updatedBook!!.metadata) {
|
||||
assertThat(title).isEqualTo("newTitle")
|
||||
assertThat(summary).isEqualTo("newSummary")
|
||||
assertThat(number).isEqualTo("newNumber")
|
||||
assertThat(numberSort).isEqualTo(1F)
|
||||
assertThat(readingDirection).isEqualTo(BookMetadata.ReadingDirection.LEFT_TO_RIGHT)
|
||||
assertThat(publisher).isEqualTo("newPublisher")
|
||||
assertThat(ageRating).isEqualTo(12)
|
||||
assertThat(releaseDate).isEqualTo(LocalDate.of(2020, 1, 1))
|
||||
assertThat(authors)
|
||||
.hasSize(2)
|
||||
.extracting("name", "role")
|
||||
.containsExactlyInAnyOrder(
|
||||
tuple("newAuthor", "newauthorrole"),
|
||||
tuple("newAuthor2", "newauthorrole2")
|
||||
)
|
||||
|
||||
assertThat(titleLock).isEqualTo(true)
|
||||
assertThat(summaryLock).isEqualTo(true)
|
||||
assertThat(numberLock).isEqualTo(true)
|
||||
assertThat(numberSortLock).isEqualTo(true)
|
||||
assertThat(readingDirectionLock).isEqualTo(true)
|
||||
assertThat(publisherLock).isEqualTo(true)
|
||||
assertThat(ageRatingLock).isEqualTo(true)
|
||||
assertThat(releaseDateLock).isEqualTo(true)
|
||||
assertThat(authorsLock).isEqualTo(true)
|
||||
}
|
||||
}
|
||||
|
||||
//Not part of the above @Nested class because @Transactional fails
|
||||
@Test
|
||||
@Transactional
|
||||
@WithMockCustomUser(roles = [UserRoles.ADMIN])
|
||||
fun `given json with null fields when updating metadata then fields with null are unset`() {
|
||||
val testDate = LocalDate.of(2020, 1, 1)
|
||||
val series = makeSeries(
|
||||
name = "series",
|
||||
books = listOf(makeBook("1.cbr").also {
|
||||
it.metadata.ageRating = 12
|
||||
it.metadata.readingDirection = BookMetadata.ReadingDirection.LEFT_TO_RIGHT
|
||||
it.metadata.authors.add(Author("Author", "role"))
|
||||
it.metadata.releaseDate = testDate
|
||||
})
|
||||
).also { it.library = library }
|
||||
seriesRepository.save(series)
|
||||
val bookId = series.books.first().id
|
||||
|
||||
val initialBook = bookRepository.findByIdOrNull(bookId)
|
||||
with(initialBook!!.metadata) {
|
||||
assertThat(readingDirection).isEqualTo(BookMetadata.ReadingDirection.LEFT_TO_RIGHT)
|
||||
assertThat(ageRating).isEqualTo(12)
|
||||
assertThat(authors).hasSize(1)
|
||||
assertThat(releaseDate).isEqualTo(testDate)
|
||||
}
|
||||
|
||||
val jsonString = """
|
||||
{
|
||||
"readingDirection":null,
|
||||
"ageRating":null,
|
||||
"authors":null,
|
||||
"releaseDate":null
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.patch("/api/v1/books/${bookId}/metadata") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isOk }
|
||||
}
|
||||
|
||||
val updatedBook = bookRepository.findByIdOrNull(bookId)
|
||||
with(updatedBook!!.metadata) {
|
||||
assertThat(readingDirection).isNull()
|
||||
assertThat(ageRating).isNull()
|
||||
assertThat(authors).isEmpty()
|
||||
assertThat(releaseDate).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue