mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): allow sorting by custom metadata fields
Extend book sorting to support custom metadata field references (custom:<id>). Join custom metadata values in BookSortBuilder based on field data type, resolve active field types in BookService, and expose custom fields in BookSortBuilder UI. Closes #829
This commit is contained in:
@@ -4,6 +4,9 @@ export type CustomMetadataFieldType = (typeof CUSTOM_METADATA_FIELD_TYPES)[numbe
|
||||
|
||||
export type CustomMetadataPrimitiveValue = string | number | boolean | null;
|
||||
|
||||
/** Active field id to type, resolved per query so sorting can pick the matching value column. */
|
||||
export type CustomMetadataFieldTypeMap = ReadonlyMap<number, CustomMetadataFieldType>;
|
||||
|
||||
export interface CustomMetadataFieldDefinition {
|
||||
id: number;
|
||||
key: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BookQuery, SortField, SortSpec } from "./query";
|
||||
import { isCustomSortField } from "./query";
|
||||
import type { BookQuery, SortSpec, StaticSortField } from "./query";
|
||||
|
||||
export type JumpBucketKind = "letter" | "temporal" | "category";
|
||||
|
||||
@@ -37,7 +38,7 @@ export type TemporalJumpBucketPrecision = "date" | "year";
|
||||
|
||||
export type JumpRailStrategy = { kind: "letter" } | { kind: "temporal"; precision: TemporalJumpBucketPrecision } | { kind: "category" };
|
||||
|
||||
const STRATEGY_BY_PRIMARY_SORT_FIELD: Partial<Record<SortField, JumpRailStrategy>> = {
|
||||
const STRATEGY_BY_PRIMARY_SORT_FIELD: Partial<Record<StaticSortField, JumpRailStrategy>> = {
|
||||
title: { kind: "letter" },
|
||||
author: { kind: "letter" },
|
||||
series: { kind: "letter" },
|
||||
@@ -55,7 +56,9 @@ const STRATEGY_BY_PRIMARY_SORT_FIELD: Partial<Record<SortField, JumpRailStrategy
|
||||
};
|
||||
|
||||
export function jumpRailStrategyForSort(sort: SortSpec[]): JumpRailStrategy | null {
|
||||
const primary = sort[0] ?? { field: "title", dir: "asc" };
|
||||
const primary = sort[0] ?? { field: "title" as const, dir: "asc" as const };
|
||||
// Custom metadata fields carry no bucketing semantics, so the rail is hidden for them.
|
||||
if (isCustomSortField(primary.field)) return null;
|
||||
return STRATEGY_BY_PRIMARY_SORT_FIELD[primary.field] ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -189,8 +189,10 @@ export type GroupRule = {
|
||||
*
|
||||
* Fields marked "per-user, correlated subquery" require an authenticated userId and
|
||||
* execute a subquery per result row; they are slower on large result sets.
|
||||
*
|
||||
* User-defined custom metadata fields are sortable too, as `CustomSortField`.
|
||||
*/
|
||||
export type SortField =
|
||||
export type StaticSortField =
|
||||
| "author"
|
||||
| "title"
|
||||
| "series"
|
||||
@@ -213,7 +215,21 @@ export type SortField =
|
||||
| "language"
|
||||
| "metadataScore";
|
||||
|
||||
export const SORT_FIELDS: SortField[] = [
|
||||
/**
|
||||
* A user-defined custom metadata field, referenced by its numeric id.
|
||||
*
|
||||
* Values live in `book_custom_metadata_values`, which stores one typed column
|
||||
* per value kind, so the server resolves the field's type at query time to pick
|
||||
* the column to order by. Ids that no longer resolve to an active field are
|
||||
* dropped from the sort rather than rejected, so a saved sort survives the
|
||||
* field being archived or deleted.
|
||||
*/
|
||||
export type CustomSortField = `custom:${number}`;
|
||||
|
||||
export type SortField = StaticSortField | CustomSortField;
|
||||
|
||||
/** Built-in sort fields only. Custom fields are resolved at runtime, not enumerable here. */
|
||||
export const SORT_FIELDS: StaticSortField[] = [
|
||||
"author",
|
||||
"title",
|
||||
"series",
|
||||
@@ -242,6 +258,36 @@ export type SortSpec = {
|
||||
dir: "asc" | "desc";
|
||||
};
|
||||
|
||||
// Bounded to 9 digits so a parsed id always fits the int4 field id column.
|
||||
const CUSTOM_SORT_FIELD_PATTERN = /^custom:([1-9]\d{0,8})$/;
|
||||
|
||||
export function customSortField(fieldId: number): CustomSortField {
|
||||
return `custom:${fieldId}`;
|
||||
}
|
||||
|
||||
export function isCustomSortField(field: string): field is CustomSortField {
|
||||
return CUSTOM_SORT_FIELD_PATTERN.test(field);
|
||||
}
|
||||
|
||||
/** Returns the custom metadata field id a sort field references, or null when it is not a custom sort. */
|
||||
export function parseCustomSortFieldId(field: string): number | null {
|
||||
const match = CUSTOM_SORT_FIELD_PATTERN.exec(field);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
export function isSortField(field: string): field is SortField {
|
||||
return (SORT_FIELDS as string[]).includes(field) || isCustomSortField(field);
|
||||
}
|
||||
|
||||
export function customSortFieldIds(sort: SortSpec[]): number[] {
|
||||
const ids = new Set<number>();
|
||||
for (const { field } of sort) {
|
||||
const id = parseCustomSortFieldId(field);
|
||||
if (id !== null) ids.add(id);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export type BookQuery = {
|
||||
filter?: GroupRule;
|
||||
sort: SortSpec[];
|
||||
|
||||
Reference in New Issue
Block a user