feat: add more series metadata fields

title, sort title, lock for: status, title and sort title
This commit is contained in:
Gauthier Roebroeck 2020-01-31 17:22:02 +08:00
parent f1952eee4a
commit 8f08ce82e1
15 changed files with 269 additions and 123 deletions

View file

@ -17,9 +17,9 @@
<v-card-subtitle class="pa-2 pb-1 text--primary"
v-line-clamp="2"
style="word-break: normal !important; height: 4em"
:title="series.name"
:title="series.metadata.title"
>
{{ series.name }}
{{ series.metadata.title }}
</v-card-subtitle>
<v-card-text class="px-2"

View file

@ -9,7 +9,7 @@
<v-btn icon @click="dialogCancel">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>Edit {{ series.name }}</v-toolbar-title>
<v-toolbar-title>Edit {{ $_.get(series, 'metadata.title') }}</v-toolbar-title>
<v-spacer/>
<v-toolbar-items>
<v-btn text color="primary" @click="dialogConfirm">Save changes</v-btn>
@ -18,7 +18,7 @@
<v-card-title class="hidden-xs-only">
<v-icon class="mr-4">mdi-pencil</v-icon>
Edit {{ series.name }}
Edit {{ $_.get(series, 'metadata.title') }}
</v-card-title>
<v-tabs :vertical="$vuetify.breakpoint.smAndUp">
@ -32,13 +32,59 @@
<v-card flat>
<form novalidate>
<v-container fluid>
<!-- Title -->
<v-row>
<v-col cols="12">
<v-text-field v-model="form.title"
label="Title"
@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>
<!-- Sort Title -->
<v-row>
<v-col cols="12">
<v-text-field v-model="form.titleSort"
label="Sort Title"
@change="form.titleSortLock = true"
>
<template v-slot:prepend>
<v-icon :color="form.titleSortLock ? 'secondary' : ''"
@click="form.titleSortLock = !form.titleSortLock"
>
{{ form.titleSortLock ? 'mdi-lock' : 'mdi-lock-open' }}
</v-icon>
</template>
</v-text-field>
</v-col>
</v-row>
<!-- Status -->
<v-row>
<v-col cols="auto">
<v-select
:items="seriesStatus"
v-model="form.status"
label="Status"
/>
<v-select :items="seriesStatus"
v-model="form.status"
label="Status"
@change="form.statusLock = true"
>
<template v-slot:prepend>
<v-icon :color="form.statusLock ? 'secondary' : ''"
@click="form.statusLock = !form.statusLock"
>
{{ form.statusLock ? 'mdi-lock' : 'mdi-lock-open' }}
</v-icon>
</template>
</v-select>
</v-col>
</v-row>
</v-container>
@ -86,7 +132,12 @@ export default Vue.extend({
snackText: '',
seriesStatus: Object.keys(SeriesStatus).map(x => capitalize(x)),
form: {
status: ''
status: '',
statusLock: false,
title: '',
titleLock: false,
titleSort: '',
titleSortLock: false
}
}
},
@ -116,15 +167,19 @@ export default Vue.extend({
methods: {
dialogReset (series: SeriesDto) {
this.form.status = capitalize(series.metadata.status)
this.form.statusLock = series.metadata.statusLock
this.form.title = series.metadata.title
this.form.titleLock = series.metadata.titleLock
this.form.titleSort = series.metadata.titleSort
this.form.titleSortLock = series.metadata.titleSortLock
},
dialogCancel () {
this.$emit('input', false)
this.dialogReset(this.series)
},
dialogConfirm () {
this.editSeries()
async dialogConfirm () {
await this.editSeries()
this.$emit('input', false)
this.dialogReset(this.series)
},
showSnack (message: string) {
this.snackText = message
@ -133,10 +188,16 @@ export default Vue.extend({
async editSeries () {
try {
const metadata = {
status: this.form.status.toUpperCase()
status: this.form.status.toUpperCase(),
statusLock: this.form.statusLock,
title: this.form.title,
titleLock: this.form.titleLock,
titleSort: this.form.titleSort,
titleSortLock: this.form.titleSortLock
} as SeriesMetadataUpdateDto
await this.$komgaSeries.updateMetadata(this.series.id, metadata)
const updatedSeries = await this.$komgaSeries.updateMetadata(this.series.id, metadata)
this.$emit('update:series', updatedSeries)
} catch (e) {
this.showSnack(e.message)
}

View file

@ -33,7 +33,7 @@
class="ma-1 mr-3"
/>
<v-list-item-content>
<v-list-item-title v-text="item.name"/>
<v-list-item-title v-text="item.metadata.title"/>
</v-list-item-content>
</v-list-item>
</template>

View file

@ -105,9 +105,9 @@ export default class KomgaSeriesService {
}
}
async updateMetadata (seriesId: number, metadata: SeriesMetadataUpdateDto) {
async updateMetadata (seriesId: number, metadata: SeriesMetadataUpdateDto): Promise<SeriesDto> {
try {
await this.http.patch(`${API_SERIES}/${seriesId}/metadata`, metadata)
return (await this.http.patch(`${API_SERIES}/${seriesId}/metadata`, metadata)).data
} catch (e) {
let msg = `An error occurred while trying to update series metadata`
if (e.response.data.message) {

View file

@ -10,10 +10,20 @@ interface SeriesDto {
interface SeriesMetadata {
status: string,
statusLock: boolean,
created: string,
lastModified: string
lastModified: string,
title: string,
titleLock: boolean,
titleSort: string,
titleSortLock: boolean
}
interface SeriesMetadataUpdateDto {
status?: string
status?: string,
statusLock?: boolean,
title?: string,
titleLock?: boolean,
titleSort?: string,
titleSortLock?: boolean
}

View file

@ -100,12 +100,13 @@ export default mixins(VisibleElements).extend({
pagesState: [] as LoadState[],
pageSize: 20,
totalElements: null as number | null,
sortOptions: [{ name: 'Name', key: 'name' }, { name: 'Date added', key: 'createdDate' }, {
name: 'Date updated',
key: 'lastModifiedDate'
}] as SortOption[],
sortOptions: [
{ name: 'Name', key: 'metadata.titleSort' },
{ name: 'Date added', key: 'createdDate' },
{ name: 'Date updated', key: 'lastModifiedDate' }
] as SortOption[],
sortActive: {} as SortActive,
sortDefault: { key: 'name', order: 'asc' } as SortActive,
sortDefault: { key: 'metadata.titleSort', order: 'asc' } as SortActive,
filterStatus: [] as string[],
SeriesStatus,
cardWidth: 150,

View file

@ -24,7 +24,7 @@
</v-menu>
<v-toolbar-title>
<span v-if="series.name">{{ series.name }}</span>
<span v-if="$_.get(series, 'metadata.title')">{{ series.metadata.title }}</span>
<badge class="ml-4" v-if="totalElements" v-model="totalElements"/>
</v-toolbar-title>
@ -54,7 +54,7 @@
<v-col cols="8">
<v-row>
<v-col>
<div class="headline" v-if="series.name">{{ series.name }}</div>
<div class="headline" v-if="$_.get(series, 'metadata.title')">{{ series.metadata.title }}</div>
</v-col>
</v-row>
@ -89,7 +89,7 @@
</v-container>
<edit-series-dialog v-model="dialogEdit"
:series="series"/>
:series.sync="series"/>
</div>
</template>
@ -159,11 +159,6 @@ export default mixins(VisibleElements).extend({
if (this.$route.params.index !== index) {
this.updateRoute(index)
}
},
dialogEdit (val) {
if (!val) {
this.loadSeries()
}
}
},
async created () {

View file

@ -66,7 +66,7 @@ class Series(
@OneToOne(optional = false, orphanRemoval = true, cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
@JoinColumn(name = "metadata_id", nullable = false)
var metadata: SeriesMetadata = SeriesMetadata()
var metadata: SeriesMetadata = SeriesMetadata(title = name, titleSort = name)
init {
this.books = books.toList()

View file

@ -18,7 +18,13 @@ import javax.persistence.Table
class SeriesMetadata(
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
var status: Status = Status.ONGOING
var status: Status = Status.ONGOING,
@Column(name = "title", nullable = false)
var title: String,
@Column(name = "title_sort", nullable = false)
var titleSort: String
) : AuditableEntity() {
@Id
@ -26,6 +32,15 @@ class SeriesMetadata(
@Column(name = "id", nullable = false, unique = true)
val id: Long = 0
@Column(name = "status_lock", nullable = false)
var statusLock: Boolean = false
@Column(name = "title_lock", nullable = false)
var titleLock: Boolean = false
@Column(name = "title_sort_lock", nullable = false)
var titleSortLock: Boolean = false
enum class Status {
ENDED, ONGOING, ABANDONED, HIATUS
}

View file

@ -113,16 +113,16 @@ class OpdsController(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam("search") searchTerm: String?
): OpdsFeed {
val sort = Sort.by(Sort.Order.asc("name").ignoreCase())
val sort = Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase())
val series =
mutableListOf<Specification<Series>>().let { specs ->
if (!principal.user.sharedAllLibraries) {
specs.add(Series::library.`in`(principal.user.sharedLibraries))
}
mutableListOf<Specification<Series>>().let { specs ->
if (!principal.user.sharedAllLibraries) {
specs.add(Series::library.`in`(principal.user.sharedLibraries))
}
if (!searchTerm.isNullOrEmpty()) {
specs.add(Series::name.likeLower("%$searchTerm%"))
}
if (!searchTerm.isNullOrEmpty()) {
specs.add(Series::name.likeLower("%$searchTerm%"))
}
if (specs.isNotEmpty()) {
seriesRepository.findAll(specs.reduce { acc, spec -> acc.and(spec)!! }, sort)
@ -230,14 +230,14 @@ class OpdsController(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${ROUTE_BASE}libraries/$id"),
linkStart
),
entries = seriesRepository.findByLibraryId(library.id, Sort.by(Sort.Order.asc("name").ignoreCase())).map { it.toOpdsEntry() }
entries = seriesRepository.findByLibraryId(library.id, Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase())).map { it.toOpdsEntry() }
)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
private fun Series.toOpdsEntry() =
OpdsEntryNavigation(
title = name,
title = metadata.title,
updated = lastModifiedDate?.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = id.toString(),
content = "",

View file

@ -63,7 +63,7 @@ class SeriesController(
page.pageNumber,
page.pageSize,
if (page.sort.isSorted) page.sort
else Sort.by(Sort.Order.asc("name").ignoreCase())
else Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase())
)
return mutableListOf<Specification<Series>>().let { specs ->
@ -226,6 +226,15 @@ class SeriesController(
): SeriesDto =
seriesRepository.findByIdOrNull(seriesId)?.let { series ->
newMetadata.status?.let { series.metadata.status = newMetadata.status }
newMetadata.statusLock?.let { series.metadata.statusLock = newMetadata.statusLock }
if (!newMetadata.title.isNullOrBlank()) {
series.metadata.title = newMetadata.title
}
newMetadata.titleLock?.let { series.metadata.titleLock = newMetadata.titleLock }
if (!newMetadata.titleSort.isNullOrBlank()) {
series.metadata.titleSort = newMetadata.titleSort
}
newMetadata.titleSortLock?.let { series.metadata.titleSortLock = newMetadata.titleSortLock }
seriesRepository.save(series).toDto(includeUrl = true)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)

View file

@ -21,10 +21,15 @@ data class SeriesDto(
data class SeriesMetadataDto(
val status: String,
val statusLock: Boolean,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val created: LocalDateTime?,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val lastModified: LocalDateTime?
val lastModified: LocalDateTime?,
val title: String,
val titleLock: Boolean,
val titleSort: String,
val titleSortLock: Boolean
)
fun Series.toDto(includeUrl: Boolean) = SeriesDto(
@ -38,7 +43,12 @@ fun Series.toDto(includeUrl: Boolean) = SeriesDto(
booksCount = books.size,
metadata = SeriesMetadataDto(
status = metadata.status.name,
statusLock = metadata.statusLock,
created = metadata.createdDate?.toUTC(),
lastModified = metadata.lastModifiedDate?.toUTC()
lastModified = metadata.lastModifiedDate?.toUTC(),
title = metadata.title,
titleLock = metadata.titleLock,
titleSort = metadata.titleSort,
titleSortLock = metadata.titleSortLock
)
)

View file

@ -3,5 +3,10 @@ package org.gotson.komga.interfaces.rest.dto
import org.gotson.komga.domain.model.SeriesMetadata
data class SeriesMetadataUpdateDto(
val status: SeriesMetadata.Status?
val status: SeriesMetadata.Status?,
val statusLock: Boolean?,
val title: String?,
val titleLock: Boolean?,
val titleSort: String?,
val titleSortLock: Boolean?
)

View file

@ -0,0 +1,18 @@
alter table series_metadata
add (
status_lock boolean default false,
title varchar,
title_lock boolean default false,
title_sort varchar,
title_sort_lock boolean default false
);
update series_metadata m
set m.title = (select name from series where metadata_id = m.id),
m.title_sort = (select name from series where metadata_id = m.id);
alter table series_metadata
alter column title set not null;
alter table series_metadata
alter column title_sort set not null;

View file

@ -29,9 +29,9 @@ import javax.sql.DataSource
@AutoConfigureTestDatabase
@AutoConfigureMockMvc(printOnlyOnFailure = false)
class SeriesControllerTest(
@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 mockMvc: MockMvc
) {
lateinit var jdbcTemplate: JdbcTemplate
@ -60,14 +60,36 @@ class SeriesControllerTest(
seriesRepository.deleteAll()
}
@Nested
inner class SeriesSort {
@Test
@WithMockCustomUser
fun `given series with titleSort when requesting via api then series are sorted by titleSort`() {
val alpha = makeSeries("The Alpha").also {
it.metadata.titleSort = "Alpha, The"
it.library = library
}
seriesRepository.save(alpha)
val beta = makeSeries("Beta").also { it.library = library }
seriesRepository.save(beta)
mockMvc.get("/api/v1/series")
.andExpect {
status { isOk }
jsonPath("$.content[0].metadata.title") { value("The Alpha") }
jsonPath("$.content[1].metadata.title") { value("Beta") }
}
}
}
@Nested
inner class BookOrdering {
@Test
@WithMockCustomUser
fun `given books with unordered index when requesting via api then books are ordered`() {
val series = makeSeries(
name = "series",
books = listOf(makeBook("1"), makeBook("3"))
name = "series",
books = listOf(makeBook("1"), makeBook("3"))
).also { it.library = library }
seriesRepository.save(series)
@ -75,20 +97,20 @@ class SeriesControllerTest(
seriesRepository.save(series)
mockMvc.get("/api/v1/series/${series.id}/books")
.andExpect {
status { isOk }
jsonPath("$.content[0].name") { value("1") }
jsonPath("$.content[1].name") { value("2") }
jsonPath("$.content[2].name") { value("3") }
}
.andExpect {
status { isOk }
jsonPath("$.content[0].name") { value("1") }
jsonPath("$.content[1].name") { value("2") }
jsonPath("$.content[2].name") { value("3") }
}
}
@Test
@WithMockCustomUser
fun `given many books with unordered index when requesting via api then books are ordered and paged`() {
val series = makeSeries(
name = "series",
books = (1..100 step 2).map { makeBook("$it") }
name = "series",
books = (1..100 step 2).map { makeBook("$it") }
).also { it.library = library }
seriesRepository.save(series)
@ -96,24 +118,24 @@ class SeriesControllerTest(
seriesRepository.save(series)
mockMvc.get("/api/v1/series/${series.id}/books")
.andExpect {
status { isOk }
jsonPath("$.content[0].name") { value("1") }
jsonPath("$.content[1].name") { value("2") }
jsonPath("$.content[2].name") { value("3") }
jsonPath("$.content[3].name") { value("5") }
jsonPath("$.size") { value(20) }
jsonPath("$.first") { value(true) }
jsonPath("$.number") { value(0) }
}
.andExpect {
status { isOk }
jsonPath("$.content[0].name") { value("1") }
jsonPath("$.content[1].name") { value("2") }
jsonPath("$.content[2].name") { value("3") }
jsonPath("$.content[3].name") { value("5") }
jsonPath("$.size") { value(20) }
jsonPath("$.first") { value(true) }
jsonPath("$.number") { value(0) }
}
}
@Test
@WithMockCustomUser
fun `given many books in ready state with unordered index when requesting via api then books are ordered and paged`() {
val series = makeSeries(
name = "series",
books = (1..100 step 2).map { makeBook("$it") }
name = "series",
books = (1..100 step 2).map { makeBook("$it") }
).also { it.library = library }
seriesRepository.save(series)
@ -122,16 +144,16 @@ class SeriesControllerTest(
seriesRepository.save(series)
mockMvc.get("/api/v1/series/${series.id}/books?mediaStatus=READY")
.andExpect {
status { isOk }
jsonPath("$.content[0].name") { value("1") }
jsonPath("$.content[1].name") { value("2") }
jsonPath("$.content[2].name") { value("3") }
jsonPath("$.content[3].name") { value("5") }
jsonPath("$.size") { value(20) }
jsonPath("$.first") { value(true) }
jsonPath("$.number") { value(0) }
}
.andExpect {
status { isOk }
jsonPath("$.content[0].name") { value("1") }
jsonPath("$.content[1].name") { value("2") }
jsonPath("$.content[2].name") { value("3") }
jsonPath("$.content[3].name") { value("5") }
jsonPath("$.size") { value(20) }
jsonPath("$.first") { value(true) }
jsonPath("$.number") { value(0) }
}
}
}
@ -141,8 +163,8 @@ class SeriesControllerTest(
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [1])
fun `given user with access to a single library when getting series then only gets series 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)
@ -150,17 +172,17 @@ class SeriesControllerTest(
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/series")
.andExpect {
status { isOk }
jsonPath("$.content.length()") { value(1) }
jsonPath("$.content[0].name") { value("series") }
}
.andExpect {
status { isOk }
jsonPath("$.content.length()") { value(1) }
jsonPath("$.content[0].name") { value("series") }
}
}
}
@ -170,39 +192,39 @@ class SeriesControllerTest(
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [])
fun `given user with no access to any library when getting specific series 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)
mockMvc.get("/api/v1/series/${series.id}")
.andExpect { status { isUnauthorized } }
.andExpect { status { isUnauthorized } }
}
@Test
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [])
fun `given user with no access to any library when getting specific series 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)
mockMvc.get("/api/v1/series/${series.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 series books 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)
mockMvc.get("/api/v1/series/${series.id}/books")
.andExpect { status { isUnauthorized } }
.andExpect { status { isUnauthorized } }
}
}
@ -212,13 +234,13 @@ class SeriesControllerTest(
@WithMockCustomUser
fun `given book without thumbnail when getting series 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)
mockMvc.get("/api/v1/series/${series.id}/thumbnail")
.andExpect { status { isNotFound } }
.andExpect { status { isNotFound } }
}
}
@ -228,8 +250,8 @@ class SeriesControllerTest(
@WithMockCustomUser
fun `given regular user when getting series then 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)
@ -239,27 +261,27 @@ class SeriesControllerTest(
}
mockMvc.get("/api/v1/series")
.andExpect(validation)
.andExpect(validation)
mockMvc.get("/api/v1/series/latest")
.andExpect(validation)
.andExpect(validation)
mockMvc.get("/api/v1/series/new")
.andExpect(validation)
.andExpect(validation)
mockMvc.get("/api/v1/series/${series.id}")
.andExpect {
status { isOk }
jsonPath("$.url") { value("") }
}
.andExpect {
status { isOk }
jsonPath("$.url") { value("") }
}
}
@Test
@WithMockCustomUser(roles = [UserRoles.ADMIN])
fun `given admin user when getting series then 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)
@ -270,19 +292,19 @@ class SeriesControllerTest(
}
mockMvc.get("/api/v1/series")
.andExpect(validation)
.andExpect(validation)
mockMvc.get("/api/v1/series/latest")
.andExpect(validation)
.andExpect(validation)
mockMvc.get("/api/v1/series/new")
.andExpect(validation)
.andExpect(validation)
mockMvc.get("/api/v1/series/${series.id}")
.andExpect {
status { isOk }
jsonPath("$.url") { value(url) }
}
.andExpect {
status { isOk }
jsonPath("$.url") { value(url) }
}
}
}
}