diff --git a/core/src/db/filter/smart_filter.rs b/core/src/db/filter/smart_filter.rs index e515d51fe..6a79d9dd9 100644 --- a/core/src/db/filter/smart_filter.rs +++ b/core/src/db/filter/smart_filter.rs @@ -236,25 +236,45 @@ pub enum LibrarySmartFilter { #[serde(untagged)] #[prisma_table("series_metadata")] pub enum SeriesMetadataSmartFilter { + #[is_optional] + AgeRating { + age_rating: i32, + }, MetaType { meta_type: String, }, #[is_optional] + Title { + title: String, + }, + #[is_optional] + Summary { + summary: String, + }, + #[is_optional] Publisher { publisher: String, }, #[is_optional] - Status { - status: String, + Imprint { + imprint: String, }, #[is_optional] - AgeRating { - age_rating: i32, + ComicId { + comicid: i32, + }, + #[is_optional] + BookType { + booktype: String, }, #[is_optional] Volume { volume: i32, }, + #[is_optional] + Status { + status: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type, ToSchema)] diff --git a/packages/browser/src/components/smartList/createOrUpdate/__tests__/schema.test.ts b/packages/browser/src/components/smartList/createOrUpdate/__tests__/schema.test.ts index aa572dd9c..9e9aacb3d 100644 --- a/packages/browser/src/components/smartList/createOrUpdate/__tests__/schema.test.ts +++ b/packages/browser/src/components/smartList/createOrUpdate/__tests__/schema.test.ts @@ -116,9 +116,40 @@ describe('schema', () => { value: Object.values(filter)[0], }) } + }) + + it('should convert smart filter with series meta into form filter', () => { + for (const filter of stringFilters) { + expect( + intoFormFilter({ + series: { + metadata: { + title: filter, + }, + }, + } satisfies MediaSmartFilter), + ).toEqual({ + field: 'title', + operation: Object.keys(filter)[0], + source: 'series_meta', + value: Object.values(filter)[0], + }) + } - // TODO: support series metadata - // TODO: add numeric filters for series? + for (const filter of numericFilters) { + const operation = 'from' in filter ? 'range' : Object.keys(filter)[0] + const value = 'from' in filter ? filter : Object.values(filter)[0] + expect( + intoFormFilter({ + series: { metadata: { age_rating: filter } }, + } satisfies MediaSmartFilter), + ).toEqual({ + field: 'age_rating', + operation, + source: 'series_meta', + value, + }) + } }) it('should convert smart filter with library into form filter', () => { @@ -304,6 +335,68 @@ describe('schema', () => { }) }) + it('should convert smart filter form with series meta into API filter', () => { + // String filter + expect( + intoAPIFilter({ + field: 'title', + operation: 'any', + source: 'series_meta', + value: ['foo', 'shmoo'], + }), + ).toEqual({ + series: { + metadata: { + title: { + any: ['foo', 'shmoo'], + }, + }, + }, + }) + + // Numeric filter (basic) + expect( + intoAPIFilter({ + field: 'age_rating', + operation: 'gte', + source: 'series_meta', + value: 42, + }), + ).toEqual({ + series: { + metadata: { + age_rating: { + gte: 42, + }, + }, + }, + }) + + // Numeric filter (complex) + expect( + intoAPIFilter({ + field: 'age_rating', + operation: 'range', + source: 'series_meta', + value: { + from: 42, + inclusive: true, + to: 69, + }, + }), + ).toEqual({ + series: { + metadata: { + age_rating: { + from: 42, + inclusive: true, + to: 69, + }, + }, + }, + }) + }) + it('should convert smart filter form with library into API filter', () => { // String filter expect( diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/FieldSelector.tsx b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/FieldSelector.tsx index 7dee603a0..05e0c1fdd 100644 --- a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/FieldSelector.tsx +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/FieldSelector.tsx @@ -68,6 +68,15 @@ export function FieldSelector({ idx }: Props) { {t(getSourceKey('series', 'label'))} + + setSource('series_meta')} + className="flex items-center justify-between" + > + {t(getSourceKey('series_meta', 'label'))} + + + setSource('library')} className="flex items-center justify-between" @@ -189,6 +198,18 @@ const sourceOptions: Record = { ], library: [{ value: 'name' }, { value: 'path' }], series: [{ value: 'name' }, { value: 'path' }], + series_meta: [ + { value: 'age_rating' }, + { value: 'meta_type' }, + { value: 'title' }, + { value: 'summary' }, + { value: 'publisher' }, + { value: 'imprint' }, + { value: 'comicid' }, + { value: 'booktype' }, + { value: 'status' }, + { value: 'volume' }, + ], } // TODO: series_meta: [meta_type, publisher, status, age_rating, volume] diff --git a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/OperatorSelect.tsx b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/OperatorSelect.tsx index eb5f55ae3..387bf8ccf 100644 --- a/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/OperatorSelect.tsx +++ b/packages/browser/src/components/smartList/createOrUpdate/queryBuilder/filterGroup/OperatorSelect.tsx @@ -59,7 +59,7 @@ export default function OperatorSelect({ idx }: Props) { (field) => isNumberField(field) || isDateField(field), () => ['gt', 'gte', 'lt', 'lte', 'not', 'equals', 'range'] as NumberOperation[], ) - .otherwise(() => [] as Operation[]), + .otherwise(() => ['not', 'equals'] as Operation[]), [fieldDef], ) @@ -79,7 +79,7 @@ export default function OperatorSelect({ idx }: Props) { }, ] : []), - ] + ].filter(({ operators }) => operators.length) }, [operators, fieldDef]) useEffect(() => { diff --git a/packages/browser/src/components/smartList/createOrUpdate/schema.ts b/packages/browser/src/components/smartList/createOrUpdate/schema.ts index 24b0ad63b..adbc9aa7c 100644 --- a/packages/browser/src/components/smartList/createOrUpdate/schema.ts +++ b/packages/browser/src/components/smartList/createOrUpdate/schema.ts @@ -4,6 +4,7 @@ import { LibrarySmartFilter, MediaMetadataSmartFilter, MediaSmartFilter, + SeriesMetadataSmartFilter, SeriesSmartFilter, SmartFilter, SmartList, @@ -56,6 +57,10 @@ export const stringField = z.enum([ 'links', 'characters', 'teams', + 'comicid', + 'booktype', + 'status', + 'meta_type', ]) export type StringField = z.infer export const isStringField = (field: string): field is StringField => @@ -69,6 +74,7 @@ export const numberField = z.enum([ 'pages', 'page_count', 'size', + 'volume', ]) export type NumberField = z.infer export const isNumberField = (field: string): field is NumberField => @@ -82,7 +88,7 @@ export const filter = z .object({ field: z.string(), operation, - source: z.enum(['book', 'book_meta', 'series', 'library']), + source: z.enum(['book', 'book_meta', 'series', 'series_meta', 'library']), value: z.union([ z.string(), z.string().array(), @@ -129,6 +135,13 @@ export const intoAPIFilter = (input: z.infer): MediaSmartFilter = [input.field]: fieldValue, } as SeriesSmartFilter, })) + .with('series_meta', () => ({ + series: { + metadata: { + [input.field]: fieldValue, + }, + } as SeriesSmartFilter, + })) .with('library', () => ({ series: { library: { @@ -152,6 +165,10 @@ export const intoFormFilter = (input: MediaSmartFilter): z.infer (x) => 'series' in x && 'library' in x.series, () => 'library' as const, ) + .when( + (x) => 'series' in x && 'metadata' in x.series, + () => 'series_meta' as const, + ) .when( (x) => 'series' in x, () => 'series' as const, @@ -165,6 +182,13 @@ export const intoFormFilter = (input: MediaSmartFilter): z.infer () => Object.keys((input as { metadata: MediaMetadataSmartFilter }).metadata)[0], ) .with('series', () => Object.keys((input as { series: SeriesSmartFilter }).series)[0]) + .with( + 'series_meta', + () => + Object.keys( + (input as { series: { metadata: SeriesMetadataSmartFilter } }).series.metadata, + )[0], + ) .with( 'library', () => Object.keys((input as { series: { library: LibrarySmartFilter } }).series.library)[0], @@ -217,6 +241,21 @@ export const intoFormFilter = (input: MediaSmartFilter): z.infer value, } }) + .with('series_meta', () => { + const castedInput = input as { series: { metadata: SeriesMetadataSmartFilter } } // { series: { metadata: { [field]: { [operation]: value } } } } + const filterValue = getProperty(castedInput.series.metadata, field || '') // { [operation]: value } + const operation = 'from' in filterValue ? 'range' : Object.keys(filterValue || {})[0] + const value = match(operation) + .with('range', () => filterValue) + .otherwise(() => getProperty(filterValue, operation || '')) + + return { + field, + operation, + source, + value, + } + }) .with('library', () => { const castedInput = input as { series: { library: LibrarySmartFilter } } // { series: { library: { [field]: { [operation]: value } } } } const filterValue = getProperty(castedInput.series.library, field || '') // { [operation]: value } @@ -361,8 +400,6 @@ export const intoAPI = ({ export const intoAPIFilters = ({ groups, - joiner, }: Pick['filters']): SmartFilter => ({ groups: groups.map(intoAPIGroup), - joiner: joiner.toUpperCase() as 'AND' | 'OR', }) diff --git a/packages/browser/src/components/smartList/createOrUpdate/sections/FilterConfigJSON.tsx b/packages/browser/src/components/smartList/createOrUpdate/sections/FilterConfigJSON.tsx index 7b91566d1..2116d2bc5 100644 --- a/packages/browser/src/components/smartList/createOrUpdate/sections/FilterConfigJSON.tsx +++ b/packages/browser/src/components/smartList/createOrUpdate/sections/FilterConfigJSON.tsx @@ -11,12 +11,11 @@ export default function FilterConfigJSON() { const [joiner] = form.watch(['filters.joiner']) const groups = useMemo(() => (filters?.groups ?? []) as FilterGroupSchema[], [filters?.groups]) - // FIXME: this errors lol const apiFilters = useMemo( () => intoAPIFilters({ groups, - joiner: joiner ?? 'AND', + joiner, }), [groups, joiner], ) diff --git a/packages/components/src/select/ComboBox.tsx b/packages/components/src/select/ComboBox.tsx index 2759c9f51..60fe0406d 100644 --- a/packages/components/src/select/ComboBox.tsx +++ b/packages/components/src/select/ComboBox.tsx @@ -136,8 +136,8 @@ export function ComboBox({