feat: support for book metadata

closes #48, closes #43
This commit is contained in:
Gauthier Roebroeck 2020-03-17 10:55:30 +08:00
parent f69f73df08
commit 6a53e8fd6b
27 changed files with 1843 additions and 162 deletions

View file

@ -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>

View 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>

View 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
}

View file

@ -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 })

View 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;
}
}

View file

@ -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)
}
}
}

View 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)
}
}
}

View 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' }
]

View file

@ -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

View file

@ -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: {

View file

@ -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
}
}
})

View file

@ -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
}
}
})

View file

@ -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")
}
}

View file

@ -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()
}
}

View file

@ -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())

View file

@ -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
}
}

View file

@ -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>
}

View file

@ -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()

View file

@ -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)
}

View file

@ -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())

View file

@ -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)

View file

@ -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
}

View file

@ -76,6 +76,17 @@ caffeine.jcache {
}
}
cache.book_metadata {
monitoring {
statistics = true
}
policy {
maximum {
size = 500
}
}
}
default-update-timestamps-region {
monitoring {
statistics = true

View file

@ -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);

View file

@ -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")
}
}

View file

@ -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")
}
}

View file

@ -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()
}
}
}