/* * URL shape adapted from OnCo viewParams: `q` for search, repeated facet keys, * and `sort` with a leading "-" for descending. * https://github.com/judegomila/OnCo @ b594d7433bf4e408b44215e25e5304696d6f840e * Copyright (c) 2026 OnCo. MIT License. See ui/NOTICE. * * The compact `v` parameter, table scopes, and domain value normalisers are not included. */ import { cellSortValue, type BrowserRow } from "./browser-row"; import { sortRows } from "./sort-rows"; import type { SortState } from "./types"; export type TableView = { q: string; facets: Record; sort?: SortState; }; export type ViewHistory = { read: () => string; push: (search: string) => void; subscribe: (listener: () => void) => () => void; }; export function browserViewHistory(): ViewHistory { return { read: () => (typeof window === "undefined" ? "" : window.location.search), push: (search) => { const path = window.location.pathname; window.history.pushState(null, "", search ? `${path}?${search}` : path); }, subscribe: (listener) => { window.addEventListener("popstate", listener); return () => window.removeEventListener("popstate", listener); }, }; } export function facetKeysOf(rows: readonly BrowserRow[]): string[] { const keys = new Set(); for (const row of rows) { for (const key of Object.keys(row.facets)) keys.add(key); } return [...keys].sort(); } export function facetValues(rows: readonly BrowserRow[], key: string): string[] { const values = new Set(); for (const row of rows) { for (const value of row.facets[key] ?? []) values.add(value); } return [...values].sort((a, b) => a.localeCompare(b)); } function splitValues(params: URLSearchParams, key: string): string[] { const values: string[] = []; for (const raw of params.getAll(key)) { for (const part of raw.split(",")) { const trimmed = part.trim(); if (trimmed && !values.includes(trimmed)) values.push(trimmed); } } return values; } export function parseTableView( search: string, facetKeys: readonly string[], sortable: (key: string) => boolean, ): TableView { const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); const facets: Record = {}; for (const key of facetKeys) { const values = splitValues(params, key); if (values.length > 0) facets[key] = values; } const rawSort = params.get("sort"); let sort: SortState | undefined; if (rawSort) { const key = rawSort.replace(/^-/, ""); if (sortable(key)) sort = { key, dir: rawSort.startsWith("-") ? -1 : 1 }; } return { q: params.get("q") ?? "", facets, sort }; } export function serializeTableView(view: TableView, baseSort: SortState): string { const params = new URLSearchParams(); for (const key of Object.keys(view.facets).sort()) { for (const value of view.facets[key] ?? []) params.append(key, value); } const q = view.q.trim(); if (q) params.set("q", q); if (view.sort && (view.sort.key !== baseSort.key || view.sort.dir !== baseSort.dir)) { params.set("sort", `${view.sort.dir === -1 ? "-" : ""}${view.sort.key}`); } return params.toString(); } export function applyTableView(rows: readonly BrowserRow[], view: TableView): BrowserRow[] { const q = view.q.trim().toLowerCase(); const filtered = rows.filter((row) => { for (const [key, selected] of Object.entries(view.facets)) { if (selected.length === 0) continue; const have = new Set(row.facets[key] ?? []); if (!selected.some((value) => have.has(value))) return false; } if (!q) return true; const haystack = [row.name, row.tldr, ...Object.values(row.facets).flat()].join("\n").toLowerCase(); return haystack.includes(q); }); return sortRows(filtered, view.sort, cellSortValue); }