This commit is contained in:
Irodzuita 2025-12-01 14:18:37 +08:00 committed by GitHub
commit 1ba896ef9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 674 additions and 15 deletions

16
.gitignore vendored
View file

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

@ -0,0 +1,3 @@
{
"java.compile.nullAnalysis.mode": "disabled"
}

View file

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

View file

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

View file

@ -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 = []

View file

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

View file

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

View file

@ -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[],

View file

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

View file

@ -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[],

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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/"]

View file

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

View file

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

View file

@ -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 = "",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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