// Shared column-sort primitives, lifted out of DataTable so a NON-table renderer (the channel // manager's card layout) can sort the same rows the same way — one comparator, one source of truth. // Kept structural (no `Column` import) so it has zero coupling to DataTable. import { compareText } from "./collate"; export type SortState = { key: string; dir: "asc" | "desc" } | null; interface Sortable { key: string; sortValue?: (row: T) => string | number; } /** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff, or text in the UI * language's alphabet — `lang`). Returns `rows` unchanged when there's no sort or the column has no * `sortValue`. */ export function sortRows(rows: T[], columns: Sortable[], sort: SortState, lang: string): T[] { if (!sort) return rows; const get = columns.find((c) => c.key === sort.key)?.sortValue; if (!get) return rows; return [...rows].sort((a, b) => { const av = get(a); const bv = get(b); const cmp = typeof av === "number" && typeof bv === "number" ? av - bv : compareText(String(av), String(bv), lang); return sort.dir === "asc" ? cmp : -cmp; }); } /** Coerce a stored value to a SortState. Anything malformed (corrupt storage, a shape from an older * build) becomes "no sort" rather than flowing a junk `dir` into the comparator. */ export function parseSortState(raw: unknown): SortState { if (!raw || typeof raw !== "object") return null; const { key, dir } = raw as { key?: unknown; dir?: unknown }; return typeof key === "string" && (dir === "asc" || dir === "desc") ? { key, dir } : null; } /** Encode a SortState to the compact `key:dir` wire form the shareable-URL modules (plex/channels) * put in the address bar. Sort keys are simple words (no colon), so the plain join round-trips. Returns * null for no-sort. A module with a default sort short-circuits that to null at its own call site. */ export function encodeSortParam(s: SortState): string | null { return s ? `${s.key}:${s.dir}` : null; } /** Inverse of `encodeSortParam`: parse `key:dir` (or a bare key → asc) from the URL back into a * SortState, healing anything malformed to null. One codec for both URL-state modules — no drift. */ export function decodeSortParam(raw: string): SortState { const i = raw.indexOf(":"); const key = i === -1 ? raw : raw.slice(0, i); const dir = i === -1 ? "asc" : raw.slice(i + 1); return parseSortState({ key, dir }); } /** The header-click cycle: unsorted → ascending → descending → unsorted. */ export function cycleSort(current: SortState, key: string): SortState { if (!current || current.key !== key) return { key, dir: "asc" }; if (current.dir === "asc") return { key, dir: "desc" }; return null; } // --- the source-agnostic sort vocabulary ------------------------------------------------- // A `SortOption[]` is the one shape every content surface (YT feed, Plex library, channel // manager, discovery) hands to the shared `SortControl`. A table derives it from its columns; // the feed and Plex declare it explicitly. Direction lives in `SortState`, never encoded into // the key — the feed's wire string (`feedSort.ts`) is a codec at the server boundary only. export interface SortOption { key: string; label: string; /** No meaningful asc/desc flip (the feed's `shuffle`/`relevance`) — hides the direction toggle. */ directionless?: boolean; /** The natural first direction when this option is freshly picked (defaults to `"asc"`). */ defaultDir?: "asc" | "desc"; } /** Decide the next `SortState` when the user PICKS `key` from a dropdown/chip (NOT the header-click * cycle — that's `cycleSort`). Re-picking the same key keeps its direction; a new key starts at its * `defaultDir` (asc fallback); a directionless option carries a stable dir; an empty key clears sort. */ export function pickSort(options: SortOption[], prev: SortState, key: string): SortState { if (!key) return null; const opt = options.find((o) => o.key === key); if (!opt) return null; if (opt.directionless) return { key, dir: "desc" }; if (prev?.key === key) return prev; return { key, dir: opt.defaultDir ?? "asc" }; } /** Collapse a sort that names an option no longer offered (a renamed column, or Plex's movie/show * scope swap dropping a key) to `fallback` — otherwise a stale key claims a sort nothing applies. */ export function healSort( sort: SortState, options: SortOption[], fallback: SortState = null, ): SortState { return sort && options.some((o) => o.key === sort.key) ? sort : fallback; } /** Hydrate a persisted raw string into a `SortState`. Accepts the JSON `{key,dir}` shape and tolerates * a legacy bare-key string (older builds stored just the key). When `options` is empty, the value is * parsed but NOT healed — for a table/provider that owns no vocabulary itself and heals for display * elsewhere (e.g. `ColumnSortControl`); a non-empty `options` heals an absent key to `fallback`. */ export function hydrateSort( raw: string | null, options: SortOption[], fallback: SortState = null, ): SortState { if (!raw) return fallback; const heal = (s: SortState): SortState => options.length === 0 ? (s ?? fallback) : healSort(s, options, fallback); try { return heal(parseSortState(JSON.parse(raw))); } catch { const opt = options.find((o) => o.key === raw); return opt ? { key: raw, dir: opt.defaultDir ?? "asc" } : fallback; } }