Files
jellytau/src/lib/utils/searchScope.ts
T
dtourolle 105cc082ea fix(search): move scope→item-type taxonomy into Rust (UR-049, DR-063)
Stage 1 of scoped-search-boundary-implementation.md — the query side.

scoped-search-boundary.md diagnosed this leak, specified the fix in
detail, and became the justification for the boundary rule in CLAUDE.md,
the check:boundary tripwire, and the spec-review checklist. The fix was
never built: SCOPE_ITEM_TYPES was still live in searchScope.ts, called by
library.ts, and no SearchScope existed anywhere in src-tauri/. The rule's
own founding violation was still shipping.

Rust now owns the taxonomy:

  pub enum SearchScope { All, Music, Movies, Tv }
  impl SearchScope { pub fn item_types(self) -> Option<Vec<String>> }

- SearchOptions gains `scope`, resolved by resolve_scope(). Scope wins
  over include_item_types, which stays for the non-search get_items
  callers that legitimately request one concrete type.
- repository_search resolves the scope ONCE, before the cache/server
  paths diverge, so online and offline filter identically — the failure
  mode most likely to go unnoticed.
- All expands to None (no filter), not the union of the other scopes:
  an explicit includeItemTypes list would silently drop People, folders,
  and any type nobody enumerated.
- searchScope.ts re-exports SearchScope from generated bindings instead
  of a hand-written union, and no longer names an item type for search.
- library.ts sends { scope }.

8 Rust tests written first, confirmed failing on "use of undeclared type
SearchScope" before the implementation existed.

The frontend tests that asserted includeItemTypes contents were rewritten
to assert the opaque scope is sent and includeItemTypes is absent —
keeping the old assertions would require the frontend to know the
taxonomy again, defeating the fix. The expansion is now asserted in Rust.

Verified the spec's headline criterion by hashing every src/ file, adding
"AudioBook" to the Music scope in Rust, and re-hashing: zero frontend
files change. That criterion failed before this commit.

Stage 2 (result-side grouping: GROUP_ITEM_TYPES, GroupedSearchResult on
both search payloads) remains open.
2026-07-30 10:30:38 +02:00

283 lines
9.2 KiB
TypeScript

// Search scoping and result-group ordering.
//
// Two independent axes govern how search results are presented:
// - *scope* narrows which item types are requested from the repository,
// - *group order* decides the sequence the surviving groups render in.
// Neither one rewrites the other: narrowing to Music and widening back to All
// restores the user's saved arrangement untouched.
//
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
// Sourced from Rust via the generated bindings — the backend owns what a scope
// *means* (which Jellyfin item types it covers). Naming an opaque variant is
// presentation; knowing its expansion is domain vocabulary and stays in Rust.
export type { SearchScope } from "$lib/api/bindings";
import type { SearchScope } from "$lib/api/bindings";
export const SEARCH_SCOPES: readonly SearchScope[] = ["all", "music", "movies", "tv"];
export const SCOPE_LABELS: Record<SearchScope, string> = {
all: "All",
music: "Music",
movies: "Movies",
tv: "TV",
};
// NOTE: the scope → Jellyfin item-type mapping deliberately does NOT live here.
// It is domain vocabulary and lives in Rust (`SearchScope::item_types()` in
// repository/types.rs); the frontend sends the opaque scope and the backend
// expands it. Re-introducing a `{ music: ["MusicAlbum", …] }` table in this file
// is the boundary leak documented in docs/specs/scoped-search-boundary.md.
/**
* Resolve the scope a search started from a given route should default to.
* Pure — takes a pathname, touches no DOM, so it unit-tests directly.
*
* TRACES: UR-049 | DR-063
*/
export function resolveSearchScope(pathname: string): SearchScope {
// Tolerate query strings, hashes and trailing slashes.
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
if (path === "/library/music" || path.startsWith("/library/music/")) return "music";
if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies";
if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv";
// `/library/shows/genres` is the TV genre route despite the differing segment.
if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv";
return "all";
}
/**
* The URL of the single search surface for a query + scope.
*
* `/search` is the *only* route that renders results, so every other search
* affordance (the desktop header bar) is a navigator to this URL rather than a
* second result renderer. The `all` scope is the page's own default, so it is
* omitted to keep shared/back-navigated URLs clean.
*
* TRACES: UR-049 | DR-063
*/
export function searchRouteUrl(query: string, scope: SearchScope): string {
const trimmed = query.trim();
if (!trimmed) return "/search";
const params = new URLSearchParams({ q: trimmed });
if (scope !== "all") params.set("scope", scope);
// URLSearchParams renders spaces as "+", valid in a query but noisier to
// read; %20 is equally valid and matches how the app builds other links.
return `/search?${params.toString().replace(/\+/g, "%20")}`;
}
/**
* Whether a search typed on `pathname` must navigate to `/search` to be seen.
*
* True for every route except `/search` itself: no other page renders
* `searchResults`, so a search performed there is invisible. Guarding on
* `/search` keeps typing from pushing a history entry per keystroke.
*
* TRACES: UR-049 | DR-063
*/
export function shouldNavigateToSearch(pathname: string, query: string): boolean {
if (!query.trim()) return false;
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
return path !== "/search";
}
// ---------------------------------------------------------------------------
// Result groups
// ---------------------------------------------------------------------------
export type SearchGroupId =
| "shows"
| "episodes"
| "movies"
| "songs"
| "albums"
| "artists"
| "people";
/**
* Shipped default order.
*
* TRACES: UR-060 | DR-091
*
* Containers lead the kinds they contain — a show above its episodes, an album
* above nothing (songs are ranked separately) — which matches how people search:
* you look for the show, not an arbitrary episode of it. `people` sits last as
* a peripheral match; it exists so searching an actor's name reaches their bio
* page rather than silently dropping the result.
*/
export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"people",
];
export const GROUP_LABELS: Record<SearchGroupId, string> = {
shows: "TV Shows",
episodes: "Episodes",
movies: "Movies",
songs: "Songs",
albums: "Albums",
artists: "Artists",
people: "People",
};
/**
* Which scopes each group belongs to (`all` always includes everything).
*
* `people` maps to no narrow scope: cast/crew cut across music, film and TV, so
* it surfaces only under All rather than being forced into one of them.
*/
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all"> | null> = {
shows: "tv",
episodes: "tv",
movies: "movies",
songs: "music",
albums: "music",
artists: "music",
people: null,
};
/** Item types that fall into each group. */
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
shows: ["Series"],
episodes: ["Episode"],
movies: ["Movie"],
songs: ["Audio"],
albums: ["MusicAlbum"],
artists: ["MusicArtist"],
people: ["Person"],
};
export function groupItemTypes(group: SearchGroupId): string[] {
return [...GROUP_ITEM_TYPES[group]];
}
/**
* Stored group ids that no longer exist, mapped to the ids that replaced them.
*
* `tvShows` was one group holding both Series and Episode; it split so a show
* can outrank its own episodes. Expanding in place preserves the position the
* user chose for it.
*
* TRACES: UR-060 | DR-091
*/
const RETIRED_GROUP_IDS: Record<string, SearchGroupId[]> = {
tvShows: ["shows", "episodes"],
};
/** Resolve a stored id to the live id(s) it corresponds to, or none if unknown. */
function migrateGroupId(id: string, known: Set<string>): SearchGroupId[] {
if (known.has(id)) return [id as SearchGroupId];
return RETIRED_GROUP_IDS[id] ?? [];
}
/**
* Normalise a stored order into a usable one.
*
* The stored array is a *hint*, not a contract: ids that no longer exist are
* dropped, and groups it never mentions (a user upgrading from a build with
* fewer groups) are appended in default order rather than lost.
*
* TRACES: UR-050 | DR-066
*/
export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
const known = new Set<string>(DEFAULT_GROUP_ORDER);
const seen = new Set<SearchGroupId>();
const order: SearchGroupId[] = [];
if (Array.isArray(stored)) {
for (const id of stored) {
if (typeof id !== "string") continue;
// Retired ids expand in place rather than being dropped, so a user who
// dragged the old combined "TV Shows" group to the top keeps TV at the
// top instead of having shows/episodes appended to the bottom.
for (const groupId of migrateGroupId(id, known)) {
if (seen.has(groupId)) continue;
seen.add(groupId);
order.push(groupId);
}
}
}
for (const id of DEFAULT_GROUP_ORDER) {
if (!seen.has(id)) order.push(id);
}
return order;
}
/** Groups visible under a scope, in the user's configured order. */
export function groupsForScope(
scope: SearchScope,
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
): SearchGroupId[] {
// A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives
// only under `all` — the `=== scope` test already excludes it elsewhere.
return normalizeGroupOrder(order as SearchGroupId[]).filter(
(id) => scope === "all" || GROUP_SCOPE[id] === scope
);
}
export interface SearchGroup<T> {
id: SearchGroupId;
label: string;
items: T[];
}
/**
* Compose scope, saved order and the results into the sections to render:
* drop out-of-scope groups, sort by the saved order, omit empty groups.
*
* TRACES: UR-050 | DR-067
*/
export function composeSearchGroups<T extends { type?: string | null }>(
results: readonly T[],
scope: SearchScope,
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
): SearchGroup<T>[] {
return groupsForScope(scope, order)
.map((id) => {
const types = GROUP_ITEM_TYPES[id];
return {
id,
label: GROUP_LABELS[id],
items: results.filter((item) => item.type != null && types.includes(item.type)),
};
})
.filter((group) => group.items.length > 0);
}
/** Move a group one slot up (-1) or down (+1); out-of-range moves are no-ops. */
export function moveGroup(
order: readonly SearchGroupId[],
id: SearchGroupId,
delta: number
): SearchGroupId[] {
const next = [...order];
const from = next.indexOf(id);
if (from === -1) return next;
const to = from + delta;
if (to < 0 || to >= next.length) return next;
next.splice(to, 0, ...next.splice(from, 1));
return next;
}
/** Move a group from one index to another (drag-and-drop drop handler). */
export function reorderGroups(
order: readonly SearchGroupId[],
from: number,
to: number
): SearchGroupId[] {
const next = [...order];
if (from < 0 || from >= next.length || to < 0 || to >= next.length || from === to) return next;
next.splice(to, 0, ...next.splice(from, 1));
return next;
}