feat(feed,channels): "No tag" filter for untagged channels (E8·S1)
Filter to channels you haven't personally tagged in the channel manager (no user-owned ChannelTag; auto language/topic tags don't count): - Feed: a "No tag" sentinel in the "Your tags" sidebar combobox (exclusive with real tag picks) + a removable "Untagged" chip; new `untagged` bool feed param / `notag` URL key, persisted + coerced on every filter path. - Channel manager: a virtual "No tag" chip in the tag-chip row filters the table to untagged channels (client-side, shared NO_TAG_SENTINEL). Backend `untagged` is a plain channel-level narrowing, so feed_summary keeps it applied in the base pass — the count/facet lockstep holds with no mirror. Note: item E (per-tag counts) and item D (Any/All toggle) were already shipped; S1 delivers only item B.
This commit is contained in:
@@ -32,7 +32,13 @@ export type * from "./api/setup";
|
||||
import { feedApi } from "./api/feed";
|
||||
export type * from "./api/feed";
|
||||
export type * from "./api/tagModes";
|
||||
export { DEFAULT_TAG_MODES, TAG_CATEGORIES, coerceTagModes } from "./api/tagModes";
|
||||
export {
|
||||
DEFAULT_TAG_MODES,
|
||||
TAG_CATEGORIES,
|
||||
NO_TAG_SENTINEL,
|
||||
coerceTagModes,
|
||||
coerceUntagged,
|
||||
} from "./api/tagModes";
|
||||
import { channelsApi } from "./api/channels";
|
||||
export type * from "./api/channels";
|
||||
import { syncApi } from "./api/sync";
|
||||
|
||||
@@ -71,6 +71,9 @@ export interface FeedFilters {
|
||||
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
|
||||
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
|
||||
librarySource?: "organic" | "all" | "search" | "plex";
|
||||
/** "No tag" filter (item B): keep only channels the user has NOT personally tagged in the channel
|
||||
* manager (no user-owned ChannelTag). Auto/system tags don't count. */
|
||||
untagged?: boolean;
|
||||
/** OR-ed together: a video from ANY of these passes, and every other filter still applies. */
|
||||
channelIds: string[];
|
||||
maxAgeDays?: number;
|
||||
@@ -89,6 +92,7 @@ function filterParams(f: FeedFilters): URLSearchParams {
|
||||
for (const cat of TAG_CATEGORIES) {
|
||||
if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and");
|
||||
}
|
||||
if (f.untagged) p.set("untagged", "true");
|
||||
if (f.q) p.set("q", f.q);
|
||||
p.set("scope", f.scope);
|
||||
p.set("show_normal", String(f.includeNormal));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { coerceTagModes, DEFAULT_TAG_MODES } from "./tagModes";
|
||||
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES } from "./tagModes";
|
||||
|
||||
// coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL
|
||||
// parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`.
|
||||
@@ -40,3 +40,15 @@ describe("coerceTagModes", () => {
|
||||
expect(coerceTagModes({ tagModes: { language: 1 as unknown as "or" } }).language).toBe("or");
|
||||
});
|
||||
});
|
||||
|
||||
// coerceUntagged guards the "No tag" (item B) flag on the same persisted/URL/saved-view paths.
|
||||
describe("coerceUntagged", () => {
|
||||
it("is true only for an explicit untagged:true", () => {
|
||||
expect(coerceUntagged({ untagged: true })).toBe(true);
|
||||
expect(coerceUntagged(undefined)).toBe(false);
|
||||
expect(coerceUntagged({})).toBe(false);
|
||||
expect(coerceUntagged(null)).toBe(false);
|
||||
expect(coerceUntagged({ untagged: "true" })).toBe(false);
|
||||
expect(coerceUntagged({ untagged: ["other"] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,11 @@ export type TagModes = Record<TagCategory, TagMode>;
|
||||
export const TAG_CATEGORIES: readonly TagCategory[] = ["language", "topic", "other"];
|
||||
export const DEFAULT_TAG_MODES: TagModes = { language: "or", topic: "or", other: "or" };
|
||||
|
||||
/** UI sentinel for the "No tag" (item B) option — the feed's user-tags combobox and the channel
|
||||
* manager's tag chip row both use it to mean "channels I haven't personally tagged". Not a real tag
|
||||
* id (those are numeric strings), so it can never collide with one. */
|
||||
export const NO_TAG_SENTINEL = "__none__";
|
||||
|
||||
/** Normalise a persisted/shared filter's tag match into the per-category shape, migrating the legacy
|
||||
* global `tagMode` (which only ever affected topics) into the topic slot. */
|
||||
export function coerceTagModes(raw: unknown): TagModes {
|
||||
@@ -22,3 +27,8 @@ export function coerceTagModes(raw: unknown): TagModes {
|
||||
};
|
||||
return { language: pick("language"), topic: pick("topic"), other: pick("other") };
|
||||
}
|
||||
|
||||
/** Normalise a persisted/shared filter's "No tag" (item B) flag to a boolean. */
|
||||
export function coerceUntagged(raw: unknown): boolean {
|
||||
return (raw as { untagged?: unknown } | null)?.untagged === true;
|
||||
}
|
||||
|
||||
@@ -140,4 +140,12 @@ describe("filterParamUpdates", () => {
|
||||
const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS);
|
||||
expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
|
||||
});
|
||||
|
||||
it("round-trips the 'No tag' (untagged) flag; emitted only when on", () => {
|
||||
expect(filtersToParams({ ...DEFAULTS, untagged: true }).get("notag")).toBe("1");
|
||||
expect(filtersToParams({ ...DEFAULTS, untagged: false }).has("notag")).toBe(false);
|
||||
expect(paramsToFilters(new URLSearchParams("notag=1"), DEFAULTS).untagged).toBe(true);
|
||||
// Absent → the base value is left untouched (undefined here, i.e. off).
|
||||
expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).untagged).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ const KEYS = [
|
||||
"tagmode_topic",
|
||||
"tagmode_other",
|
||||
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
|
||||
"notag",
|
||||
"channel",
|
||||
"maxage",
|
||||
"from",
|
||||
@@ -44,6 +45,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
|
||||
for (const cat of TAG_CATEGORIES) {
|
||||
if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and");
|
||||
}
|
||||
if (f.untagged) p.set("notag", "1");
|
||||
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);
|
||||
@@ -95,6 +97,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
|
||||
topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or",
|
||||
other: params.get("tagmode_other") === "and" ? "and" : "or",
|
||||
};
|
||||
if (params.has("notag")) f.untagged = params.get("notag") === "1";
|
||||
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
|
||||
f.maxAgeDays = num(params.get("maxage"));
|
||||
f.dateFrom = params.get("from") || undefined;
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ActiveFilter {
|
||||
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
untagged: false,
|
||||
channelIds: [],
|
||||
sort: "newest",
|
||||
includeNormal: true,
|
||||
@@ -66,6 +67,15 @@ export function useActiveFilters(
|
||||
});
|
||||
}
|
||||
|
||||
// "No tag" (item B): channels the user hasn't personally tagged. One removable chip.
|
||||
if (filters.untagged) {
|
||||
out.push({
|
||||
key: "untagged",
|
||||
label: t("feed.chips.untagged"),
|
||||
remove: () => setFilters({ ...filters, untagged: false }),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.show !== "unwatched") {
|
||||
out.push({
|
||||
key: "show",
|
||||
|
||||
Reference in New Issue
Block a user