mirror of
https://github.com/gotson/komga.git
synced 2025-12-13 20:15:03 +01:00
Merge ed9a8d7813 into 361d20df2c
This commit is contained in:
commit
1ba896ef9a
38 changed files with 674 additions and 15 deletions
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -54,6 +54,22 @@ application-oauth2.yml
|
|||
/benchmark
|
||||
/release_notes
|
||||
|
||||
### Local Testing
|
||||
test-config/
|
||||
test-data/
|
||||
Dockerfile.local
|
||||
docker-compose.test.yml
|
||||
TESTING_LOCALLY.md
|
||||
QUICK_START.md
|
||||
README_TESTING.md
|
||||
WEBUI_BUILD_NOTE.md
|
||||
WHAT_WENT_WRONG.md
|
||||
FRONTEND_IMPLEMENTATION_NEEDED.md
|
||||
start-test.ps1
|
||||
stop-test.ps1
|
||||
test-api.ps1
|
||||
CHARACTERS_IMPLEMENTATION_SUMMARY.md
|
||||
|
||||
### Conveyor
|
||||
output/
|
||||
secret/
|
||||
|
|
|
|||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"java.compile.nullAnalysis.mode": "disabled"
|
||||
}
|
||||
|
|
@ -306,6 +306,32 @@
|
|||
</v-combobox>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Characters -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<span class="text-body-2">{{ $t('common.characters') }}</span>
|
||||
<v-combobox v-model="form.characters"
|
||||
:items="charactersAvailable"
|
||||
@input="$v.form.characters.$touch()"
|
||||
@change="form.charactersLock = true"
|
||||
hide-selected
|
||||
chips
|
||||
deletable-chips
|
||||
multiple
|
||||
filled
|
||||
dense
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="form.charactersLock ? 'secondary' : ''"
|
||||
@click="form.charactersLock = !form.charactersLock"
|
||||
>
|
||||
{{ form.charactersLock ? 'mdi-lock' : 'mdi-lock-open' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-combobox>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card>
|
||||
</v-tab-item>
|
||||
|
|
@ -468,6 +494,8 @@ export default Vue.extend({
|
|||
authorsLock: false,
|
||||
tags: [] as string[],
|
||||
tagsLock: false,
|
||||
characters: [] as string[],
|
||||
charactersLock: false,
|
||||
isbn: '',
|
||||
isbnLock: false,
|
||||
links: [],
|
||||
|
|
@ -482,6 +510,7 @@ export default Vue.extend({
|
|||
authorSearch: [],
|
||||
authorSearchResults: [] as string[],
|
||||
tagsAvailable: [] as string[],
|
||||
charactersAvailable: [] as string[],
|
||||
}
|
||||
},
|
||||
props: {
|
||||
|
|
@ -499,6 +528,7 @@ export default Vue.extend({
|
|||
if (val) {
|
||||
this.getThumbnails(this.books)
|
||||
this.loadAvailableTags()
|
||||
this.loadAvailableCharacters()
|
||||
} else {
|
||||
this.dialogCancel()
|
||||
}
|
||||
|
|
@ -535,6 +565,7 @@ export default Vue.extend({
|
|||
}),
|
||||
},
|
||||
tags: {},
|
||||
characters: {},
|
||||
releaseDate: {validDate},
|
||||
summary: {},
|
||||
authors: {},
|
||||
|
|
@ -591,6 +622,9 @@ export default Vue.extend({
|
|||
async loadAvailableTags() {
|
||||
this.tagsAvailable = await this.$komgaReferential.getTags()
|
||||
},
|
||||
async loadAvailableCharacters() {
|
||||
this.charactersAvailable = await this.$komgaReferential.getCharacters()
|
||||
},
|
||||
linksLabelRules(label: string): boolean | string {
|
||||
if (!!this.$_.trim(label)) return true
|
||||
return this.$t('common.required').toString()
|
||||
|
|
@ -637,8 +671,14 @@ export default Vue.extend({
|
|||
|
||||
const tagsLock = this.$_.uniq(books.map(x => x.metadata.tagsLock))
|
||||
this.form.tagsLock = tagsLock.length > 1 ? false : tagsLock[0]
|
||||
|
||||
this.form.characters = []
|
||||
|
||||
const charactersLock = this.$_.uniq(books.map(x => x.metadata.charactersLock))
|
||||
this.form.charactersLock = charactersLock.length > 1 ? false : charactersLock[0]
|
||||
} else {
|
||||
this.form.tags = []
|
||||
this.form.characters = []
|
||||
this.form.links = []
|
||||
const book = books as BookDto
|
||||
this.$_.merge(this.form, book.metadata)
|
||||
|
|
@ -663,6 +703,7 @@ export default Vue.extend({
|
|||
const metadata = {
|
||||
authorsLock: this.form.authorsLock,
|
||||
tagsLock: this.form.tagsLock,
|
||||
charactersLock: this.form.charactersLock,
|
||||
}
|
||||
|
||||
if (this.$v.form?.authors?.$dirty) {
|
||||
|
|
@ -677,6 +718,10 @@ export default Vue.extend({
|
|||
this.$_.merge(metadata, {tags: this.form.tags})
|
||||
}
|
||||
|
||||
if (this.$v.form?.characters?.$dirty) {
|
||||
this.$_.merge(metadata, {characters: this.form.characters})
|
||||
}
|
||||
|
||||
if (this.single) {
|
||||
this.$_.merge(metadata, {
|
||||
titleLock: this.form.titleLock,
|
||||
|
|
|
|||
|
|
@ -402,6 +402,32 @@
|
|||
</v-combobox>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Characters -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<span class="text-body-2">{{ $t('dialog.edit_books.field_characters') }}</span>
|
||||
<v-combobox v-model="form.book.characters"
|
||||
:items="charactersAvailable"
|
||||
@input="$v.form.book.characters.$touch()"
|
||||
@change="form.book.charactersLock = true"
|
||||
hide-selected
|
||||
chips
|
||||
deletable-chips
|
||||
multiple
|
||||
filled
|
||||
dense
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="form.book.charactersLock ? 'secondary' : ''"
|
||||
@click="form.book.charactersLock = !form.book.charactersLock"
|
||||
>
|
||||
{{ form.book.charactersLock ? 'mdi-lock' : 'mdi-lock-open' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-combobox>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card>
|
||||
</v-tab-item>
|
||||
|
|
@ -621,6 +647,8 @@ export default Vue.extend({
|
|||
authorsLock: false,
|
||||
tags: [],
|
||||
tagsLock: false,
|
||||
characters: [],
|
||||
charactersLock: false,
|
||||
isbn: '',
|
||||
isbnLock: false,
|
||||
links: [],
|
||||
|
|
@ -643,6 +671,7 @@ export default Vue.extend({
|
|||
authorSearchResults: [] as string[],
|
||||
genresAvailable: [] as string[],
|
||||
tagsAvailable: [] as string[],
|
||||
charactersAvailable: [] as string[],
|
||||
sharingLabelsAvailable: [] as string[],
|
||||
}
|
||||
},
|
||||
|
|
@ -661,6 +690,7 @@ export default Vue.extend({
|
|||
if (val) {
|
||||
this.getThumbnails(this.books)
|
||||
this.loadAvailableTags()
|
||||
this.loadAvailableCharacters()
|
||||
this.loadAvailableGenres()
|
||||
this.loadAvailableSharingLabels()
|
||||
} else {
|
||||
|
|
@ -706,6 +736,7 @@ export default Vue.extend({
|
|||
},
|
||||
summary: {},
|
||||
tags: {},
|
||||
characters: {},
|
||||
releaseDate: {validDate},
|
||||
links: {},
|
||||
authors: {},
|
||||
|
|
@ -807,6 +838,9 @@ export default Vue.extend({
|
|||
async loadAvailableTags() {
|
||||
this.tagsAvailable = await this.$komgaReferential.getTags()
|
||||
},
|
||||
async loadAvailableCharacters() {
|
||||
this.charactersAvailable = await this.$komgaReferential.getCharacters()
|
||||
},
|
||||
async loadAvailableGenres() {
|
||||
this.genresAvailable = await this.$komgaReferential.getGenres()
|
||||
},
|
||||
|
|
@ -874,6 +908,11 @@ export default Vue.extend({
|
|||
const tagsLock = this.$_.uniq(oneshots.map(x => x.book.metadata.tagsLock))
|
||||
this.form.book.tagsLock = tagsLock.length > 1 ? false : tagsLock[0]
|
||||
|
||||
this.form.book.characters = []
|
||||
|
||||
const charactersLock = this.$_.uniq(oneshots.map(x => x.book.metadata.charactersLock))
|
||||
this.form.book.charactersLock = charactersLock.length > 1 ? false : charactersLock[0]
|
||||
|
||||
this.form.series.sharingLabels = []
|
||||
|
||||
const sharingLabelsLock = this.$_.uniq(oneshots.map(x => x.series.metadata.sharingLabelsLock))
|
||||
|
|
@ -888,6 +927,7 @@ export default Vue.extend({
|
|||
this.form.series.genres = []
|
||||
this.form.series.sharingLabels = []
|
||||
this.form.book.tags = []
|
||||
this.form.book.characters = []
|
||||
this.form.book.links = []
|
||||
const oneshot = oneshots as Oneshot
|
||||
this.$_.merge(this.form.series, oneshot.series.metadata)
|
||||
|
|
|
|||
|
|
@ -395,6 +395,32 @@
|
|||
</v-combobox>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Characters -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<span class="text-body-2">{{ $t('dialog.edit_series.field_characters') }}</span>
|
||||
<v-combobox v-model="form.characters"
|
||||
:items="charactersAvailable"
|
||||
@input="$v.form.characters.$touch()"
|
||||
@change="form.charactersLock = true"
|
||||
hide-selected
|
||||
chips
|
||||
deletable-chips
|
||||
multiple
|
||||
filled
|
||||
dense
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="form.charactersLock ? 'secondary' : ''"
|
||||
@click="form.charactersLock = !form.charactersLock"
|
||||
>
|
||||
{{ form.charactersLock ? 'mdi-lock' : 'mdi-lock-open' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-combobox>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card>
|
||||
</v-tab-item>
|
||||
|
|
@ -597,6 +623,8 @@ export default Vue.extend({
|
|||
genresLock: false,
|
||||
tags: [],
|
||||
tagsLock: false,
|
||||
characters: [],
|
||||
charactersLock: false,
|
||||
totalBookCount: undefined as number | undefined,
|
||||
totalBookCountLock: false,
|
||||
sharingLabels: [],
|
||||
|
|
@ -621,6 +649,7 @@ export default Vue.extend({
|
|||
},
|
||||
genresAvailable: [] as string[],
|
||||
tagsAvailable: [] as string[],
|
||||
charactersAvailable: [] as string[],
|
||||
sharingLabelsAvailable: [] as string[],
|
||||
}
|
||||
},
|
||||
|
|
@ -639,6 +668,7 @@ export default Vue.extend({
|
|||
if(val) {
|
||||
this.getThumbnails(this.series)
|
||||
this.loadAvailableTags()
|
||||
this.loadAvailableCharacters()
|
||||
this.loadAvailableGenres()
|
||||
this.loadAvailableSharingLabels()
|
||||
} else {
|
||||
|
|
@ -744,6 +774,9 @@ export default Vue.extend({
|
|||
async loadAvailableTags() {
|
||||
this.tagsAvailable = await this.$komgaReferential.getTags()
|
||||
},
|
||||
async loadAvailableCharacters() {
|
||||
this.charactersAvailable = await this.$komgaReferential.getCharacters()
|
||||
},
|
||||
async loadAvailableGenres() {
|
||||
this.genresAvailable = await this.$komgaReferential.getGenres()
|
||||
},
|
||||
|
|
@ -809,6 +842,11 @@ export default Vue.extend({
|
|||
const tagsLock = this.$_.uniq(series.map(x => x.metadata.tagsLock))
|
||||
this.form.tagsLock = tagsLock.length > 1 ? false : tagsLock[0]
|
||||
|
||||
this.form.characters = []
|
||||
|
||||
const charactersLock = this.$_.uniq(series.map(x => x.metadata.charactersLock))
|
||||
this.form.charactersLock = charactersLock.length > 1 ? false : charactersLock[0]
|
||||
|
||||
this.form.sharingLabels = []
|
||||
|
||||
const sharingLabelsLock = this.$_.uniq(series.map(x => x.metadata.sharingLabelsLock))
|
||||
|
|
@ -819,6 +857,7 @@ export default Vue.extend({
|
|||
} else {
|
||||
this.form.genres = []
|
||||
this.form.tags = []
|
||||
this.form.characters = []
|
||||
this.form.sharingLabels = []
|
||||
this.form.links = []
|
||||
this.form.alternateTitles = []
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@
|
|||
"settings": "Settings",
|
||||
"sidecars": "Sidecars",
|
||||
"tags": "Tags",
|
||||
"characters": "Characters",
|
||||
"ui": "User Interface",
|
||||
"unavailable": "Unavailable",
|
||||
"unlock_all": "Unlock all",
|
||||
|
|
@ -434,6 +435,7 @@
|
|||
"field_release_date_error": "Must be a valid date in YYYY-MM-DD format",
|
||||
"field_summary": "Summary",
|
||||
"field_tags": "Tags",
|
||||
"field_characters": "Characters",
|
||||
"field_title": "Title",
|
||||
"number_sort_decrement": "Decrement all by 1",
|
||||
"number_sort_increment": "Increment all by 1",
|
||||
|
|
@ -838,6 +840,7 @@
|
|||
"age_rating": "age rating",
|
||||
"age_rating_none": "None",
|
||||
"any": "Any",
|
||||
"character": "character",
|
||||
"complete": "Complete",
|
||||
"genre": "genre",
|
||||
"in_progress": "In Progress",
|
||||
|
|
|
|||
|
|
@ -85,6 +85,18 @@ export default class KomgaReferentialService {
|
|||
}
|
||||
}
|
||||
|
||||
async getCharacters(): Promise<string[]> {
|
||||
try {
|
||||
return (await this.http.get('/api/v1/characters')).data
|
||||
} catch (e) {
|
||||
let msg = 'An error occurred while trying to retrieve characters'
|
||||
if (e.response.data.message) {
|
||||
msg += `: ${e.response.data.message}`
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async getSharingLabels(libraryIds?: string[], collectionId?: string): Promise<string[]> {
|
||||
try {
|
||||
const params = {} as any
|
||||
|
|
@ -123,6 +135,25 @@ export default class KomgaReferentialService {
|
|||
}
|
||||
}
|
||||
|
||||
async getSeriesAndBookCharacters(libraryIds?: string[], collectionId?: string): Promise<string[]> {
|
||||
try {
|
||||
const params = {} as any
|
||||
if (libraryIds) params.library_id = libraryIds
|
||||
if (collectionId) params.collection_id = collectionId
|
||||
|
||||
return (await this.http.get('/api/v1/characters', {
|
||||
params: params,
|
||||
paramsSerializer: params => qs.stringify(params, {indices: false}),
|
||||
})).data
|
||||
} catch (e) {
|
||||
let msg = 'An error occurred while trying to retrieve series and book characters'
|
||||
if (e.response.data.message) {
|
||||
msg += `: ${e.response.data.message}`
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async getBookTags(seriesId?: string, readListId?: string, libraryIds?: string[]): Promise<string[]> {
|
||||
try {
|
||||
const params = {} as any
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ export interface BookMetadataDto {
|
|||
authorsLock: boolean,
|
||||
tags: string[],
|
||||
tagsLock: boolean,
|
||||
characters: string[],
|
||||
charactersLock: boolean,
|
||||
isbn: string,
|
||||
isbnLock: boolean
|
||||
links?: WebLinkDto[],
|
||||
|
|
@ -100,6 +102,8 @@ export interface BookMetadataUpdateDto {
|
|||
authorsLock?: boolean,
|
||||
tags?: string[],
|
||||
tagsLock?: boolean
|
||||
characters?: string[],
|
||||
charactersLock?: boolean,
|
||||
isbn?: string,
|
||||
isbnLock?: boolean,
|
||||
links?: WebLinkDto[],
|
||||
|
|
|
|||
|
|
@ -110,6 +110,14 @@ export class SearchConditionTag implements SearchConditionBook, SearchConditionS
|
|||
}
|
||||
}
|
||||
|
||||
export class SearchConditionCharacter implements SearchConditionBook, SearchConditionSeries {
|
||||
character: SearchOperatorEquality
|
||||
|
||||
constructor(op: SearchOperatorEquality) {
|
||||
this.character = op
|
||||
}
|
||||
}
|
||||
|
||||
export class SearchConditionLanguage implements SearchConditionSeries {
|
||||
language: SearchOperatorEquality
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ export interface SeriesMetadataDto {
|
|||
genresLock: boolean,
|
||||
tags: string[],
|
||||
tagsLock: boolean,
|
||||
characters: string[],
|
||||
charactersLock: boolean,
|
||||
totalBookCount?: number,
|
||||
totalBookCountLock: boolean,
|
||||
sharingLabels: string[],
|
||||
|
|
@ -59,6 +61,7 @@ export interface SeriesBooksMetadataDto {
|
|||
lastModified: string
|
||||
authors: AuthorDto[],
|
||||
tags: string[],
|
||||
characters: string[],
|
||||
releaseDate: string,
|
||||
summary: string,
|
||||
summaryNumber: string,
|
||||
|
|
@ -85,6 +88,8 @@ export interface SeriesMetadataUpdateDto {
|
|||
genresLock?: boolean,
|
||||
tags?: string[],
|
||||
tagsLock?: boolean,
|
||||
characters?: string[],
|
||||
charactersLock?: boolean,
|
||||
totalBookCount?: number,
|
||||
totalBookCountLock: boolean,
|
||||
sharingLabels?: string[],
|
||||
|
|
|
|||
|
|
@ -342,6 +342,37 @@
|
|||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Characters -->
|
||||
<v-row v-if="book.metadata.characters.length > 0" class="align-center text-caption">
|
||||
<v-col cols="4" sm="3" md="2" xl="1" class="py-1 text-uppercase">{{ $i18n.t('common.characters') }}</v-col>
|
||||
<v-col cols="8" sm="9" md="10" xl="11" class="py-1 text-capitalize">
|
||||
<vue-horizontal>
|
||||
<template v-slot:btn-prev>
|
||||
<v-btn icon small>
|
||||
<v-icon>mdi-chevron-left</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template v-slot:btn-next>
|
||||
<v-btn icon small>
|
||||
<v-icon>mdi-chevron-right</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-chip v-for="(c, i) in book.metadata.characters.slice().sort((a, b) => a.localeCompare(b))"
|
||||
:key="i"
|
||||
class="me-2"
|
||||
:title="c"
|
||||
:to="{name:'browse-series', params: {seriesId: book.seriesId}, query: {character: [new SearchConditionCharacter(new SearchOperatorIs(c))]}}"
|
||||
label
|
||||
small
|
||||
outlined
|
||||
link
|
||||
>{{ c }}
|
||||
</v-chip>
|
||||
</vue-horizontal>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col>
|
||||
<read-lists-expansion-panels :read-lists="readLists">
|
||||
|
|
@ -470,7 +501,7 @@ import RtlIcon from '@/components/RtlIcon.vue'
|
|||
import {BookSseDto, LibrarySseDto, ReadListSseDto, ReadProgressSseDto} from '@/types/komga-sse'
|
||||
import {RawLocation} from 'vue-router/types/router'
|
||||
import {ReadListDto} from '@/types/komga-readlists'
|
||||
import {BookSearch, SearchConditionSeriesId, SearchConditionTag, SearchOperatorIs} from '@/types/komga-search'
|
||||
import {BookSearch, SearchConditionCharacter, SearchConditionSeriesId, SearchConditionTag, SearchOperatorIs} from '@/types/komga-search'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'BrowseBook',
|
||||
|
|
@ -479,6 +510,7 @@ export default Vue.extend({
|
|||
return {
|
||||
MediaStatus,
|
||||
SearchConditionTag,
|
||||
SearchConditionCharacter,
|
||||
SearchOperatorIs,
|
||||
book: {} as BookDto,
|
||||
context: {} as Context,
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ import {
|
|||
SearchConditionAllOfBook,
|
||||
SearchConditionAnyOfBook,
|
||||
SearchConditionAuthor,
|
||||
SearchConditionCharacter,
|
||||
SearchConditionDeleted,
|
||||
SearchConditionLibraryId,
|
||||
SearchConditionMediaProfile,
|
||||
|
|
@ -222,6 +223,7 @@ export default Vue.extend({
|
|||
drawer: false,
|
||||
filterOptions: {
|
||||
tag: [] as NameValue[],
|
||||
character: [] as NameValue[],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
@ -375,6 +377,18 @@ export default Vue.extend({
|
|||
],
|
||||
anyAllSelector: true,
|
||||
},
|
||||
character: {
|
||||
name: this.$t('filter.character').toString(),
|
||||
values: [
|
||||
{
|
||||
name: this.$t('filter.any').toString(),
|
||||
value: new SearchConditionCharacter(new SearchOperatorIsNotNull()),
|
||||
nValue: new SearchConditionCharacter(new SearchOperatorIsNull()),
|
||||
},
|
||||
...this.filterOptions.character,
|
||||
],
|
||||
anyAllSelector: true,
|
||||
},
|
||||
mediaProfile: {
|
||||
name: this.$t('filter.media_profile').toString(), values: Object.values(MediaProfile).map(x => ({
|
||||
name: i18n.t(`enums.media_profile.${x}`),
|
||||
|
|
@ -447,17 +461,20 @@ export default Vue.extend({
|
|||
const requestLibraryIds = libraryId !== LIBRARIES_ALL ? [libraryId] : this.$store.getters.getLibrariesPinned.map((it: LibraryDto) => it.id)
|
||||
|
||||
// load dynamic filters
|
||||
const [tags] = await Promise.all([
|
||||
const [tags, characters] = await Promise.all([
|
||||
this.$komgaReferential.getBookTags(undefined, undefined, requestLibraryIds),
|
||||
this.$komgaReferential.getCharacters(undefined, undefined, requestLibraryIds),
|
||||
])
|
||||
this.$set(this.filterOptions, 'tag', toNameValueCondition(tags, x => new SearchConditionTag(new SearchOperatorIs(x)), x => new SearchConditionTag(new SearchOperatorIsNot(x))))
|
||||
this.$set(this.filterOptions, 'character', toNameValueCondition(characters, x => new SearchConditionCharacter(new SearchOperatorIs(x)), x => new SearchConditionCharacter(new SearchOperatorIsNot(x))))
|
||||
|
||||
// get filter from query params or local storage and validate with available filter values
|
||||
let activeFilters: any
|
||||
if (route.query.readStatus || route.query.tag || authorRoles.some(role => role in route.query) || route.query.oneshot || route.query.deleted || route.query.mediaProfile || route.query.mediaStatus) {
|
||||
if (route.query.readStatus || route.query.tag || route.query.character || authorRoles.some(role => role in route.query) || route.query.oneshot || route.query.deleted || route.query.mediaProfile || route.query.mediaStatus) {
|
||||
activeFilters = {
|
||||
readStatus: route.query.readStatus || [],
|
||||
tag: route.query.tag || [],
|
||||
character: route.query.character || [],
|
||||
oneshot: route.query.oneshot || [],
|
||||
deleted: route.query.deleted || [],
|
||||
mediaProfile: route.query.mediaProfile || [],
|
||||
|
|
@ -491,6 +508,7 @@ export default Vue.extend({
|
|||
const validFilter = {
|
||||
readStatus: this.$_.intersectionWith(filters.readStatus, extractFilterOptionsValues(this.filterOptionsList.readStatus.values), objIsEqual) || [],
|
||||
tag: this.$_.intersectionWith(filters.tag, extractFilterOptionsValues(this.filterOptions.tag), objIsEqual) || [],
|
||||
character: this.$_.intersectionWith(filters.character, extractFilterOptionsValues(this.filterOptions.character), objIsEqual) || [],
|
||||
oneshot: this.$_.intersectionWith(filters.oneshot, extractFilterOptionsValues(this.filterOptionsList.oneshot.values), objIsEqual) || [],
|
||||
deleted: this.$_.intersectionWith(filters.deleted, extractFilterOptionsValues(this.filterOptionsList.deleted.values), objIsEqual) || [],
|
||||
mediaProfile: this.$_.intersectionWith(filters.mediaProfile, extractFilterOptionsValues(this.filterOptionsPanel.mediaProfile.values), objIsEqual) || [],
|
||||
|
|
@ -603,6 +621,7 @@ export default Vue.extend({
|
|||
}
|
||||
if (this.filters.readStatus && this.filters.readStatus.length > 0) conditions.push(new SearchConditionAnyOfBook(this.filters.readStatus))
|
||||
if (this.filters.tag && this.filters.tag.length > 0) this.filtersMode?.tag?.allOf ? conditions.push(new SearchConditionAllOfBook(this.filters.tag)) : conditions.push(new SearchConditionAnyOfBook(this.filters.tag))
|
||||
if (this.filters.character && this.filters.character.length > 0) this.filtersMode?.character?.allOf ? conditions.push(new SearchConditionAllOfBook(this.filters.character)) : conditions.push(new SearchConditionAnyOfBook(this.filters.character))
|
||||
if (this.filters.oneshot && this.filters.oneshot.length > 0) conditions.push(...this.filters.oneshot)
|
||||
if (this.filters.mediaProfile && this.filters.mediaProfile.length > 0) this.filtersMode?.mediaProfile?.allOf ? conditions.push(new SearchConditionAllOfBook(this.filters.mediaProfile)) : conditions.push(new SearchConditionAnyOfBook(this.filters.mediaProfile))
|
||||
if (this.filters.deleted && this.filters.deleted.length > 0) conditions.push(...this.filters.deleted)
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ import {
|
|||
SearchConditionAnyOfBook,
|
||||
SearchConditionAnyOfSeries,
|
||||
SearchConditionAuthor,
|
||||
SearchConditionCharacter,
|
||||
SearchConditionComplete,
|
||||
SearchConditionDeleted,
|
||||
SearchConditionGenre,
|
||||
|
|
@ -250,6 +251,7 @@ export default Vue.extend({
|
|||
filterOptions: {
|
||||
genre: [] as NameValue[],
|
||||
tag: [] as NameValue[],
|
||||
character: [] as NameValue[],
|
||||
publisher: [] as NameValue[],
|
||||
language: [] as NameValue[],
|
||||
ageRating: [] as NameValue[],
|
||||
|
|
@ -459,6 +461,18 @@ export default Vue.extend({
|
|||
],
|
||||
anyAllSelector: true,
|
||||
},
|
||||
character: {
|
||||
name: this.$t('filter.character').toString(),
|
||||
values: [
|
||||
{
|
||||
name: this.$t('filter.any').toString(),
|
||||
value: new SearchConditionCharacter(new SearchOperatorIsNotNull()),
|
||||
nValue: new SearchConditionCharacter(new SearchOperatorIsNull()),
|
||||
},
|
||||
...this.filterOptions.character,
|
||||
],
|
||||
anyAllSelector: true,
|
||||
},
|
||||
publisher: {
|
||||
name: this.$t('filter.publisher').toString(),
|
||||
values: [
|
||||
|
|
@ -579,9 +593,10 @@ export default Vue.extend({
|
|||
const requestLibraryIds = libraryId !== LIBRARIES_ALL ? [libraryId] : this.$store.getters.getLibrariesPinned.map((it: LibraryDto) => it.id)
|
||||
|
||||
// load dynamic filters
|
||||
const [genres, tags, publishers, languages, ageRatings, releaseDates, sharingLabels] = await Promise.all([
|
||||
const [genres, tags, characters, publishers, languages, ageRatings, releaseDates, sharingLabels] = await Promise.all([
|
||||
this.$komgaReferential.getGenres(requestLibraryIds),
|
||||
this.$komgaReferential.getSeriesAndBookTags(requestLibraryIds),
|
||||
this.$komgaReferential.getSeriesAndBookCharacters(requestLibraryIds),
|
||||
this.$komgaReferential.getPublishers(requestLibraryIds),
|
||||
this.$komgaReferential.getLanguages(requestLibraryIds),
|
||||
this.$komgaReferential.getAgeRatings(requestLibraryIds),
|
||||
|
|
@ -590,6 +605,7 @@ export default Vue.extend({
|
|||
])
|
||||
this.$set(this.filterOptions, 'genre', toNameValueCondition(genres, x => new SearchConditionGenre(new SearchOperatorIs(x)), x => new SearchConditionGenre(new SearchOperatorIsNot(x))))
|
||||
this.$set(this.filterOptions, 'tag', toNameValueCondition(tags, x => new SearchConditionTag(new SearchOperatorIs(x)), x => new SearchConditionTag(new SearchOperatorIsNot(x))))
|
||||
this.$set(this.filterOptions, 'character', toNameValueCondition(characters, x => new SearchConditionCharacter(new SearchOperatorIs(x)), x => new SearchConditionCharacter(new SearchOperatorIsNot(x))))
|
||||
this.$set(this.filterOptions, 'publisher', toNameValueCondition(publishers, x => new SearchConditionPublisher(new SearchOperatorIs(x)), x => new SearchConditionPublisher(new SearchOperatorIsNot(x))))
|
||||
this.$set(this.filterOptions, 'language', languages.map((x: NameValue) => {
|
||||
return {
|
||||
|
|
@ -620,12 +636,13 @@ export default Vue.extend({
|
|||
|
||||
// get filter from query params or local storage and validate with available filter values
|
||||
let activeFilters: any
|
||||
if (route.query.status || route.query.readStatus || route.query.genre || route.query.tag || route.query.language || route.query.ageRating || route.query.publisher || authorRoles.some(role => role in route.query) || route.query.complete || route.query.oneshot || route.query.sharingLabel || route.query.deleted) {
|
||||
if (route.query.status || route.query.readStatus || route.query.genre || route.query.tag || route.query.character || route.query.language || route.query.ageRating || route.query.publisher || authorRoles.some(role => role in route.query) || route.query.complete || route.query.oneshot || route.query.sharingLabel || route.query.deleted) {
|
||||
activeFilters = {
|
||||
status: route.query.status || [],
|
||||
readStatus: route.query.readStatus || [],
|
||||
genre: route.query.genre || [],
|
||||
tag: route.query.tag || [],
|
||||
character: route.query.character || [],
|
||||
publisher: route.query.publisher || [],
|
||||
language: route.query.language || [],
|
||||
ageRating: route.query.ageRating || [],
|
||||
|
|
@ -665,6 +682,7 @@ export default Vue.extend({
|
|||
readStatus: this.$_.intersectionWith(filters.readStatus, extractFilterOptionsValues(this.filterOptionsList.readStatus.values), objIsEqual) || [],
|
||||
genre: this.$_.intersectionWith(filters.genre, extractFilterOptionsValues(this.filterOptions.genre), objIsEqual) || [],
|
||||
tag: this.$_.intersectionWith(filters.tag, extractFilterOptionsValues(this.filterOptions.tag), objIsEqual) || [],
|
||||
character: this.$_.intersectionWith(filters.character, extractFilterOptionsValues(this.filterOptions.character), objIsEqual) || [],
|
||||
publisher: this.$_.intersectionWith(filters.publisher, extractFilterOptionsValues(this.filterOptions.publisher), objIsEqual) || [],
|
||||
language: this.$_.intersectionWith(filters.language, extractFilterOptionsValues(this.filterOptions.language), objIsEqual) || [],
|
||||
ageRating: this.$_.intersectionWith(filters.ageRating, extractFilterOptionsValues(this.filterOptions.ageRating), objIsEqual) || [],
|
||||
|
|
@ -786,6 +804,7 @@ export default Vue.extend({
|
|||
if (this.filters.readStatus && this.filters.readStatus.length > 0) conditions.push(new SearchConditionAnyOfSeries(this.filters.readStatus))
|
||||
if (this.filters.genre && this.filters.genre.length > 0) this.filtersMode?.genre?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.genre)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.genre))
|
||||
if (this.filters.tag && this.filters.tag.length > 0) this.filtersMode?.tag?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.tag)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.tag))
|
||||
if (this.filters.character && this.filters.character.length > 0) this.filtersMode?.character?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.character)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.character))
|
||||
if (this.filters.language && this.filters.language.length > 0) this.filtersMode?.language?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.language)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.language))
|
||||
if (this.filters.publisher && this.filters.publisher.length > 0) this.filtersMode?.publisher?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.publisher)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.publisher))
|
||||
if (this.filters.ageRating && this.filters.ageRating.length > 0) this.filtersMode?.ageRating?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.ageRating)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.ageRating))
|
||||
|
|
|
|||
|
|
@ -413,6 +413,37 @@
|
|||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Characters -->
|
||||
<v-row v-if="book.metadata.characters.length > 0" class="align-center text-caption">
|
||||
<v-col cols="4" sm="3" md="2" xl="1" class="py-1 text-uppercase">{{ $t('common.characters') }}</v-col>
|
||||
<v-col cols="8" sm="9" md="10" xl="11" class="py-1 text-capitalize">
|
||||
<vue-horizontal>
|
||||
<template v-slot:btn-prev>
|
||||
<v-btn icon small>
|
||||
<v-icon>mdi-chevron-left</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template v-slot:btn-next>
|
||||
<v-btn icon small>
|
||||
<v-icon>mdi-chevron-right</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-chip v-for="(c, i) in book.metadata.characters"
|
||||
:key="i"
|
||||
class="me-2"
|
||||
:title="c"
|
||||
:to="{name:'browse-libraries', params: {libraryId: book.libraryId}, query: {character: [new SearchConditionCharacter(new SearchOperatorIs(c))]}}"
|
||||
label
|
||||
small
|
||||
outlined
|
||||
link
|
||||
>{{ c }}
|
||||
</v-chip>
|
||||
</vue-horizontal>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" class="pb-1">
|
||||
|
|
@ -573,6 +604,7 @@ import OneshotActionsMenu from '@/components/menus/OneshotActionsMenu.vue'
|
|||
import {
|
||||
BookSearch,
|
||||
SearchConditionAgeRating,
|
||||
SearchConditionCharacter,
|
||||
SearchConditionGenre,
|
||||
SearchConditionLanguage,
|
||||
SearchConditionPublisher,
|
||||
|
|
@ -595,6 +627,7 @@ export default Vue.extend({
|
|||
SearchConditionPublisher,
|
||||
SearchConditionGenre,
|
||||
SearchConditionTag,
|
||||
SearchConditionCharacter,
|
||||
SearchConditionLanguage,
|
||||
SearchConditionAgeRating,
|
||||
SearchOperatorIs,
|
||||
|
|
|
|||
|
|
@ -381,6 +381,39 @@
|
|||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Characters -->
|
||||
<v-row v-if="series.booksMetadata.characters.length > 0"
|
||||
class="align-center text-caption">
|
||||
<v-col cols="4" sm="3" md="2" xl="1" class="py-1 text-uppercase">{{ $t('common.characters') }}</v-col>
|
||||
<v-col cols="8" sm="9" md="10" xl="11" class="py-1 text-capitalize">
|
||||
<vue-horizontal>
|
||||
<template v-slot:btn-prev>
|
||||
<v-btn icon small>
|
||||
<v-icon>mdi-chevron-left</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template v-slot:btn-next>
|
||||
<v-btn icon small>
|
||||
<v-icon>mdi-chevron-right</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-chip v-for="(c, i) in $_.sortBy(series.booksMetadata.characters)"
|
||||
:key="`char_${i}`"
|
||||
class="me-2"
|
||||
:title="c"
|
||||
:to="{name:'browse-libraries', params: {libraryId: series.libraryId }, query: {character: [new SearchConditionCharacter(new SearchOperatorIs(c))]}}"
|
||||
label
|
||||
small
|
||||
outlined
|
||||
link
|
||||
color="contrast-light-2"
|
||||
>{{ c }}
|
||||
</v-chip>
|
||||
</vue-horizontal>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="series.metadata.links.length > 0" class="align-center text-caption">
|
||||
<v-col class="py-1 text-uppercase" cols="4" sm="3" md="2" xl="1">{{ $t('browse_book.links') }}</v-col>
|
||||
<v-col class="py-1" cols="8" sm="9" md="10" xl="11">
|
||||
|
|
@ -555,6 +588,7 @@ import {
|
|||
SearchConditionAnyOfBook,
|
||||
SearchConditionAuthor,
|
||||
SearchConditionBook,
|
||||
SearchConditionCharacter,
|
||||
SearchConditionDeleted,
|
||||
SearchConditionGenre,
|
||||
SearchConditionLanguage,
|
||||
|
|
@ -610,6 +644,7 @@ export default Vue.extend({
|
|||
SearchConditionPublisher,
|
||||
SearchConditionGenre,
|
||||
SearchConditionTag,
|
||||
SearchConditionCharacter,
|
||||
SearchConditionLanguage,
|
||||
SearchConditionAgeRating,
|
||||
SearchOperatorIs,
|
||||
|
|
|
|||
24
komga/Dockerfile.local
Normal file
24
komga/Dockerfile.local
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
FROM eclipse-temurin:17-jre
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y wget && \
|
||||
wget "https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit" -O /usr/bin/kepubify && \
|
||||
chmod +x /usr/bin/kepubify && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
VOLUME /tmp
|
||||
VOLUME /config
|
||||
VOLUME /data
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the JAR file
|
||||
COPY build/libs/*.jar app.jar
|
||||
|
||||
ENV KOMGA_CONFIGDIR="/config"
|
||||
ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8'
|
||||
|
||||
EXPOSE 25600
|
||||
|
||||
ENTRYPOINT ["java", "-Dspring.profiles.include=docker", "-jar", "app.jar", "--spring.config.additional-location=file:/config/"]
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
-- Add characters support to book metadata
|
||||
-- Following the same pattern as tags (BOOK_METADATA_TAG and BOOK_METADATA_AGGREGATION_TAG)
|
||||
|
||||
-- Table for book-level characters
|
||||
CREATE TABLE BOOK_METADATA_CHARACTER
|
||||
(
|
||||
CHARACTER varchar NOT NULL,
|
||||
BOOK_ID varchar NOT NULL,
|
||||
FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID)
|
||||
);
|
||||
|
||||
-- Index for performance
|
||||
CREATE INDEX idx__book_metadata_character__book_id on BOOK_METADATA_CHARACTER (BOOK_ID);
|
||||
|
||||
-- Add characters lock column to book metadata
|
||||
alter table BOOK_METADATA
|
||||
add column CHARACTERS_LOCK boolean NOT NULL DEFAULT 0;
|
||||
|
||||
-- Table for series-level aggregated characters
|
||||
CREATE TABLE BOOK_METADATA_AGGREGATION_CHARACTER
|
||||
(
|
||||
CHARACTER varchar NOT NULL,
|
||||
SERIES_ID varchar NOT NULL,
|
||||
FOREIGN KEY (SERIES_ID) REFERENCES SERIES (ID)
|
||||
);
|
||||
|
||||
-- Index for performance
|
||||
CREATE INDEX idx__book_metadata_aggregation_character__series_id on BOOK_METADATA_AGGREGATION_CHARACTER (SERIES_ID);
|
||||
|
|
@ -12,6 +12,7 @@ class BookMetadata(
|
|||
val releaseDate: LocalDate? = null,
|
||||
val authors: List<Author> = emptyList(),
|
||||
tags: Set<String> = emptySet(),
|
||||
characters: Set<String> = emptySet(),
|
||||
val isbn: String = "",
|
||||
val links: List<WebLink> = emptyList(),
|
||||
val titleLock: Boolean = false,
|
||||
|
|
@ -21,6 +22,7 @@ class BookMetadata(
|
|||
val releaseDateLock: Boolean = false,
|
||||
val authorsLock: Boolean = false,
|
||||
val tagsLock: Boolean = false,
|
||||
val charactersLock: Boolean = false,
|
||||
val isbnLock: Boolean = false,
|
||||
val linksLock: Boolean = false,
|
||||
val bookId: String = "",
|
||||
|
|
@ -31,6 +33,7 @@ class BookMetadata(
|
|||
val summary = summary.trim()
|
||||
val number = number.trim()
|
||||
val tags = tags.lowerNotBlank().toSet()
|
||||
val characters = characters.lowerNotBlank().toSet()
|
||||
|
||||
fun copy(
|
||||
title: String = this.title,
|
||||
|
|
@ -40,6 +43,7 @@ class BookMetadata(
|
|||
releaseDate: LocalDate? = this.releaseDate,
|
||||
authors: List<Author> = this.authors.toList(),
|
||||
tags: Set<String> = this.tags,
|
||||
characters: Set<String> = this.characters,
|
||||
isbn: String = this.isbn,
|
||||
links: List<WebLink> = this.links,
|
||||
titleLock: Boolean = this.titleLock,
|
||||
|
|
@ -49,6 +53,7 @@ class BookMetadata(
|
|||
releaseDateLock: Boolean = this.releaseDateLock,
|
||||
authorsLock: Boolean = this.authorsLock,
|
||||
tagsLock: Boolean = this.tagsLock,
|
||||
charactersLock: Boolean = this.charactersLock,
|
||||
isbnLock: Boolean = this.isbnLock,
|
||||
linksLock: Boolean = this.linksLock,
|
||||
bookId: String = this.bookId,
|
||||
|
|
@ -62,6 +67,7 @@ class BookMetadata(
|
|||
releaseDate = releaseDate,
|
||||
authors = authors,
|
||||
tags = tags,
|
||||
characters = characters,
|
||||
isbn = isbn,
|
||||
links = links,
|
||||
titleLock = titleLock,
|
||||
|
|
@ -71,6 +77,7 @@ class BookMetadata(
|
|||
releaseDateLock = releaseDateLock,
|
||||
authorsLock = authorsLock,
|
||||
tagsLock = tagsLock,
|
||||
charactersLock = charactersLock,
|
||||
isbnLock = isbnLock,
|
||||
linksLock = linksLock,
|
||||
bookId = bookId,
|
||||
|
|
@ -78,5 +85,5 @@ class BookMetadata(
|
|||
lastModifiedDate = lastModifiedDate,
|
||||
)
|
||||
|
||||
override fun toString(): String = "BookMetadata(numberSort=$numberSort, releaseDate=$releaseDate, authors=$authors, isbn='$isbn', links=$links, titleLock=$titleLock, summaryLock=$summaryLock, numberLock=$numberLock, numberSortLock=$numberSortLock, releaseDateLock=$releaseDateLock, authorsLock=$authorsLock, tagsLock=$tagsLock, isbnLock=$isbnLock, linksLock=$linksLock, bookId='$bookId', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate, title='$title', summary='$summary', number='$number', tags=$tags)"
|
||||
override fun toString(): String = "BookMetadata(numberSort=$numberSort, releaseDate=$releaseDate, authors=$authors, isbn='$isbn', links=$links, titleLock=$titleLock, summaryLock=$summaryLock, numberLock=$numberLock, numberSortLock=$numberSortLock, releaseDateLock=$releaseDateLock, authorsLock=$authorsLock, tagsLock=$tagsLock, charactersLock=$charactersLock, isbnLock=$isbnLock, linksLock=$linksLock, bookId='$bookId', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate, title='$title', summary='$summary', number='$number', tags=$tags, characters=$characters)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import java.time.LocalDateTime
|
|||
data class BookMetadataAggregation(
|
||||
val authors: List<Author> = emptyList(),
|
||||
val tags: Set<String> = emptySet(),
|
||||
val characters: Set<String> = emptySet(),
|
||||
val releaseDate: LocalDate? = null,
|
||||
val summary: String = "",
|
||||
val summaryNumber: String = "",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ data class BookMetadataPatch(
|
|||
val isbn: String? = null,
|
||||
val links: List<WebLink>? = null,
|
||||
val tags: Set<String>? = null,
|
||||
val characters: Set<String>? = null,
|
||||
val readLists: List<ReadListEntry> = emptyList(),
|
||||
) {
|
||||
data class ReadListEntry(
|
||||
|
|
@ -28,6 +29,7 @@ enum class BookMetadataPatchCapability {
|
|||
RELEASE_DATE,
|
||||
AUTHORS,
|
||||
TAGS,
|
||||
CHARACTERS,
|
||||
ISBN,
|
||||
READ_LISTS,
|
||||
THUMBNAILS,
|
||||
|
|
|
|||
|
|
@ -105,6 +105,12 @@ class SearchCondition {
|
|||
) : Book,
|
||||
Series
|
||||
|
||||
data class Character(
|
||||
@JsonProperty("character")
|
||||
val operator: SearchOperator.EqualityNullable<String>,
|
||||
) : Book,
|
||||
Series
|
||||
|
||||
data class SharingLabel(
|
||||
@JsonProperty("sharingLabel")
|
||||
val operator: SearchOperator.EqualityNullable<String>,
|
||||
|
|
|
|||
|
|
@ -99,6 +99,18 @@ interface ReferentialRepository {
|
|||
filterOnLibraryIds: Collection<String>?,
|
||||
): Set<String>
|
||||
|
||||
fun findAllSeriesAndBookCharacters(filterOnLibraryIds: Collection<String>?): Set<String>
|
||||
|
||||
fun findAllSeriesAndBookCharactersByLibraries(
|
||||
libraryIds: Set<String>,
|
||||
filterOnLibraryIds: Collection<String>?,
|
||||
): Set<String>
|
||||
|
||||
fun findAllSeriesAndBookCharactersByCollection(
|
||||
collectionId: String,
|
||||
filterOnLibraryIds: Collection<String>?,
|
||||
): Set<String>
|
||||
|
||||
fun findAllSeriesTags(filterOnLibraryIds: Collection<String>?): Set<String>
|
||||
|
||||
fun findAllSeriesTagsByLibrary(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ class MetadataAggregator {
|
|||
fun aggregate(metadatas: Collection<BookMetadata>): BookMetadataAggregation {
|
||||
val authors = metadatas.flatMap { it.authors }.distinctBy { "${it.role}__${it.name}" }
|
||||
val tags = metadatas.flatMap { it.tags }.toSet()
|
||||
val characters = metadatas.flatMap { it.characters }.toSet()
|
||||
val (summary, summaryNumber) =
|
||||
metadatas
|
||||
.sortedBy { it.numberSort }
|
||||
|
|
@ -18,6 +19,6 @@ class MetadataAggregator {
|
|||
} ?: ("" to "")
|
||||
val releaseDate = metadatas.mapNotNull { it.releaseDate }.minOrNull()
|
||||
|
||||
return BookMetadataAggregation(authors = authors, tags = tags, releaseDate = releaseDate, summary = summary, summaryNumber = summaryNumber)
|
||||
return BookMetadataAggregation(authors = authors, tags = tags, characters = characters, releaseDate = releaseDate, summary = summary, summaryNumber = summaryNumber)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class MetadataApplier {
|
|||
isbn = getIfNotLocked(isbn, patch.isbn, isbnLock),
|
||||
links = getIfNotLocked(links, patch.links, linksLock),
|
||||
tags = getIfNotLocked(tags, patch.tags, tagsLock),
|
||||
characters = getIfNotLocked(characters, patch.characters, charactersLock),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,33 @@ class BookSearchHelper(
|
|||
}
|
||||
} to emptySet()
|
||||
|
||||
is SearchCondition.Character ->
|
||||
Tables.BOOK.ID.let { field ->
|
||||
val innerEquals = { character: String ->
|
||||
DSL
|
||||
.select(Tables.BOOK_METADATA_CHARACTER.BOOK_ID)
|
||||
.from(Tables.BOOK_METADATA_CHARACTER)
|
||||
.where(
|
||||
Tables.BOOK_METADATA_CHARACTER.CHARACTER
|
||||
.collate(SqliteUdfDataSource.COLLATION_UNICODE_3)
|
||||
.equalIgnoreCase(character),
|
||||
)
|
||||
}
|
||||
val innerAny = {
|
||||
DSL
|
||||
.select(Tables.BOOK_METADATA_CHARACTER.BOOK_ID)
|
||||
.from(Tables.BOOK_METADATA_CHARACTER)
|
||||
.where(Tables.BOOK_METADATA_CHARACTER.CHARACTER.isNotNull)
|
||||
}
|
||||
|
||||
when (searchCondition.operator) {
|
||||
is SearchOperator.Is -> field.`in`(innerEquals(searchCondition.operator.value))
|
||||
is SearchOperator.IsNot -> field.notIn(innerEquals(searchCondition.operator.value))
|
||||
is SearchOperator.IsNullT<*> -> field.notIn(innerAny())
|
||||
is SearchOperator.IsNotNullT<*> -> field.`in`(innerAny())
|
||||
}
|
||||
} to emptySet()
|
||||
|
||||
is SearchCondition.Author ->
|
||||
Tables.BOOK.ID.let { field ->
|
||||
val inner = { name: String?, role: String? ->
|
||||
|
|
|
|||
|
|
@ -137,6 +137,33 @@ class SeriesSearchHelper(
|
|||
}
|
||||
} to emptySet()
|
||||
|
||||
is SearchCondition.Character ->
|
||||
Tables.SERIES.ID.let { field ->
|
||||
val innerEquals = { character: String ->
|
||||
DSL
|
||||
.select(Tables.BOOK_METADATA_AGGREGATION_CHARACTER.SERIES_ID)
|
||||
.from(Tables.BOOK_METADATA_AGGREGATION_CHARACTER)
|
||||
.where(
|
||||
Tables.BOOK_METADATA_AGGREGATION_CHARACTER.CHARACTER
|
||||
.collate(SqliteUdfDataSource.COLLATION_UNICODE_3)
|
||||
.equalIgnoreCase(character),
|
||||
)
|
||||
}
|
||||
val innerAny = {
|
||||
DSL
|
||||
.select(Tables.BOOK_METADATA_AGGREGATION_CHARACTER.SERIES_ID)
|
||||
.from(Tables.BOOK_METADATA_AGGREGATION_CHARACTER)
|
||||
.where(Tables.BOOK_METADATA_AGGREGATION_CHARACTER.CHARACTER.isNotNull)
|
||||
}
|
||||
|
||||
when (searchCondition.operator) {
|
||||
is SearchOperator.Is -> field.`in`(innerEquals(searchCondition.operator.value))
|
||||
is SearchOperator.IsNot -> field.notIn(innerEquals(searchCondition.operator.value))
|
||||
is SearchOperator.IsNullT<*> -> field.notIn(innerAny())
|
||||
is SearchOperator.IsNotNullT<*> -> field.`in`(innerAny())
|
||||
}
|
||||
} to emptySet()
|
||||
|
||||
is SearchCondition.Author ->
|
||||
Tables.SERIES.ID.let { field ->
|
||||
val inner = { name: String?, role: String? ->
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ class BookDtoDao(
|
|||
private val sd = Tables.SERIES_METADATA
|
||||
private val rlb = Tables.READLIST_BOOK
|
||||
private val bt = Tables.BOOK_METADATA_TAG
|
||||
private val bc = Tables.BOOK_METADATA_CHARACTER
|
||||
private val bl = Tables.BOOK_METADATA_LINK
|
||||
|
||||
private val onDeckFields = b.fields() + m.fields() + d.fields() + r.fields() + sd.TITLE
|
||||
|
|
@ -445,6 +446,7 @@ class BookDtoDao(
|
|||
|
||||
lateinit var authors: Map<String, List<AuthorDto>>
|
||||
lateinit var tags: Map<String, List<String>>
|
||||
lateinit var characters: Map<String, List<String>>
|
||||
lateinit var links: Map<String, List<WebLinkDto>>
|
||||
dsl.withTempTable(batchSize, bookIds).use { tempTable ->
|
||||
authors =
|
||||
|
|
@ -460,6 +462,12 @@ class BookDtoDao(
|
|||
.where(bt.BOOK_ID.`in`(tempTable.selectTempStrings()))
|
||||
.groupBy({ it.bookId }, { it.tag })
|
||||
|
||||
characters =
|
||||
dsl
|
||||
.selectFrom(bc)
|
||||
.where(bc.BOOK_ID.`in`(tempTable.selectTempStrings()))
|
||||
.groupBy({ it.bookId }, { it.character })
|
||||
|
||||
links =
|
||||
dsl
|
||||
.selectFrom(bl)
|
||||
|
|
@ -475,7 +483,7 @@ class BookDtoDao(
|
|||
val rr = rec.into(r)
|
||||
val seriesTitle = rec.into(sd.TITLE).component1()
|
||||
|
||||
br.toDto(mr.toDto(), dr.toDto(authors[br.id].orEmpty(), tags[br.id].orEmpty().toSet(), links[br.id].orEmpty()), if (rr.userId != null) rr.toDto() else null, seriesTitle)
|
||||
br.toDto(mr.toDto(), dr.toDto(authors[br.id].orEmpty(), tags[br.id].orEmpty().toSet(), characters[br.id].orEmpty().toSet(), links[br.id].orEmpty()), if (rr.userId != null) rr.toDto() else null, seriesTitle)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -517,6 +525,7 @@ class BookDtoDao(
|
|||
private fun BookMetadataRecord.toDto(
|
||||
authors: List<AuthorDto>,
|
||||
tags: Set<String>,
|
||||
characters: Set<String>,
|
||||
links: List<WebLinkDto>,
|
||||
) = BookMetadataDto(
|
||||
title = title,
|
||||
|
|
@ -533,6 +542,8 @@ class BookDtoDao(
|
|||
authorsLock = authorsLock,
|
||||
tags = tags,
|
||||
tagsLock = tagsLock,
|
||||
characters = characters,
|
||||
charactersLock = charactersLock,
|
||||
isbn = isbn,
|
||||
isbnLock = isbnLock,
|
||||
links = links,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ class BookMetadataAggregationDao(
|
|||
private val d = Tables.BOOK_METADATA_AGGREGATION
|
||||
private val a = Tables.BOOK_METADATA_AGGREGATION_AUTHOR
|
||||
private val t = Tables.BOOK_METADATA_AGGREGATION_TAG
|
||||
private val c = Tables.BOOK_METADATA_AGGREGATION_CHARACTER
|
||||
|
||||
override fun findById(seriesId: String): BookMetadataAggregation = dslRO.findOne(listOf(seriesId)).first()
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ class BookMetadataAggregationDao(
|
|||
{ it.into(d) },
|
||||
{ it.into(a) },
|
||||
).map { (dr, ar) ->
|
||||
dr.toDomain(ar.filterNot { it.name == null }.map { it.toDomain() }, this.findTags(dr.seriesId))
|
||||
dr.toDomain(ar.filterNot { it.name == null }.map { it.toDomain() }, this.findTags(dr.seriesId), this.findCharacters(dr.seriesId))
|
||||
}
|
||||
|
||||
private fun DSLContext.findTags(seriesId: String) =
|
||||
|
|
@ -53,6 +54,13 @@ class BookMetadataAggregationDao(
|
|||
.where(t.SERIES_ID.eq(seriesId))
|
||||
.fetchSet(t.TAG)
|
||||
|
||||
private fun DSLContext.findCharacters(seriesId: String) =
|
||||
this
|
||||
.select(c.CHARACTER)
|
||||
.from(c)
|
||||
.where(c.SERIES_ID.eq(seriesId))
|
||||
.fetchSet(c.CHARACTER)
|
||||
|
||||
@Transactional
|
||||
override fun insert(metadata: BookMetadataAggregation) {
|
||||
dslRW
|
||||
|
|
@ -88,8 +96,14 @@ class BookMetadataAggregationDao(
|
|||
.where(t.SERIES_ID.eq(metadata.seriesId))
|
||||
.execute()
|
||||
|
||||
dslRW
|
||||
.deleteFrom(c)
|
||||
.where(c.SERIES_ID.eq(metadata.seriesId))
|
||||
.execute()
|
||||
|
||||
dslRW.insertAuthors(metadata)
|
||||
dslRW.insertTags(metadata)
|
||||
dslRW.insertCharacters(metadata)
|
||||
}
|
||||
|
||||
private fun DSLContext.insertAuthors(metadata: BookMetadataAggregation) {
|
||||
|
|
@ -126,10 +140,28 @@ class BookMetadataAggregationDao(
|
|||
}
|
||||
}
|
||||
|
||||
private fun DSLContext.insertCharacters(metadata: BookMetadataAggregation) {
|
||||
if (metadata.characters.isNotEmpty()) {
|
||||
metadata.characters.chunked(batchSize).forEach { chunk ->
|
||||
this
|
||||
.batch(
|
||||
this
|
||||
.insertInto(c, c.SERIES_ID, c.CHARACTER)
|
||||
.values(null as String?, null),
|
||||
).also { step ->
|
||||
chunk.forEach {
|
||||
step.bind(metadata.seriesId, it)
|
||||
}
|
||||
}.execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
override fun delete(seriesId: String) {
|
||||
dslRW.deleteFrom(a).where(a.SERIES_ID.eq(seriesId)).execute()
|
||||
dslRW.deleteFrom(t).where(t.SERIES_ID.eq(seriesId)).execute()
|
||||
dslRW.deleteFrom(c).where(c.SERIES_ID.eq(seriesId)).execute()
|
||||
dslRW.deleteFrom(d).where(d.SERIES_ID.eq(seriesId)).execute()
|
||||
}
|
||||
|
||||
|
|
@ -138,6 +170,7 @@ class BookMetadataAggregationDao(
|
|||
dslRW.withTempTable(batchSize, seriesIds).use {
|
||||
dslRW.deleteFrom(a).where(a.SERIES_ID.`in`(it.selectTempStrings())).execute()
|
||||
dslRW.deleteFrom(t).where(t.SERIES_ID.`in`(it.selectTempStrings())).execute()
|
||||
dslRW.deleteFrom(c).where(c.SERIES_ID.`in`(it.selectTempStrings())).execute()
|
||||
dslRW.deleteFrom(d).where(d.SERIES_ID.`in`(it.selectTempStrings())).execute()
|
||||
}
|
||||
}
|
||||
|
|
@ -147,9 +180,11 @@ class BookMetadataAggregationDao(
|
|||
private fun BookMetadataAggregationRecord.toDomain(
|
||||
authors: List<Author>,
|
||||
tags: Set<String>,
|
||||
characters: Set<String>,
|
||||
) = BookMetadataAggregation(
|
||||
authors = authors,
|
||||
tags = tags,
|
||||
characters = characters,
|
||||
releaseDate = releaseDate,
|
||||
summary = summary,
|
||||
summaryNumber = summaryNumber,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class BookMetadataDao(
|
|||
private val d = Tables.BOOK_METADATA
|
||||
private val a = Tables.BOOK_METADATA_AUTHOR
|
||||
private val bt = Tables.BOOK_METADATA_TAG
|
||||
private val bc = Tables.BOOK_METADATA_CHARACTER
|
||||
private val bl = Tables.BOOK_METADATA_LINK
|
||||
|
||||
private val groupFields = arrayOf(*d.fields(), *a.fields())
|
||||
|
|
@ -52,7 +53,7 @@ class BookMetadataDao(
|
|||
{ it.into(d) },
|
||||
{ it.into(a) },
|
||||
).map { (dr, ar) ->
|
||||
dr.toDomain(ar.filterNot { it.name == null }.map { it.toDomain() }, this.findTags(dr.bookId), this.findLinks(dr.bookId))
|
||||
dr.toDomain(ar.filterNot { it.name == null }.map { it.toDomain() }, this.findTags(dr.bookId), this.findCharacters(dr.bookId), this.findLinks(dr.bookId))
|
||||
}
|
||||
|
||||
private fun DSLContext.findTags(bookId: String) =
|
||||
|
|
@ -62,6 +63,13 @@ class BookMetadataDao(
|
|||
.where(bt.BOOK_ID.eq(bookId))
|
||||
.fetchSet(bt.TAG)
|
||||
|
||||
private fun DSLContext.findCharacters(bookId: String) =
|
||||
this
|
||||
.select(bc.CHARACTER)
|
||||
.from(bc)
|
||||
.where(bc.BOOK_ID.eq(bookId))
|
||||
.fetchSet(bc.CHARACTER)
|
||||
|
||||
private fun DSLContext.findLinks(bookId: String) =
|
||||
this
|
||||
.select(bl.LABEL, bl.URL)
|
||||
|
|
@ -97,10 +105,11 @@ class BookMetadataDao(
|
|||
d.RELEASE_DATE_LOCK,
|
||||
d.AUTHORS_LOCK,
|
||||
d.TAGS_LOCK,
|
||||
d.CHARACTERS_LOCK,
|
||||
d.ISBN,
|
||||
d.ISBN_LOCK,
|
||||
d.LINKS_LOCK,
|
||||
).values(null as String?, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null),
|
||||
).values(null as String?, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null),
|
||||
).also { step ->
|
||||
chunk.forEach {
|
||||
step.bind(
|
||||
|
|
@ -117,6 +126,7 @@ class BookMetadataDao(
|
|||
it.releaseDateLock,
|
||||
it.authorsLock,
|
||||
it.tagsLock,
|
||||
it.charactersLock,
|
||||
it.isbn,
|
||||
it.isbnLock,
|
||||
it.linksLock,
|
||||
|
|
@ -127,6 +137,7 @@ class BookMetadataDao(
|
|||
|
||||
dslRW.insertAuthors(metadatas)
|
||||
dslRW.insertTags(metadatas)
|
||||
dslRW.insertCharacters(metadatas)
|
||||
dslRW.insertLinks(metadatas)
|
||||
}
|
||||
}
|
||||
|
|
@ -156,6 +167,7 @@ class BookMetadataDao(
|
|||
.set(d.RELEASE_DATE_LOCK, metadata.releaseDateLock)
|
||||
.set(d.AUTHORS_LOCK, metadata.authorsLock)
|
||||
.set(d.TAGS_LOCK, metadata.tagsLock)
|
||||
.set(d.CHARACTERS_LOCK, metadata.charactersLock)
|
||||
.set(d.ISBN, metadata.isbn)
|
||||
.set(d.ISBN_LOCK, metadata.isbnLock)
|
||||
.set(d.LINKS_LOCK, metadata.linksLock)
|
||||
|
|
@ -171,6 +183,10 @@ class BookMetadataDao(
|
|||
.deleteFrom(bt)
|
||||
.where(bt.BOOK_ID.eq(metadata.bookId))
|
||||
.execute()
|
||||
dslRW
|
||||
.deleteFrom(bc)
|
||||
.where(bc.BOOK_ID.eq(metadata.bookId))
|
||||
.execute()
|
||||
dslRW
|
||||
.deleteFrom(bl)
|
||||
.where(bl.BOOK_ID.eq(metadata.bookId))
|
||||
|
|
@ -178,6 +194,7 @@ class BookMetadataDao(
|
|||
|
||||
dslRW.insertAuthors(listOf(metadata))
|
||||
dslRW.insertTags(listOf(metadata))
|
||||
dslRW.insertCharacters(listOf(metadata))
|
||||
dslRW.insertLinks(listOf(metadata))
|
||||
}
|
||||
|
||||
|
|
@ -219,6 +236,25 @@ class BookMetadataDao(
|
|||
}
|
||||
}
|
||||
|
||||
private fun DSLContext.insertCharacters(metadatas: Collection<BookMetadata>) {
|
||||
if (metadatas.any { it.characters.isNotEmpty() }) {
|
||||
metadatas.chunked(batchSize).forEach { chunk ->
|
||||
this
|
||||
.batch(
|
||||
this
|
||||
.insertInto(bc, bc.BOOK_ID, bc.CHARACTER)
|
||||
.values(null as String?, null),
|
||||
).also { step ->
|
||||
chunk.forEach { metadata ->
|
||||
metadata.characters.forEach {
|
||||
step.bind(metadata.bookId, it)
|
||||
}
|
||||
}
|
||||
}.execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLContext.insertLinks(metadatas: Collection<BookMetadata>) {
|
||||
if (metadatas.any { it.links.isNotEmpty() }) {
|
||||
metadatas.chunked(batchSize).forEach { chunk ->
|
||||
|
|
@ -242,6 +278,7 @@ class BookMetadataDao(
|
|||
override fun delete(bookId: String) {
|
||||
dslRW.deleteFrom(a).where(a.BOOK_ID.eq(bookId)).execute()
|
||||
dslRW.deleteFrom(bt).where(bt.BOOK_ID.eq(bookId)).execute()
|
||||
dslRW.deleteFrom(bc).where(bc.BOOK_ID.eq(bookId)).execute()
|
||||
dslRW.deleteFrom(bl).where(bl.BOOK_ID.eq(bookId)).execute()
|
||||
dslRW.deleteFrom(d).where(d.BOOK_ID.eq(bookId)).execute()
|
||||
}
|
||||
|
|
@ -251,6 +288,7 @@ class BookMetadataDao(
|
|||
dslRW.withTempTable(batchSize, bookIds).use {
|
||||
dslRW.deleteFrom(a).where(a.BOOK_ID.`in`(it.selectTempStrings())).execute()
|
||||
dslRW.deleteFrom(bt).where(bt.BOOK_ID.`in`(it.selectTempStrings())).execute()
|
||||
dslRW.deleteFrom(bc).where(bc.BOOK_ID.`in`(it.selectTempStrings())).execute()
|
||||
dslRW.deleteFrom(bl).where(bl.BOOK_ID.`in`(it.selectTempStrings())).execute()
|
||||
dslRW.deleteFrom(d).where(d.BOOK_ID.`in`(it.selectTempStrings())).execute()
|
||||
}
|
||||
|
|
@ -261,6 +299,7 @@ class BookMetadataDao(
|
|||
private fun BookMetadataRecord.toDomain(
|
||||
authors: List<Author>,
|
||||
tags: Set<String>,
|
||||
characters: Set<String>,
|
||||
links: List<WebLink>,
|
||||
) = BookMetadata(
|
||||
title = title,
|
||||
|
|
@ -270,6 +309,7 @@ class BookMetadataDao(
|
|||
releaseDate = releaseDate,
|
||||
authors = authors,
|
||||
tags = tags,
|
||||
characters = characters,
|
||||
isbn = isbn,
|
||||
links = links,
|
||||
bookId = bookId,
|
||||
|
|
@ -282,6 +322,7 @@ class BookMetadataDao(
|
|||
releaseDateLock = releaseDateLock,
|
||||
authorsLock = authorsLock,
|
||||
tagsLock = tagsLock,
|
||||
charactersLock = charactersLock,
|
||||
isbnLock = isbnLock,
|
||||
linksLock = linksLock,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class ReferentialDao(
|
|||
private val g = Tables.SERIES_METADATA_GENRE
|
||||
private val bt = Tables.BOOK_METADATA_TAG
|
||||
private val st = Tables.SERIES_METADATA_TAG
|
||||
private val bc = Tables.BOOK_METADATA_CHARACTER
|
||||
private val cs = Tables.COLLECTION_SERIES
|
||||
private val rb = Tables.READLIST_BOOK
|
||||
private val sl = Tables.SERIES_METADATA_SHARING
|
||||
|
|
@ -341,6 +342,49 @@ class ReferentialDao(
|
|||
.sortedBy { it.stripAccents().lowercase() }
|
||||
.toSet()
|
||||
|
||||
override fun findAllSeriesAndBookCharacters(filterOnLibraryIds: Collection<String>?): Set<String> =
|
||||
dslRO
|
||||
.select(bc.CHARACTER.`as`("character"))
|
||||
.from(bc)
|
||||
.apply { filterOnLibraryIds?.let { leftJoin(b).on(bc.BOOK_ID.eq(b.ID)).where(b.LIBRARY_ID.`in`(it)) } }
|
||||
.fetchSet(0, String::class.java)
|
||||
.sortedBy { it.stripAccents().lowercase() }
|
||||
.toSet()
|
||||
|
||||
override fun findAllSeriesAndBookCharactersByLibraries(
|
||||
libraryIds: Set<String>,
|
||||
filterOnLibraryIds: Collection<String>?,
|
||||
): Set<String> =
|
||||
dslRO
|
||||
.select(bc.CHARACTER.`as`("character"))
|
||||
.from(bc)
|
||||
.leftJoin(b)
|
||||
.on(bc.BOOK_ID.eq(b.ID))
|
||||
.where(b.LIBRARY_ID.`in`(libraryIds))
|
||||
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
|
||||
.fetchSet(0, String::class.java)
|
||||
.sortedBy { it.stripAccents().lowercase() }
|
||||
.toSet()
|
||||
|
||||
override fun findAllSeriesAndBookCharactersByCollection(
|
||||
collectionId: String,
|
||||
filterOnLibraryIds: Collection<String>?,
|
||||
): Set<String> =
|
||||
dslRO
|
||||
.select(bc.CHARACTER.`as`("character"))
|
||||
.from(bc)
|
||||
.leftJoin(b)
|
||||
.on(bc.BOOK_ID.eq(b.ID))
|
||||
.leftJoin(s)
|
||||
.on(b.SERIES_ID.eq(s.ID))
|
||||
.leftJoin(cs)
|
||||
.on(s.ID.eq(cs.SERIES_ID))
|
||||
.where(cs.COLLECTION_ID.eq(collectionId))
|
||||
.apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } }
|
||||
.fetchSet(0, String::class.java)
|
||||
.sortedBy { it.stripAccents().lowercase() }
|
||||
.toSet()
|
||||
|
||||
override fun findAllSeriesTags(filterOnLibraryIds: Collection<String>?): Set<String> =
|
||||
dslRO
|
||||
.select(st.TAG)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ class SeriesDtoDao(
|
|||
private val bma = Tables.BOOK_METADATA_AGGREGATION
|
||||
private val bmaa = Tables.BOOK_METADATA_AGGREGATION_AUTHOR
|
||||
private val bmat = Tables.BOOK_METADATA_AGGREGATION_TAG
|
||||
private val bmac = Tables.BOOK_METADATA_AGGREGATION_CHARACTER
|
||||
|
||||
private val groupFields =
|
||||
arrayOf(
|
||||
|
|
@ -310,6 +311,7 @@ class SeriesDtoDao(
|
|||
lateinit var alternateTitles: Map<String, List<AlternateTitleDto>>
|
||||
lateinit var aggregatedAuthors: Map<String, List<AuthorDto>>
|
||||
lateinit var aggregatedTags: Map<String, List<String>>
|
||||
lateinit var aggregatedCharacters: Map<String, List<String>>
|
||||
|
||||
dsl.withTempTable(batchSize, seriesIds).use { tempTable ->
|
||||
genres =
|
||||
|
|
@ -354,6 +356,12 @@ class SeriesDtoDao(
|
|||
.selectFrom(bmat)
|
||||
.where(bmat.SERIES_ID.`in`(tempTable.selectTempStrings()))
|
||||
.groupBy({ it.seriesId }, { it.tag })
|
||||
|
||||
aggregatedCharacters =
|
||||
dsl
|
||||
.selectFrom(bmac)
|
||||
.where(bmac.SERIES_ID.`in`(tempTable.selectTempStrings()))
|
||||
.groupBy({ it.seriesId }, { it.character })
|
||||
}
|
||||
|
||||
return records
|
||||
|
|
@ -372,7 +380,7 @@ class SeriesDtoDao(
|
|||
booksUnreadCount,
|
||||
booksInProgressCount,
|
||||
dr.toDto(genres[sr.id].orEmpty().toSet(), tags[sr.id].orEmpty().toSet(), sharingLabels[sr.id].orEmpty().toSet(), links[sr.id].orEmpty(), alternateTitles[sr.id].orEmpty()),
|
||||
bmar.toDto(aggregatedAuthors[sr.id].orEmpty(), aggregatedTags[sr.id].orEmpty().toSet()),
|
||||
bmar.toDto(aggregatedAuthors[sr.id].orEmpty(), aggregatedTags[sr.id].orEmpty().toSet(), aggregatedCharacters[sr.id].orEmpty().toSet()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -450,9 +458,11 @@ class SeriesDtoDao(
|
|||
private fun BookMetadataAggregationRecord.toDto(
|
||||
authors: List<AuthorDto>,
|
||||
tags: Set<String>,
|
||||
characters: Set<String>,
|
||||
) = BookMetadataAggregationDto(
|
||||
authors = authors,
|
||||
tags = tags,
|
||||
characters = characters,
|
||||
releaseDate = releaseDate,
|
||||
summary = summary,
|
||||
summaryNumber = summaryNumber,
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ class ComicInfoProvider(
|
|||
}
|
||||
|
||||
val tags = comicInfo.tags?.split(',')?.mapNotNull { it.trim().lowercase().ifBlank { null } }
|
||||
val characters = comicInfo.characters?.split(',')?.mapNotNull { it.trim().lowercase().ifBlank { null } }
|
||||
|
||||
val isbn = comicInfo.gtin?.let { isbnValidator.validate(it) }
|
||||
|
||||
|
|
@ -116,6 +117,7 @@ class ComicInfoProvider(
|
|||
readLists = readLists,
|
||||
links = links?.ifEmpty { null },
|
||||
tags = if (!tags.isNullOrEmpty()) tags.toSet() else null,
|
||||
characters = if (!characters.isNullOrEmpty()) characters.toSet() else null,
|
||||
isbn = isbn,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,19 @@ class ReferentialController(
|
|||
else -> referentialRepository.findAllBookTags(principal.user.getAuthorizedLibraryIds(null))
|
||||
}
|
||||
|
||||
@GetMapping("v1/characters")
|
||||
@Operation(summary = "List characters", description = "Can be filtered by various criteria")
|
||||
fun getCharacters(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@RequestParam(name = "library_id", required = false) libraryIds: Set<String> = emptySet(),
|
||||
@RequestParam(name = "collection_id", required = false) collectionId: String?,
|
||||
): Set<String> =
|
||||
when {
|
||||
libraryIds.isNotEmpty() -> referentialRepository.findAllSeriesAndBookCharactersByLibraries(libraryIds, principal.user.getAuthorizedLibraryIds(null))
|
||||
collectionId != null -> referentialRepository.findAllSeriesAndBookCharactersByCollection(collectionId, principal.user.getAuthorizedLibraryIds(null))
|
||||
else -> referentialRepository.findAllSeriesAndBookCharacters(principal.user.getAuthorizedLibraryIds(null))
|
||||
}
|
||||
|
||||
@GetMapping("v1/tags/series")
|
||||
@Operation(summary = "List series tags", description = "Can be filtered by various criteria")
|
||||
fun getSeriesTags(
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ data class BookMetadataDto(
|
|||
val authorsLock: Boolean,
|
||||
val tags: Set<String>,
|
||||
val tagsLock: Boolean,
|
||||
val characters: Set<String>,
|
||||
val charactersLock: Boolean,
|
||||
val isbn: String,
|
||||
val isbnLock: Boolean,
|
||||
val links: List<WebLinkDto>,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,13 @@ class BookMetadataUpdateDto {
|
|||
|
||||
var tagsLock: Boolean? = null
|
||||
|
||||
var characters: Set<String>?
|
||||
by Delegates.observable(null) { prop, _, _ ->
|
||||
isSet[prop.name] = true
|
||||
}
|
||||
|
||||
var charactersLock: Boolean? = null
|
||||
|
||||
@get:NullOrBlankOrISBN
|
||||
var isbn: String?
|
||||
by Delegates.observable(null) { prop, _, _ ->
|
||||
|
|
@ -120,6 +127,13 @@ fun BookMetadata.patch(patch: BookMetadataUpdateDto) =
|
|||
this.tags
|
||||
},
|
||||
tagsLock = patch.tagsLock ?: this.tagsLock,
|
||||
characters =
|
||||
if (patch.isSet("characters")) {
|
||||
if (patch.characters != null) patch.characters!! else emptySet()
|
||||
} else {
|
||||
this.characters
|
||||
},
|
||||
charactersLock = patch.charactersLock ?: this.charactersLock,
|
||||
isbn = if (patch.isSet("isbn")) patch.isbn?.filter { it.isDigit() } ?: "" else this.isbn,
|
||||
isbnLock = patch.isbnLock ?: this.isbnLock,
|
||||
links =
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ data class SeriesMetadataDto(
|
|||
data class BookMetadataAggregationDto(
|
||||
val authors: List<AuthorDto> = emptyList(),
|
||||
val tags: Set<String> = emptySet(),
|
||||
val characters: Set<String> = emptySet(),
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
val releaseDate: LocalDate?,
|
||||
val summary: String,
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class TransientBookLifecycleTest(
|
|||
Media(),
|
||||
)
|
||||
|
||||
every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, 15F, null, null, null, null, null, emptyList())
|
||||
every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, 15F, null, null, null, null, null, emptySet())
|
||||
every { mockProvider.getSeriesMetadataFromBook(any(), any()) } returns SeriesMetadataPatch("BATMAN", null, null, null, null, null, null, null, null, null, emptySet())
|
||||
|
||||
val (seriesId, number) = transientBookLifecycle.getMetadata(book)
|
||||
|
|
@ -82,7 +82,7 @@ class TransientBookLifecycleTest(
|
|||
Media(),
|
||||
)
|
||||
|
||||
every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, null, null, null, null, null, null, emptyList())
|
||||
every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, null, null, null, null, null, null, emptySet())
|
||||
every { mockProvider.getSeriesMetadataFromBook(any(), any()) } returns SeriesMetadataPatch(null, null, null, null, null, null, null, null, null, null, emptySet())
|
||||
|
||||
val (seriesId, number) = transientBookLifecycle.getMetadata(book)
|
||||
|
|
@ -104,7 +104,7 @@ class TransientBookLifecycleTest(
|
|||
Media(),
|
||||
)
|
||||
|
||||
every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, null, null, null, null, null, null, emptyList())
|
||||
every { mockProvider.getBookMetadataFromBook(any()) } returns BookMetadataPatch(null, null, null, null, null, null, null, null, null, emptySet())
|
||||
every { mockProvider.getSeriesMetadataFromBook(any(), any()) } returns SeriesMetadataPatch(" ", null, null, null, null, null, null, null, null, null, emptySet())
|
||||
|
||||
val (seriesId, number) = transientBookLifecycle.getMetadata(book)
|
||||
|
|
|
|||
|
|
@ -95,6 +95,24 @@ class ComicInfoProviderTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given comicInfo with characters when getting book metadata then characters are parsed correctly`() {
|
||||
val comicInfo =
|
||||
ComicInfo().apply {
|
||||
characters = "Spider-Man, Mary Jane Watson, Green Goblin"
|
||||
}
|
||||
|
||||
every { mockMapper.readValue(any<ByteArray>(), ComicInfo::class.java) } returns comicInfo
|
||||
|
||||
val patch = comicInfoProvider.getBookMetadataFromBook(BookWithMedia(book, media))
|
||||
|
||||
with(patch!!) {
|
||||
assertThat(characters as Iterable<String>)
|
||||
.hasSize(3)
|
||||
.containsExactlyInAnyOrder("spider-man", "mary jane watson", "green goblin")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given comicInfo with single link when getting book metadata then metadata patch is valid`() {
|
||||
val comicInfo =
|
||||
|
|
|
|||
Loading…
Reference in a new issue