diff --git a/komga-webui/src/components/FilterList.vue b/komga-webui/src/components/FilterList.vue index db887de04..3f93192b3 100644 --- a/komga-webui/src/components/FilterList.vue +++ b/komga-webui/src/components/FilterList.vue @@ -5,14 +5,14 @@ > {{ f.name }} - + mdi-minus-box - + mdi-checkbox-marked @@ -41,16 +41,24 @@ export default Vue.extend({ }, }, methods: { - click(key: string, value: string, nValue?: string) { + includes(array: any[], value: any): boolean { + return this.$_.isObject(value) ? this.$_.some(array, value) : this.$_.includes(array, value) + }, + click(key: string, value: any, nValue?: any) { let r = this.$_.cloneDeep(this.filtersActive) if (!(key in r)) r[key] = [] - if (nValue && r[key].includes(nValue)) - this.$_.pull(r[key], (nValue)) - else if (r[key].includes(value)) { - this.$_.pull(r[key], (value)) + + const pull = this.$_.isObject(value) ? this.$_.remove : this.$_.pull + const includes = this.$_.isObject(value) ? this.$_.some : this.$_.includes + + if (nValue && includes(r[key], nValue)) + pull(r[key], nValue) + else if (includes(r[key], value)) { + pull(r[key], value) if (nValue) r[key].push(nValue) - } else r[key].push(value) + } else + r[key].push(value) this.$emit('update:filtersActive', r) }, diff --git a/komga-webui/src/components/FilterPanels.vue b/komga-webui/src/components/FilterPanels.vue index bd97e04ad..72f416c39 100644 --- a/komga-webui/src/components/FilterPanels.vue +++ b/komga-webui/src/components/FilterPanels.vue @@ -5,11 +5,18 @@ :key="key" :disabled="(f.values && f.values.length === 0) && !f.search" > - + {{ groupAllOfActive(key) ? 'mdi-filter-multiple' : '' }} + + {{ groupActive(key) ? 'mdi-checkbox-marked' : '' }} @@ -26,6 +33,28 @@ +
+ + + + {{ $t('common.any_of') }} + + + + + {{ $t('common.all_of') }} + + +
+ - - - - - mdi-checkbox-marked - - - mdi-checkbox-blank-outline - - - {{ v.name }} - - +
+ + + + + mdi-minus-box + + + mdi-checkbox-marked + + + mdi-checkbox-blank-outline + + + {{ v.name }} + + +
@@ -81,11 +112,19 @@ export default Vue.extend({ type: Object as PropType, required: true, }, + filtersActiveMode: { + type: Object as PropType, + required: false, + }, }, methods: { + includes(array: any[], value: any): boolean { + return this.$_.isObject(value) ? this.$_.some(array, value) : this.$_.includes(array, value) + }, clear(key: string) { let r = this.$_.cloneDeep(this.filtersActive) r[key] = [] + if(!this.filtersOptions[key].anyAllSelector) this.clickFilterMode(key, false) this.$emit('update:filtersActive', r) }, @@ -93,14 +132,39 @@ export default Vue.extend({ if (!(key in this.filtersActive)) return false return this.filtersActive[key].length > 0 }, - click(key: string, value: string) { + groupAllOfActive(key: string): boolean { + if (!this.filtersActiveMode || !(key in this.filtersActiveMode)) return false + return this.filtersActiveMode[key].allOf + }, + click(key: string, value: any, nValue?: any) { let r = this.$_.cloneDeep(this.filtersActive) if (!(key in r)) r[key] = [] - if (r[key].includes(value)) this.$_.pull(r[key], (value)) - else r[key].push(value) + + const pull = this.$_.isObject(value) ? this.$_.remove : this.$_.pull + const includes = this.$_.isObject(value) ? this.$_.some : this.$_.includes + + if (nValue && includes(r[key], nValue)) + pull(r[key], nValue) + else if (includes(r[key], value)) { + pull(r[key], value) + if (nValue) { + r[key].push(nValue) + this.clickFilterMode(key, true) + } + } else + r[key].push(value) + + if(!this.filtersOptions[key].anyAllSelector && r[key].length == 0) this.clickFilterMode(key, false) this.$emit('update:filtersActive', r) }, + clickFilterMode(key: string, value: boolean) { + if(!this.filtersActiveMode) return + let r = this.$_.cloneDeep(this.filtersActiveMode) + r[key] = {allOf: value} + + this.$emit('update:filtersActiveMode', r) + }, }, }) @@ -109,4 +173,10 @@ export default Vue.extend({ .no-padding .v-expansion-panel-content__wrap { padding: 0; } +.semi-transparent { + opacity: 0.5; +} +.semi-transparent:hover { + opacity: 1; +} diff --git a/komga-webui/src/components/SearchBox.vue b/komga-webui/src/components/SearchBox.vue index 2b16198cd..b20dbfa09 100644 --- a/komga-webui/src/components/SearchBox.vue +++ b/komga-webui/src/components/SearchBox.vue @@ -123,6 +123,11 @@ import {SeriesDto} from '@/types/komga-series' import {getReadProgress} from '@/functions/book-progress' import {ReadStatus} from '@/types/enum-books' import {ReadListDto} from '@/types/komga-readlists' +import { + SearchConditionOneShot, + SearchOperatorIsFalse, + SeriesSearch, +} from '@/types/komga-search' export default Vue.extend({ name: 'SearchBox', @@ -202,7 +207,10 @@ export default Vue.extend({ searchItems: debounce(async function (this: any, query: string) { if (query) { this.loading = true - this.series = (await this.$komgaSeries.getSeries(undefined, {size: this.pageSize}, query, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, false)).content + this.series = (await this.$komgaSeries.getSeriesList({ + fullTextSearch: query, + condition: new SearchConditionOneShot(new SearchOperatorIsFalse()), + } as SeriesSearch, {size: this.pageSize})).content this.books = (await this.$komgaBooks.getBooks(undefined, {size: this.pageSize}, query)).content this.collections = (await this.$komgaCollections.getCollections(undefined, {size: this.pageSize}, query)).content this.readLists = (await this.$komgaReadLists.getReadLists(undefined, {size: this.pageSize}, query)).content diff --git a/komga-webui/src/components/dialogs/SeriesPickerDialog.vue b/komga-webui/src/components/dialogs/SeriesPickerDialog.vue index cd7a66b51..24ffa4e2e 100644 --- a/komga-webui/src/components/dialogs/SeriesPickerDialog.vue +++ b/komga-webui/src/components/dialogs/SeriesPickerDialog.vue @@ -82,6 +82,7 @@ import Vue, {PropType} from 'vue' import {SeriesDto} from '@/types/komga-series' import {debounce} from 'lodash' import {seriesThumbnailUrl} from '@/functions/urls' +import {SearchConditionOneShot, SearchOperatorIsFalse, SeriesSearch} from '@/types/komga-search' export default Vue.extend({ name: 'SeriesPickerDialog', @@ -123,7 +124,10 @@ export default Vue.extend({ searchItems: debounce(async function (this: any, query: string) { if (query) { this.showResults = false - this.results = (await this.$komgaSeries.getSeries(undefined, {unpaged: true}, query, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, this.includeOneshots ? undefined : false)).content + this.results = (await this.$komgaSeries.getSeriesList({ + fullTextSearch: query, + condition: this.includeOneshots ? undefined : new SearchConditionOneShot(new SearchOperatorIsFalse()), + } as SeriesSearch, {unpaged: true})).content this.showResults = true } else { this.clear() diff --git a/komga-webui/src/functions/filter.ts b/komga-webui/src/functions/filter.ts index 7bbdd61d5..a6d1733af 100644 --- a/komga-webui/src/functions/filter.ts +++ b/komga-webui/src/functions/filter.ts @@ -1,4 +1,6 @@ -export function sortOrFilterActive (sortActive: SortActive, sortDefault: SortActive, filters: FiltersActive): boolean { +import {SearchConditionSeries} from '@/types/komga-search' + +export function sortOrFilterActive(sortActive: SortActive, sortDefault: SortActive, filters: FiltersActive): boolean { const sortCustom = sortActive.key !== sortDefault.key || sortActive.order !== sortDefault.order const filterCustom = Object.keys(filters).some(x => filters[x].length !== 0) return sortCustom || filterCustom @@ -10,6 +12,19 @@ export function mergeFilterParams (filter: FiltersActive, query: any) { } } -export function toNameValue (list: string[]): NameValue[] { - return list.map(x => ({ name: x, value: x } as NameValue)) +export function toNameValue(list: string[]): NameValue[] { + return list.map(x => ({name: x, value: x} as NameValue)) +} + +export function toNameValueCondition(list: string[], valueSupplier: (x: any) => SearchConditionSeries, nValueSupplier?: (x: any) => SearchConditionSeries): NameValue[] { + return list.map(x => ({name: x, value: valueSupplier(x), nValue: nValueSupplier ? nValueSupplier(x) : undefined} as NameValue)) +} + +export function extractFilterOptionsValues(options: NameValue[] | undefined): any[] { + const r: any[] = [] + options?.forEach(x => { + r.push(x.value) + if (x.nValue) r.push(x.nValue) + }) + return r } diff --git a/komga-webui/src/functions/object.ts b/komga-webui/src/functions/object.ts new file mode 100644 index 000000000..e302d6d46 --- /dev/null +++ b/komga-webui/src/functions/object.ts @@ -0,0 +1,3 @@ +export function objIsEqual(o1: any, o2: any): boolean { + return JSON.stringify(o1) === JSON.stringify(o2) +} diff --git a/komga-webui/src/locales/en.json b/komga-webui/src/locales/en.json index 14d18e578..a92b9e569 100644 --- a/komga-webui/src/locales/en.json +++ b/komga-webui/src/locales/en.json @@ -201,6 +201,8 @@ "common": { "age": "Age", "all_libraries": "All Libraries", + "all_of": "All of", + "any_of": "Any of", "book": "Book", "books": "Books", "books_n": "No book | 1 book | {count} books", diff --git a/komga-webui/src/plugins/persisted-state.ts b/komga-webui/src/plugins/persisted-state.ts index 061a83f7a..36ae9c10a 100644 --- a/komga-webui/src/plugins/persisted-state.ts +++ b/komga-webui/src/plugins/persisted-state.ts @@ -29,7 +29,11 @@ export const persistedModule: Module = { filter: {}, }, library: { + // DEPRECATED: this is the old filter, before criteria-dsl was introduced filter: {}, + // this is the criteria-dsl filter, incompatible with the previous one + filterDsl: {}, + filterMode: {}, sort: {}, route: {}, }, @@ -63,7 +67,10 @@ export const persistedModule: Module = { return state.readList.filter[id] }, getLibraryFilter: (state) => (id: string) => { - return state.library.filter[id] + return state.library.filterDsl[id] + }, + getLibraryFilterMode: (state) => (id: string) => { + return state.library.filterMode[id] }, getLibrarySort: (state) => (id: string) => { return state.library.sort[id] @@ -119,7 +126,10 @@ export const persistedModule: Module = { state.readList.filter[id] = filter }, setLibraryFilter(state, {id, filter}) { - state.library.filter[id] = filter + state.library.filterDsl[id] = filter + }, + setLibraryFilterMode(state, {id, filterMode: filterMode}) { + state.library.filterMode[id] = filterMode }, setLibrarySort(state, {id, sort}) { state.library.sort[id] = sort diff --git a/komga-webui/src/services/komga-series.service.ts b/komga-webui/src/services/komga-series.service.ts index 49989adcd..9cdc83680 100644 --- a/komga-webui/src/services/komga-series.service.ts +++ b/komga-webui/src/services/komga-series.service.ts @@ -1,6 +1,7 @@ import {AxiosInstance} from 'axios' import {AuthorDto, BookDto} from '@/types/komga-books' import {GroupCountDto, SeriesDto, SeriesMetadataUpdateDto, SeriesThumbnailDto} from '@/types/komga-series' +import {SeriesSearch} from '@/types/komga-search' const qs = require('qs') @@ -48,6 +49,21 @@ export default class KomgaSeriesService { } } + async getSeriesList(search: SeriesSearch, pageRequest?: PageRequest): Promise> { + try { + return (await this.http.post(`${API_SERIES}/list`, search, { + params: {...pageRequest}, + paramsSerializer: params => qs.stringify(params, {indices: false}), + })).data + } catch (e) { + let msg = 'An error occurred while trying to retrieve series' + if (e.response.data.message) { + msg += `: ${e.response.data.message}` + } + throw new Error(msg) + } + } + async getAlphabeticalGroups(libraryId?: string, search?: string, status?: string[], readStatus?: string[], genre?: string[], tag?: string[], language?: string[], publisher?: string[], ageRating?: string[], releaseDate?: string[], authors?: AuthorDto[], @@ -82,6 +98,18 @@ export default class KomgaSeriesService { } } + async getSeriesListByAlphabeticalGroups(search: SeriesSearch): Promise{ + try { + return (await this.http.post(`${API_SERIES}/list/alphabetical-groups`, search)).data + } catch (e) { + let msg = 'An error occurred while trying to retrieve series alphabetical groups' + if (e.response.data.message) { + msg += `: ${e.response.data.message}` + } + throw new Error(msg) + } + } + async getNewSeries(libraryId?: string, oneshot?: boolean, pageRequest?: PageRequest): Promise> { try { const params = {...pageRequest} as any diff --git a/komga-webui/src/types/filter.ts b/komga-webui/src/types/filter.ts index 358b27825..8d5cbc625 100644 --- a/komga-webui/src/types/filter.ts +++ b/komga-webui/src/types/filter.ts @@ -3,16 +3,25 @@ interface FiltersOptions { name?: string, values?: NameValue[], search?: (search: string) => Promise, + anyAllSelector?: boolean, }, } interface NameValue { name: string, - value: string, + value: any, // an optional negative value - nValue?: string, + nValue?: any, } interface FiltersActive { - [key: string]: string[], + [key: string]: any[], +} + +interface FiltersActiveMode { + [key: string]: FilterMode, +} + +interface FilterMode { + allOf: boolean, } diff --git a/komga-webui/src/types/komga-search.ts b/komga-webui/src/types/komga-search.ts new file mode 100644 index 000000000..8572a30fa --- /dev/null +++ b/komga-webui/src/types/komga-search.ts @@ -0,0 +1,236 @@ +export interface SeriesSearch { + condition?: SearchConditionSeries, + fullTextSearch?: string, +} + +export interface SearchConditionSeries { +} + +export interface SearchConditionBook { +} + +export interface SearchConditionAnyOfBook extends SearchConditionBook { + anyOf: SearchConditionBook[], +} + +export interface SearchConditionAllOfBook extends SearchConditionBook { + allOf: SearchConditionBook[], +} + +export class SearchConditionAnyOfSeries implements SearchConditionSeries { + anyOf: SearchConditionSeries[] + + constructor(conditions: SearchConditionSeries[]) { + this.anyOf = conditions + } +} + +export class SearchConditionAllOfSeries implements SearchConditionSeries { + allOf: SearchConditionSeries[] + + constructor(conditions: SearchConditionSeries[]) { + this.allOf = conditions + } +} + +export class SearchConditionLibraryId implements SearchConditionBook, SearchConditionSeries { + libraryId: SearchOperatorEquality + + constructor(op: SearchOperatorEquality) { + this.libraryId = op + } +} + +export class SearchConditionSeriesStatus implements SearchConditionSeries { + seriesStatus: SearchOperatorEquality + + constructor(op: SearchOperatorEquality) { + this.seriesStatus = op + } +} + +export class SearchConditionReadStatus implements SearchConditionBook, SearchConditionSeries { + readStatus: SearchOperatorEquality + + constructor(op: SearchOperatorEquality) { + this.readStatus = op + } +} + +export class SearchConditionGenre implements SearchConditionSeries { + genre: SearchOperatorEquality + + constructor(op: SearchOperatorEquality) { + this.genre = op + } +} + +export class SearchConditionTag implements SearchConditionBook, SearchConditionSeries { + tag: SearchOperatorEquality + + constructor(op: SearchOperatorEquality) { + this.tag = op + } +} + +export class SearchConditionLanguage implements SearchConditionSeries { + language: SearchOperatorEquality + + constructor(op: SearchOperatorEquality) { + this.language = op + } +} + +export class SearchConditionPublisher implements SearchConditionSeries { + publisher: SearchOperatorEquality + + constructor(op: SearchOperatorEquality) { + this.publisher = op + } +} + +export class SearchConditionAgeRating implements SearchConditionSeries { + ageRating: SearchOperatorNumericNullable + + constructor(op: SearchOperatorNumericNullable) { + this.ageRating = op + } +} + +export class SearchConditionReleaseDate implements SearchConditionBook, SearchConditionSeries { + releaseDate: SearchOperatorDate + + constructor(op: SearchOperatorDate) { + this.releaseDate = op + } +} + +export class SearchConditionSharingLabel implements SearchConditionBook, SearchConditionSeries { + sharingLabel: SearchOperatorEquality + + constructor(op: SearchOperatorEquality) { + this.sharingLabel = op + } +} + +export class SearchConditionComplete implements SearchConditionSeries { + complete: SearchOperatorBoolean + + constructor(op: SearchOperatorBoolean) { + this.complete = op + } +} + +export class SearchConditionOneShot implements SearchConditionBook, SearchConditionSeries { + oneShot: SearchOperatorBoolean + + constructor(op: SearchOperatorBoolean) { + this.oneShot = op + } +} + +export class SearchConditionAuthor implements SearchConditionBook, SearchConditionSeries { + author: SearchOperatorBoolean + + constructor(op: SearchOperatorEquality) { + this.author = op + } +} + +export class SearchConditionTitleSort implements SearchConditionSeries { + titleSort: SearchOperatorString + + constructor(op: SearchOperatorString) { + this.titleSort = op + } +} + +export interface AuthorMatch { + name?: string, + role?: string +} + +export interface SearchOperatorEquality { +} + +export interface SearchOperatorBoolean { +} + +export interface SearchOperatorNumericNullable { +} + +export interface SearchOperatorDate { +} + +export interface SearchOperatorString { +} + +export class SearchOperatorIs implements SearchOperatorEquality, SearchOperatorNumericNullable, SearchOperatorDate { + readonly operator: string = 'is' + value: any + + constructor(value: any) { + this.value = value + } +} + +export class SearchOperatorIsNot implements SearchOperatorEquality, SearchOperatorNumericNullable, SearchOperatorDate { + readonly operator: string = 'isNot' + value: any + + constructor(value: any) { + this.value = value + } +} + +export class SearchOperatorBefore implements SearchOperatorDate { + readonly operator: string = 'before' + dateTime: any + + constructor(value: any) { + this.dateTime = value + } +} + +export class SearchOperatorAfter implements SearchOperatorDate { + readonly operator: string = 'after' + dateTime: any + + constructor(value: any) { + this.dateTime = value + } +} + +export class SearchOperatorIsNull implements SearchOperatorNumericNullable, SearchOperatorDate { + readonly operator: string = 'isNull' +} + +export class SearchOperatorIsNotNull implements SearchOperatorNumericNullable, SearchOperatorDate { + readonly operator: string = 'isNotNull' +} + +export class SearchOperatorIsTrue implements SearchOperatorBoolean { + readonly operator: string = 'isTrue' +} + +export class SearchOperatorIsFalse implements SearchOperatorBoolean { + readonly operator: string = 'isFalse' +} + +export class SearchOperatorBeginsWith implements SearchOperatorString { + readonly operator: string = 'beginsWith' + value: any + + constructor(value: any) { + this.value = value + } +} + +export class SearchOperatorDoesNotBeginWith implements SearchOperatorString { + readonly operator: string = 'doesNotBeginWith' + value: any + + constructor(value: any) { + this.value = value + } +} diff --git a/komga-webui/src/views/BrowseLibraries.vue b/komga-webui/src/views/BrowseLibraries.vue index 703afabca..2ec98abe0 100644 --- a/komga-webui/src/views/BrowseLibraries.vue +++ b/komga-webui/src/views/BrowseLibraries.vue @@ -58,6 +58,7 @@ @@ -131,9 +132,9 @@ import ItemBrowser from '@/components/ItemBrowser.vue' import LibraryNavigation from '@/components/LibraryNavigation.vue' import LibraryActionsMenu from '@/components/menus/LibraryActionsMenu.vue' import PageSizeSelect from '@/components/PageSizeSelect.vue' -import {parseBooleanFilter, parseQuerySort} from '@/functions/query-params' +import {parseQuerySort} from '@/functions/query-params' import {ReadStatus} from '@/types/enum-books' -import {SeriesStatus, SeriesStatusKeyValue} from '@/types/enum-series' +import {SeriesStatus} from '@/types/enum-series' import { LIBRARY_CHANGED, LIBRARY_DELETED, @@ -150,15 +151,51 @@ import FilterDrawer from '@/components/FilterDrawer.vue' import SortList from '@/components/SortList.vue' import FilterPanels from '@/components/FilterPanels.vue' import FilterList from '@/components/FilterList.vue' -import {mergeFilterParams, sortOrFilterActive, toNameValue} from '@/functions/filter' +import { + extractFilterOptionsValues, + mergeFilterParams, + sortOrFilterActive, + toNameValueCondition, +} from '@/functions/filter' import {GroupCountDto, Oneshot, SeriesDto} from '@/types/komga-series' -import {AuthorDto} from '@/types/komga-books' import {authorRoles} from '@/types/author-roles' import {LibrarySseDto, ReadProgressSeriesSseDto, SeriesSseDto} from '@/types/komga-sse' import {throttle} from 'lodash' import AlphabeticalNavigation from '@/components/AlphabeticalNavigation.vue' import {LibraryDto} from '@/types/komga-libraries' import {ItemContext} from '@/types/items' +import { + SearchConditionAgeRating, + SearchConditionAllOfSeries, + SearchConditionAnyOfSeries, + SearchConditionAuthor, + SearchConditionComplete, + SearchConditionGenre, + SearchConditionLanguage, + SearchConditionLibraryId, + SearchConditionOneShot, + SearchConditionPublisher, + SearchConditionReadStatus, + SearchConditionReleaseDate, + SearchConditionSeries, + SearchConditionSeriesStatus, + SearchConditionSharingLabel, + SearchConditionTag, + SearchConditionTitleSort, + SearchOperatorAfter, + SearchOperatorBefore, + SearchOperatorBeginsWith, + SearchOperatorDoesNotBeginWith, + SearchOperatorIs, + SearchOperatorIsFalse, + SearchOperatorIsNot, + SearchOperatorIsNotNull, + SearchOperatorIsNull, + SearchOperatorIsTrue, + SeriesSearch, +} from '@/types/komga-search' +import i18n from '@/i18n' +import {objIsEqual} from '@/functions/object' export default Vue.extend({ name: 'BrowseLibraries', @@ -191,8 +228,10 @@ export default Vue.extend({ sortActive: {} as SortActive, sortDefault: {key: 'metadata.titleSort', order: 'asc'} as SortActive, filters: {} as FiltersActive, + filtersMode: {} as FiltersActiveMode, sortUnwatch: null as any, filterUnwatch: null as any, + filterModeUnwatch: null as any, pageUnwatch: null as any, pageSizeUnwatch: null as any, drawer: false, @@ -266,10 +305,14 @@ export default Vue.extend({ next() }, computed: { - searchRegex(): string | undefined { + symbolCondition(): SearchConditionSeries | undefined { if (this.selectedSymbol === 'ALL') return undefined - if (this.selectedSymbol === '#') return '^[^a-z],title_sort' - return `^${this.selectedSymbol},title_sort` + if (this.selectedSymbol === '#') return new SearchConditionAllOfSeries( + this.alphabeticalNavigation + .filter(it => it !== 'ALL' && it !== '#') + .map(it => new SearchConditionTitleSort(new SearchOperatorDoesNotBeginWith(it))), + ) + return new SearchConditionTitleSort(new SearchOperatorBeginsWith(this.selectedSymbol)) }, itemContext(): ItemContext[] { if (this.sortActive.key === 'booksMetadata.releaseDate') return [ItemContext.RELEASE_DATE] @@ -291,24 +334,50 @@ export default Vue.extend({ return { readStatus: { values: [ - {name: this.$t('filter.unread').toString(), value: ReadStatus.UNREAD}, - {name: this.$t('filter.in_progress').toString(), value: ReadStatus.IN_PROGRESS}, - {name: this.$t('filter.read').toString(), value: ReadStatus.READ}, + { + name: this.$t('filter.unread').toString(), + value: new SearchConditionReadStatus(new SearchOperatorIs(ReadStatus.UNREAD)), + nValue: new SearchConditionReadStatus(new SearchOperatorIsNot(ReadStatus.UNREAD)), + }, + { + name: this.$t('filter.in_progress').toString(), + value: new SearchConditionReadStatus(new SearchOperatorIs(ReadStatus.IN_PROGRESS)), + nValue: new SearchConditionReadStatus(new SearchOperatorIsNot(ReadStatus.IN_PROGRESS)), + }, + { + name: this.$t('filter.read').toString(), + value: new SearchConditionReadStatus(new SearchOperatorIs(ReadStatus.READ)), + nValue: new SearchConditionReadStatus(new SearchOperatorIsNot(ReadStatus.READ)), + }, ], }, complete: { - values: [{name: this.$t('filter.complete').toString(), value: 'true', nValue: 'false'}], + values: [{ + name: this.$t('filter.complete').toString(), + value: new SearchConditionComplete(new SearchOperatorIsTrue()), + nValue: new SearchConditionComplete(new SearchOperatorIsFalse()), + }], }, oneshot: { - values: [{name: this.$t('filter.oneshot').toString(), value: 'true', nValue: 'false'}], + values: [{ + name: this.$t('filter.oneshot').toString(), + value: new SearchConditionOneShot(new SearchOperatorIsTrue()), + nValue: new SearchConditionOneShot(new SearchOperatorIsFalse()), + }], }, } as FiltersOptions }, filterOptionsPanel(): FiltersOptions { const r = { - status: {name: this.$t('filter.status').toString(), values: SeriesStatusKeyValue()}, - genre: {name: this.$t('filter.genre').toString(), values: this.filterOptions.genre}, - tag: {name: this.$t('filter.tag').toString(), values: this.filterOptions.tag}, + status: { + name: this.$t('filter.status').toString(), values: Object.values(SeriesStatus).map(x => ({ + name: i18n.t(`enums.series_status.${x}`), + value: new SearchConditionSeriesStatus(new SearchOperatorIs(x)), + nValue: new SearchConditionSeriesStatus(new SearchOperatorIsNot(x)), + } as NameValue)), + }, + genre: {name: this.$t('filter.genre').toString(), values: this.filterOptions.genre, anyAllSelector: true}, + tag: {name: this.$t('filter.tag').toString(), values: this.filterOptions.tag, anyAllSelector: true}, publisher: {name: this.$t('filter.publisher').toString(), values: this.filterOptions.publisher}, language: {name: this.$t('filter.language').toString(), values: this.filterOptions.language}, ageRating: { @@ -316,6 +385,7 @@ export default Vue.extend({ values: this.filterOptions.ageRating.map((x: NameValue) => ({ name: (x.value === 'None' ? this.$t('filter.age_rating_none').toString() : x.name), value: x.value, + nValue: x.nValue, } as NameValue), ), }, @@ -329,6 +399,7 @@ export default Vue.extend({ .content .map(x => x.name) }, + anyAllSelector: true, } }) r['sharingLabel'] = {name: this.$t('filter.sharing_label').toString(), values: this.filterOptions.sharingLabel} @@ -362,7 +433,7 @@ export default Vue.extend({ this.selectedSymbol = symbol this.page = 1 this.updateRoute() - this.loadPage(this.libraryId, 1, this.sortActive, this.searchRegex) + this.loadPage(this.libraryId, 1, this.sortActive, this.symbolCondition) }, resetSortAndFilters() { this.drawer = false @@ -391,13 +462,35 @@ export default Vue.extend({ this.$komgaReferential.getSeriesReleaseDates(requestLibraryId), this.$komgaReferential.getSharingLabels(requestLibraryId), ]) - this.$set(this.filterOptions, 'genre', toNameValue(genres)) - this.$set(this.filterOptions, 'tag', toNameValue(tags)) - this.$set(this.filterOptions, 'publisher', toNameValue(publishers)) - this.$set(this.filterOptions, 'language', (languages)) - this.$set(this.filterOptions, 'ageRating', toNameValue(ageRatings)) - this.$set(this.filterOptions, 'releaseDate', toNameValue(releaseDates)) - this.$set(this.filterOptions, 'sharingLabel', toNameValue(sharingLabels)) + 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, '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 { + name: x.name, + value: new SearchConditionLanguage(new SearchOperatorIs(x.value)), + nValue: new SearchConditionLanguage(new SearchOperatorIsNot(x.value)), + } as NameValue + })) + this.$set(this.filterOptions, 'ageRating', toNameValueCondition(ageRatings, x => new SearchConditionAgeRating(isFinite(x) ? new SearchOperatorIs(x) : new SearchOperatorIsNull()), x => new SearchConditionAgeRating(isFinite(x) ? new SearchOperatorIsNot(x) : new SearchOperatorIsNotNull()))) + this.$set(this.filterOptions, 'releaseDate', toNameValueCondition( + releaseDates, + x => { + const year = Number.parseInt(x) + return year ? new SearchConditionAllOfSeries([ + new SearchConditionReleaseDate(new SearchOperatorAfter(`${year - 1}-12-31T12:00:00Z`)), + new SearchConditionReleaseDate(new SearchOperatorBefore(`${year + 1}-01-01T12:00:00Z`)), + ]) : new SearchConditionAllOfSeries([]) + }, + year => + new SearchConditionAnyOfSeries([ + new SearchConditionReleaseDate(new SearchOperatorAfter(`${year}-12-31T12:00:00Z`)), + new SearchConditionReleaseDate(new SearchOperatorBefore(`${year}-01-01T12:00:00Z`)), + new SearchConditionReleaseDate(new SearchOperatorIsNull()), + ], + ), + )) + this.$set(this.filterOptions, 'sharingLabel', toNameValueCondition(sharingLabels, x => new SearchConditionSharingLabel(new SearchOperatorIs(x)), x => new SearchConditionSharingLabel(new SearchOperatorIsNot(x)))) // get filter from query params or local storage and validate with available filter values let activeFilters: any @@ -422,20 +515,36 @@ export default Vue.extend({ activeFilters = this.$store.getters.getLibraryFilter(route.params.libraryId) || {} as FiltersActive } this.filters = this.validateFilters(activeFilters) + + // get filter mode from query params or local storage + let activeFiltersMode: any + if(route.query.filterMode) { + activeFiltersMode = route.query.filterMode + } else { + activeFiltersMode = this.$store.getters.getLibraryFilterMode(route.params.libraryId) || {} as FiltersActiveMode + } + this.filtersMode = this.validateFiltersMode(activeFiltersMode) + }, + validateFiltersMode(filtersMode: any): FiltersActiveMode { + const validFilterMode = {} as FiltersActiveMode + for (let key in filtersMode) { + if (filtersMode[key].allOf == 'true' || filtersMode[key].allOf == true) validFilterMode[key] = {allOf: true} as FilterMode + } + return validFilterMode }, validateFilters(filters: FiltersActive): FiltersActive { const validFilter = { - status: filters.status?.filter(x => Object.keys(SeriesStatus).includes(x)) || [], - readStatus: filters.readStatus?.filter(x => Object.keys(ReadStatus).includes(x)) || [], - genre: filters.genre?.filter(x => this.filterOptions.genre.map(n => n.value).includes(x)) || [], - tag: filters.tag?.filter(x => this.filterOptions.tag.map(n => n.value).includes(x)) || [], - publisher: filters.publisher?.filter(x => this.filterOptions.publisher.map(n => n.value).includes(x)) || [], - language: filters.language?.filter(x => this.filterOptions.language.map(n => n.value).includes(x)) || [], - ageRating: filters.ageRating?.filter(x => this.filterOptions.ageRating.map(n => n.value).includes(x)) || [], - releaseDate: filters.releaseDate?.filter(x => this.filterOptions.releaseDate.map(n => n.value).includes(x)) || [], - complete: filters.complete?.filter(x => x === 'true' || x === 'false') || [], - oneshot: filters.oneshot?.filter(x => x === 'true' || x === 'false') || [], - sharingLabel: filters.sharingLabel?.filter(x => this.filterOptions.sharingLabel.map(n => n.value).includes(x)) || [], + status: this.$_.intersectionWith(filters.status, extractFilterOptionsValues(this.filterOptionsPanel.status.values), objIsEqual) || [], + 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) || [], + 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) || [], + releaseDate: this.$_.intersectionWith(filters.releaseDate, extractFilterOptionsValues(this.filterOptions.releaseDate), objIsEqual) || [], + complete: this.$_.intersectionWith(filters.complete, extractFilterOptionsValues(this.filterOptionsList.complete.values), objIsEqual) || [], + oneshot: this.$_.intersectionWith(filters.oneshot, extractFilterOptionsValues(this.filterOptionsList.oneshot.values), objIsEqual) || [], + sharingLabel: this.$_.intersectionWith(filters.sharingLabel, extractFilterOptionsValues(this.filterOptions.sharingLabel), objIsEqual) || [], } as any authorRoles.forEach((role: string) => { validFilter[role] = filters[role] || [] @@ -458,6 +567,10 @@ export default Vue.extend({ this.$store.commit('setLibraryFilter', {id: this.libraryId, filter: val}) this.updateRouteAndReload() }) + this.filterModeUnwatch = this.$watch('filtersMode', (val) => { + this.$store.commit('setLibraryFilterMode', {id: this.libraryId, filterMode: val}) + this.updateRouteAndReload() + }) this.pageSizeUnwatch = this.$watch('pageSize', (val) => { this.$store.commit('setBrowsingPageSize', val) this.updateRouteAndReload() @@ -465,12 +578,13 @@ export default Vue.extend({ this.pageUnwatch = this.$watch('page', (val) => { this.updateRoute() - this.loadPage(this.libraryId, val, this.sortActive, this.searchRegex) + this.loadPage(this.libraryId, val, this.sortActive, this.symbolCondition) }) }, unsetWatches() { this.sortUnwatch() this.filterUnwatch() + this.filterModeUnwatch() this.pageUnwatch() this.pageSizeUnwatch() }, @@ -480,7 +594,7 @@ export default Vue.extend({ this.page = 1 this.updateRoute() - this.loadPage(this.libraryId, this.page, this.sortActive, this.searchRegex) + this.loadPage(this.libraryId, this.page, this.sortActive, this.symbolCondition) this.setWatches() }, @@ -500,7 +614,7 @@ export default Vue.extend({ async loadLibrary(libraryId: string) { this.library = this.getLibraryLazy(libraryId) - await this.loadPage(libraryId, this.page, this.sortActive, this.searchRegex) + await this.loadPage(libraryId, this.page, this.sortActive, this.symbolCondition) }, updateRoute() { const loc = { @@ -514,13 +628,14 @@ export default Vue.extend({ }, } as Location mergeFilterParams(this.filters, loc.query) + loc.query['filterMode'] = this.validateFiltersMode(this.filtersMode) this.$router.replace(loc).catch((_: any) => { }) }, reloadPage: throttle(function (this: any) { - this.loadPage(this.libraryId, this.page, this.sortActive, this.searchRegex) + this.loadPage(this.libraryId, this.page, this.sortActive, this.symbolCondition) }, 1000), - async loadPage(libraryId: string, page: number, sort: SortActive, searchRegex?: string) { + async loadPage(libraryId: string, page: number, sort: SortActive, symbolCondition?: SearchConditionSeries) { this.selectedSeries = [] const pageRequest = { @@ -532,24 +647,43 @@ export default Vue.extend({ pageRequest.sort = [`${sort.key},${sort.order}`] } - let authorsFilter = [] as AuthorDto[] + const conditions = [] as SearchConditionSeries[] + if (libraryId !== LIBRARIES_ALL) conditions.push(new SearchConditionLibraryId(new SearchOperatorIs(libraryId))) + if (this.filters.status && this.filters.status.length > 0) this.filtersMode?.status?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.status)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.status)) + 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.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)) + if (this.filters.releaseDate && this.filters.releaseDate.length > 0) this.filtersMode?.releaseDate?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.releaseDate)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.releaseDate)) + if (this.filters.sharingLabel && this.filters.sharingLabel.length > 0) this.filtersMode?.sharingLabel?.allOf ? conditions.push(new SearchConditionAllOfSeries(this.filters.sharingLabel)) : conditions.push(new SearchConditionAnyOfSeries(this.filters.sharingLabel)) + if (this.filters.complete && this.filters.complete.length > 0) conditions.push(...this.filters.complete) + if (this.filters.oneshot && this.filters.oneshot.length > 0) conditions.push(...this.filters.oneshot) authorRoles.forEach((role: string) => { - if (role in this.filters) this.filters[role].forEach((name: string) => authorsFilter.push({ - name: name, - role: role, - })) + if (role in this.filters) { + const authorConditions = this.filters[role].map((name: string) => new SearchConditionAuthor(new SearchOperatorIs({ + name: name, + role: role, + }))) + conditions.push(this.filtersMode[role]?.allOf ? new SearchConditionAllOfSeries(authorConditions) : new SearchConditionAnyOfSeries(authorConditions)) + } }) - const requestLibraryId = libraryId !== LIBRARIES_ALL ? libraryId : undefined - const complete = parseBooleanFilter(this.filters.complete) - const oneshot = parseBooleanFilter(this.filters.oneshot) - const seriesPage = await this.$komgaSeries.getSeries(requestLibraryId, pageRequest, undefined, this.filters.status, this.filters.readStatus, this.filters.genre, this.filters.tag, this.filters.language, this.filters.publisher, this.filters.ageRating, this.filters.releaseDate, authorsFilter, searchRegex, complete, this.filters.sharingLabel, oneshot) + const groupConditions = this.$_.cloneDeep(conditions) + if (symbolCondition) conditions.push(symbolCondition) + + const seriesPage = await this.$komgaSeries.getSeriesList({ + condition: new SearchConditionAllOfSeries(conditions), + } as SeriesSearch, pageRequest) this.totalPages = seriesPage.totalPages this.totalElements = seriesPage.totalElements this.series = seriesPage.content - const seriesGroups = await this.$komgaSeries.getAlphabeticalGroups(requestLibraryId, undefined, this.filters.status, this.filters.readStatus, this.filters.genre, this.filters.tag, this.filters.language, this.filters.publisher, this.filters.ageRating, this.filters.releaseDate, authorsFilter, complete, this.filters.sharingLabel, oneshot) + const seriesGroups = await this.$komgaSeries.getSeriesListByAlphabeticalGroups({ + condition: new SearchConditionAllOfSeries(groupConditions), + } as SeriesSearch) const nonAlpha = seriesGroups .filter((g) => !(/[a-zA-Z]/).test(g.group)) .reduce((a, b) => a + b.count, 0) diff --git a/komga-webui/src/views/BrowseOneshot.vue b/komga-webui/src/views/BrowseOneshot.vue index d979697bc..328f0fc07 100644 --- a/komga-webui/src/views/BrowseOneshot.vue +++ b/komga-webui/src/views/BrowseOneshot.vue @@ -153,14 +153,14 @@ {{ series.metadata.ageRating }}+ {{ languageDisplay }} @@ -307,7 +307,7 @@