The tag 'Match: Any/All' toggle was a single GLOBAL setting that only ever
affected topics, shown as its own row under the Topic widget. Make it
per-category — language, topic, and 'your tags' each get their own independent
any/all — and move it into each filter combobox's popover footer (like
SortControl's direction segment), reclaiming the standalone Match row.
Backend: the feed query already groups selected tags by category; replace the
single tag_mode with per-category tag_mode_language/topic/other params and apply
the conjunctive (HAVING all) branch per category. Mirrored across filtered_query,
feed_summary.passes(), and the /facets drill-down exclusion (kept in lockstep;
equivalence test updated to tag_mode_topic). No schema change.
Frontend: FeedFilters.tagMode -> tagModes {language,topic,other} (pure leaf
lib/api/tagModes with a coerceTagModes migration for the legacy global value);
serialized to the API and the shareable URL per category (legacy ?tagmode=and
still resolves into the topic slot); saved-view blobs migrate on read. Combobox
gains an optional matchMode/onMatchModeChange footer segment, shown once 2+
values are picked; the three tag comboboxes wire it to their category.
Verified: backend feed tests green (12, incl. the count/facets equivalence net);
live, the footer appears at 2+ selections, toggles aria-pressed, serializes
tagmode_topic=and to the URL, and the request carries tag_mode_topic=and (200).
374 vitest, tsc, ruff, eslint (0 err).
178 lines
7.1 KiB
TypeScript
178 lines
7.1 KiB
TypeScript
// Serialize feed filters to/from a compact, human-readable URL query string, and build/patch the
|
|
// address-bar URL for the whole app's navigation. The query string IS the source of truth for the
|
|
// current view (page / channel / plex sub-view / playlist) and the feed filters, so every URL is
|
|
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
|
|
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
|
|
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
|
|
import { TAG_CATEGORIES, type FeedFilters } from "./api";
|
|
|
|
const KEYS = [
|
|
"q",
|
|
"show",
|
|
"sort",
|
|
"scope",
|
|
"source",
|
|
"normal",
|
|
"shorts",
|
|
"live",
|
|
"tags",
|
|
"tagmode_language",
|
|
"tagmode_topic",
|
|
"tagmode_other",
|
|
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
|
|
"channel",
|
|
"maxage",
|
|
"from",
|
|
"to",
|
|
"mindur",
|
|
"maxdur",
|
|
] as const;
|
|
|
|
export function filtersToParams(f: FeedFilters): URLSearchParams {
|
|
const p = new URLSearchParams();
|
|
if (f.q) p.set("q", f.q);
|
|
if (f.show && f.show !== "unwatched") p.set("show", f.show);
|
|
if (f.sort && f.sort !== "newest") p.set("sort", f.sort);
|
|
if (f.scope === "all") p.set("scope", "all");
|
|
// Library provenance filter (only meaningful in "all" scope); "organic" is the default.
|
|
if (f.scope === "all" && f.librarySource && f.librarySource !== "organic")
|
|
p.set("source", f.librarySource);
|
|
if (!f.includeNormal) p.set("normal", "0");
|
|
if (f.includeShorts) p.set("shorts", "1");
|
|
if (f.includeLive) p.set("live", "1");
|
|
if (f.tags.length) p.set("tags", f.tags.join(","));
|
|
for (const cat of TAG_CATEGORIES) {
|
|
if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and");
|
|
}
|
|
if (f.channelIds.length) p.set("channel", f.channelIds.join(","));
|
|
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
|
|
if (f.dateFrom) p.set("from", f.dateFrom);
|
|
if (f.dateTo) p.set("to", f.dateTo);
|
|
if (f.minDuration != null) p.set("mindur", String(f.minDuration));
|
|
if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration));
|
|
return p;
|
|
}
|
|
|
|
/** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted
|
|
* value, or null to delete it (so switching a filter back to its default clears the key). Pass `null`
|
|
* to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them.
|
|
* Feed the result into urlWithParams(). */
|
|
export function filterParamUpdates(f: FeedFilters | null): Record<string, string | null> {
|
|
const p = f ? filtersToParams(f) : new URLSearchParams();
|
|
const out: Record<string, string | null> = {};
|
|
for (const k of KEYS) out[k] = p.get(k);
|
|
return out;
|
|
}
|
|
|
|
/** Merge URL params onto a base filter set (typically the defaults). */
|
|
export function paramsToFilters(params: URLSearchParams, base: FeedFilters): FeedFilters {
|
|
const f: FeedFilters = { ...base };
|
|
const num = (v: string | null): number | undefined => {
|
|
if (v == null || v === "") return undefined;
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : undefined;
|
|
};
|
|
|
|
if (params.has("q")) f.q = params.get("q") ?? "";
|
|
if (params.has("show")) f.show = params.get("show") || "unwatched";
|
|
if (params.has("sort")) f.sort = params.get("sort") || "newest";
|
|
if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
|
|
if (params.has("source")) {
|
|
const s = params.get("source");
|
|
f.librarySource = s === "all" || s === "search" || s === "plex" ? s : "organic";
|
|
}
|
|
if (params.has("normal")) f.includeNormal = params.get("normal") !== "0";
|
|
if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";
|
|
if (params.has("live")) f.includeLive = params.get("live") === "1";
|
|
if (params.has("tags"))
|
|
f.tags = (params.get("tags") ?? "")
|
|
.split(",")
|
|
.map((s) => Number(s))
|
|
.filter((n) => Number.isInteger(n));
|
|
// Per-category tag match; a legacy global `tagmode=and` (topic-only) migrates into the topic slot.
|
|
f.tagModes = {
|
|
language: params.get("tagmode_language") === "and" ? "and" : "or",
|
|
topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or",
|
|
other: params.get("tagmode_other") === "and" ? "and" : "or",
|
|
};
|
|
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
|
|
f.maxAgeDays = num(params.get("maxage"));
|
|
f.dateFrom = params.get("from") || undefined;
|
|
f.dateTo = params.get("to") || undefined;
|
|
f.minDuration = num(params.get("mindur"));
|
|
f.maxDuration = num(params.get("maxdur"));
|
|
return f;
|
|
}
|
|
|
|
/** True if the query string carries any filter param we recognize. */
|
|
export function hasFilterParams(params: URLSearchParams): boolean {
|
|
return KEYS.some((k) => params.has(k));
|
|
}
|
|
|
|
// The single source of truth for valid page ids; `Page` and the runtime validator both
|
|
// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
|
|
// sync). "feed" is the default/fallback.
|
|
const PAGES = [
|
|
"feed",
|
|
"channels",
|
|
"stats",
|
|
"playlists",
|
|
"settings",
|
|
"scheduler",
|
|
"config",
|
|
"users",
|
|
"audit",
|
|
"notifications",
|
|
"messages",
|
|
"downloads",
|
|
"plex",
|
|
] as const;
|
|
|
|
export type Page = (typeof PAGES)[number];
|
|
|
|
/** Narrow an arbitrary string to a known Page (else null). */
|
|
export function isPage(p: string | null | undefined): p is Page {
|
|
return !!p && (PAGES as readonly string[]).includes(p);
|
|
}
|
|
|
|
export function readPage(): Page {
|
|
const p = new URLSearchParams(window.location.search).get("page");
|
|
return isPage(p) ? p : "feed";
|
|
}
|
|
|
|
/** Build a URL (path + query) from the CURRENT location's query with `updates` applied — a null value
|
|
* deletes that key, any other string sets it. Lets each provider own its own nav key(s) — page / chan /
|
|
* v / pl / the feed-filter keys — and update the address bar without clobbering the others (read-modify-
|
|
* write on the live query). The caller decides pushState vs replaceState with the returned string. */
|
|
export function urlWithParams(updates: Record<string, string | null>): string {
|
|
const params = new URLSearchParams(window.location.search);
|
|
for (const [k, v] of Object.entries(updates)) {
|
|
if (v == null) params.delete(k);
|
|
else params.set(k, v);
|
|
}
|
|
const qs = params.toString();
|
|
return `${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
|
}
|
|
|
|
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
|
|
* ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now
|
|
* live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now
|
|
* that the query is the source of truth for navigation. No-op when none of the keys are present. */
|
|
export function clearParams(...keys: string[]): void {
|
|
const params = new URLSearchParams(window.location.search);
|
|
let changed = false;
|
|
for (const k of keys) {
|
|
if (params.has(k)) {
|
|
params.delete(k);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (!changed) return;
|
|
const qs = params.toString();
|
|
window.history.replaceState(
|
|
window.history.state,
|
|
"",
|
|
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
|
);
|
|
}
|