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:
@@ -100,6 +100,11 @@ def serialize(row) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _tag_visible(user: User):
|
||||
"""A ChannelTag row this user may see: a shared system tag (user_id NULL) or their own."""
|
||||
return or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||
|
||||
|
||||
def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]:
|
||||
"""Group tag ids by their category (language/topic/other). The single source for the category split
|
||||
shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the
|
||||
@@ -118,6 +123,7 @@ def filtered_query(
|
||||
*,
|
||||
tags: list[int],
|
||||
tag_modes: dict[str, str],
|
||||
untagged: bool = False,
|
||||
channel_ids: list[str],
|
||||
q: str | None,
|
||||
min_duration: int | None,
|
||||
@@ -294,7 +300,7 @@ def filtered_query(
|
||||
# selections but still honours the other categories' filters).
|
||||
if exclude_tag_category:
|
||||
by_category.pop(exclude_tag_category, None)
|
||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||
visible = _tag_visible(user)
|
||||
for category, ids in by_category.items():
|
||||
if tag_modes.get(category, "or") == "and" and len(ids) > 1:
|
||||
sub = (
|
||||
@@ -307,6 +313,18 @@ def filtered_query(
|
||||
sub = select(ChannelTag.channel_id).where(ChannelTag.tag_id.in_(ids), visible)
|
||||
query = query.where(Video.channel_id.in_(sub))
|
||||
|
||||
# "No tag" filter (item B): keep only channels the USER has not personally tagged in the channel
|
||||
# manager — i.e. channels with no ChannelTag row owned by this user (user_id == me, source "user").
|
||||
# System/auto tags (user_id NULL) don't count: this is "channels I haven't tagged yet". It's a plain
|
||||
# channel-level narrowing (like channel_ids/date), NOT a tag-category selection, so feed_summary
|
||||
# leaves it applied in the base pass and needs no mirror in passes().
|
||||
if untagged:
|
||||
query = query.where(
|
||||
Video.channel_id.notin_(
|
||||
select(ChannelTag.channel_id).where(ChannelTag.user_id == user.id)
|
||||
)
|
||||
)
|
||||
|
||||
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
||||
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
|
||||
explicit_view = show in ("watched", "hidden")
|
||||
@@ -355,8 +373,9 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
within, a category in "and" mode requires ALL its selected tags)."""
|
||||
tag_modes = params.get("tag_modes", {})
|
||||
|
||||
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
|
||||
# is present to facet over.
|
||||
# ONE pass: per-channel video counts for the base filter, the positive tag filter stripped so every
|
||||
# candidate channel is present to facet over. The "No tag" (untagged) filter is a plain channel-level
|
||||
# narrowing, so it STAYS applied here (not stripped) and the facets naturally drill down within it.
|
||||
base, _rank = filtered_query(db, user, **{**params, "tags": []})
|
||||
ch_col = base.with_only_columns(Video.channel_id).subquery()
|
||||
channel_counts: dict[str, int] = dict(
|
||||
@@ -386,7 +405,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
|
||||
def passes(ch_tags: set[int], exclude_cat: str | None) -> bool:
|
||||
# The Python mirror of filtered_query's SQL tag block (AND across categories, OR within,
|
||||
# topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there.
|
||||
# topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there. (The "No tag"
|
||||
# filter is applied in the base query above, so it needs no mirror here.)
|
||||
for cat, sel in selected_by_cat.items():
|
||||
if cat == exclude_cat:
|
||||
continue
|
||||
|
||||
@@ -53,6 +53,8 @@ def _feed_params(
|
||||
tag_mode_language: str = "or",
|
||||
tag_mode_topic: str = "or",
|
||||
tag_mode_other: str = "or",
|
||||
# "No tag" filter: keep only channels the user has not personally tagged (no user-owned ChannelTag).
|
||||
untagged: bool = False,
|
||||
channel_ids: list[str] = Query(default=[]),
|
||||
q: str | None = None,
|
||||
min_duration: int | None = None,
|
||||
@@ -74,6 +76,7 @@ def _feed_params(
|
||||
"topic": tag_mode_topic,
|
||||
"other": tag_mode_other,
|
||||
},
|
||||
"untagged": untagged,
|
||||
"channel_ids": channel_ids,
|
||||
"q": q,
|
||||
"min_duration": min_duration,
|
||||
|
||||
@@ -162,11 +162,45 @@ _SUMMARY_CASES = [
|
||||
"show=all&tags={O1}&tags={O2}&tag_mode_other=and",
|
||||
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
|
||||
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
|
||||
# "No tag" filter (item B): channels with no USER tag. It stays applied in the base pass, so the
|
||||
# facet/count lockstep must still hold with it on (alone, and combined with a positive tag filter).
|
||||
"show=all&untagged=true",
|
||||
"show=all&untagged=true&tags={L1}",
|
||||
"show=all&q=clip",
|
||||
"show=unwatched",
|
||||
]
|
||||
|
||||
|
||||
def test_untagged_filter_keeps_only_channels_the_user_has_not_tagged(db, as_user, make_user):
|
||||
"""The "No tag" filter (item B): `untagged=true` keeps only channels with NO tag the USER personally
|
||||
attached (no user-owned ChannelTag). System/auto tags (user_id NULL) don't count, and another user's
|
||||
tag on the same channel doesn't either."""
|
||||
user = make_user(email="untagged@example.com")
|
||||
other = make_user(email="someone-else@example.com")
|
||||
mine = Tag(user_id=user.id, name="Fav", category="other")
|
||||
sys_topic = Tag(user_id=None, name="Tech", category="topic")
|
||||
theirs = Tag(user_id=other.id, name="TheirTag", category="other")
|
||||
db.add_all([mine, sys_topic, theirs])
|
||||
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chMine", "chSys", "chNone")}
|
||||
db.add_all(chans.values())
|
||||
db.flush()
|
||||
# chMine: the user's own tag → tagged. chSys: only a system tag + another user's tag → still
|
||||
# "untagged" for THIS user. chNone: nothing at all → untagged.
|
||||
db.add(ChannelTag(channel_id="chMine", tag_id=mine.id, user_id=user.id, source="user"))
|
||||
db.add(ChannelTag(channel_id="chSys", tag_id=sys_topic.id, user_id=None, source="auto"))
|
||||
db.add(ChannelTag(channel_id="chSys", tag_id=theirs.id, user_id=other.id, source="user"))
|
||||
for cid in chans:
|
||||
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
|
||||
db.add(_video(f"{cid}v", cid, f"{cid} clip"))
|
||||
db.commit()
|
||||
client = as_user(user)
|
||||
|
||||
# Off: every subscribed channel shows.
|
||||
assert _feed_ids(client, "show=all") == {"chMinev", "chSysv", "chNonev"}
|
||||
# On: only channels the user has NOT personally tagged (a system tag / another user's tag don't count).
|
||||
assert _feed_ids(client, "show=all&untagged=true") == {"chSysv", "chNonev"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _SUMMARY_CASES)
|
||||
def test_feed_summary_matches_count_plus_facets(db, as_user, make_user, case):
|
||||
# /feed/summary must return byte-identical numbers to the old /feed/count + /facets it replaces,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, type ManagedChannel } from "../lib/api";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
@@ -217,11 +217,18 @@ export default function Channels() {
|
||||
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
|
||||
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
|
||||
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
|
||||
// Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed.
|
||||
const toggleTagFilter = (tagId: number) => {
|
||||
const id = String(tagId);
|
||||
const next = tagFilter.includes(id) ? tagFilter.filter((x) => x !== id) : [...tagFilter, id];
|
||||
const base = tagFilter.filter((x) => x !== NO_TAG_SENTINEL);
|
||||
const next = base.includes(id) ? base.filter((x) => x !== id) : [...base, id];
|
||||
setFilters({ ...filters, tags: next });
|
||||
};
|
||||
// The "No tag" chip: filter the table to channels you haven't personally tagged yet. Exclusive with
|
||||
// real tag chips (clicking it drops them); the Tags column's test reads the sentinel (channelColumns).
|
||||
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
|
||||
const toggleNoTagFilter = () =>
|
||||
setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
|
||||
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||
useEffect(() => {
|
||||
if (focusChannelName) setSearch(focusChannelName);
|
||||
@@ -451,6 +458,13 @@ export default function Channels() {
|
||||
{tg.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={toggleNoTagFilter}
|
||||
aria-pressed={noTagOn}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{t("channels.tags.noTag")}
|
||||
</button>
|
||||
{tagFilter.length > 0 && (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
api,
|
||||
getActiveAccount,
|
||||
coerceTagModes,
|
||||
coerceUntagged,
|
||||
DEFAULT_TAG_MODES,
|
||||
type FeedFilters,
|
||||
} from "../lib/api";
|
||||
@@ -39,6 +40,7 @@ import { useNavigation } from "./NavigationProvider";
|
||||
const DEFAULT_FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
untagged: false,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
@@ -74,7 +76,12 @@ export function loadAccountFilters(id: number | null): FeedFilters {
|
||||
// (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
|
||||
if (hasStored && fKey) {
|
||||
const raw = readJSON<Partial<FeedFilters>>(fKey, {});
|
||||
return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) };
|
||||
return {
|
||||
...DEFAULT_FILTERS,
|
||||
...raw,
|
||||
tagModes: coerceTagModes(raw),
|
||||
untagged: coerceUntagged(raw),
|
||||
};
|
||||
}
|
||||
return { ...DEFAULT_FILTERS };
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
api,
|
||||
coercePlexFilters,
|
||||
coerceTagModes,
|
||||
coerceUntagged,
|
||||
getActiveAccount,
|
||||
plexFiltersSignature,
|
||||
type FeedFilters,
|
||||
@@ -42,6 +43,7 @@ export const asFilters = (raw: FeedFilters): FeedFilters => ({
|
||||
tags: raw.tags ?? [],
|
||||
channelIds: raw.channelIds ?? [],
|
||||
tagModes: coerceTagModes(raw),
|
||||
untagged: coerceUntagged(raw),
|
||||
});
|
||||
|
||||
type ViewSource = "feed" | "plex";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
|
||||
import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
|
||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||
@@ -104,6 +104,41 @@ export default function Sidebar({
|
||||
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
|
||||
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
|
||||
|
||||
// The "Your tags" combobox carries the "No tag" sentinel (item B) at the top: selecting it filters to
|
||||
// channels you HAVEN'T personally tagged in the channel manager (`filters.untagged`). It's mutually
|
||||
// exclusive with picking real user tags — choosing it clears your tag picks, and picking a tag clears
|
||||
// it. Language/Topic are auto/system tags, so "No tag" is meaningless there and only lives here.
|
||||
function userTagsCombo(): ReactNode {
|
||||
const noneOn = !!filters.untagged;
|
||||
const inList = (id: number) => userTags.some((tg) => tg.id === id);
|
||||
const onChange = (next: string[]) => {
|
||||
if (next.includes(NO_TAG_SENTINEL) && !noneOn) {
|
||||
setFilters({ ...filters, tags: filters.tags.filter((id) => !inList(id)), untagged: true });
|
||||
} else {
|
||||
const reals = next.filter((v) => v !== NO_TAG_SENTINEL).map(Number);
|
||||
setFilters({
|
||||
...filters,
|
||||
tags: [...filters.tags.filter((id) => !inList(id)), ...reals],
|
||||
untagged: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.tags")}
|
||||
options={[
|
||||
{ value: NO_TAG_SENTINEL, label: t("sidebar.noTagOption") },
|
||||
...visibleChips(userTags).map(tagOption),
|
||||
]}
|
||||
selected={noneOn ? [NO_TAG_SENTINEL] : selectedIn(userTags)}
|
||||
onChange={onChange}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
matchMode={filters.tagModes.other}
|
||||
onMatchModeChange={(m) => setTagMode("other", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
|
||||
// disagree about what's applied.
|
||||
const activeCount = useActiveFilters(filters, setFilters).length;
|
||||
@@ -124,7 +159,7 @@ export default function Sidebar({
|
||||
return (
|
||||
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
|
||||
);
|
||||
case "language": {
|
||||
case "language":
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.language")}
|
||||
@@ -136,8 +171,7 @@ export default function Sidebar({
|
||||
onMatchModeChange={(m) => setTagMode("language", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "topic": {
|
||||
case "topic":
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.topic")}
|
||||
@@ -149,22 +183,10 @@ export default function Sidebar({
|
||||
onMatchModeChange={(m) => setTagMode("topic", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "tags": {
|
||||
case "tags":
|
||||
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
|
||||
// body is just the picker.
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.tags")}
|
||||
options={visibleChips(userTags).map(tagOption)}
|
||||
selected={selectedIn(userTags)}
|
||||
onChange={(next) => setTagSelection(userTags, next)}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
matchMode={filters.tagModes.other}
|
||||
onMatchModeChange={(m) => setTagMode("other", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// body is just the picker — user tags plus the "No tag" sentinel.
|
||||
return userTagsCombo();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change
|
||||
// which channels the top chips show.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applySyncFilter } from "./channelColumns";
|
||||
import { type ManagedChannel } from "../lib/api";
|
||||
import { applySyncFilter, tagsColumnTest } from "./channelColumns";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
|
||||
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
|
||||
id: "c",
|
||||
@@ -51,3 +51,23 @@ describe("applySyncFilter", () => {
|
||||
expect(applySyncFilter(rows, "needsFull").map((r) => r.id)).toEqual(["recentOnly", "none"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagsColumnTest (item B — 'No tag')", () => {
|
||||
const tagged = ch({ id: "tagged", tag_ids: [1, 2] });
|
||||
const untagged = ch({ id: "untagged", tag_ids: [] });
|
||||
|
||||
it("the No-tag sentinel keeps only channels with no user tag", () => {
|
||||
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL])).toBe(false);
|
||||
expect(tagsColumnTest(untagged, [NO_TAG_SENTINEL])).toBe(true);
|
||||
});
|
||||
|
||||
it("real tag values match ANY selected id (sentinel absent)", () => {
|
||||
expect(tagsColumnTest(tagged, ["2"])).toBe(true);
|
||||
expect(tagsColumnTest(tagged, ["3"])).toBe(false);
|
||||
expect(tagsColumnTest(untagged, ["1"])).toBe(false);
|
||||
});
|
||||
|
||||
it("the sentinel wins if it somehow co-exists with real ids", () => {
|
||||
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL, "1"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ComponentProps } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
|
||||
import type { ManagedChannel } from "../lib/api";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
|
||||
import type { Column } from "./DataTable";
|
||||
import Tooltip from "./Tooltip";
|
||||
@@ -42,6 +42,14 @@ export function applySyncFilter(rows: ManagedChannel[], value: unknown): Managed
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't
|
||||
* personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that),
|
||||
* so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */
|
||||
export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
|
||||
if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0;
|
||||
return values.some((v) => c.tag_ids.includes(Number(v)));
|
||||
}
|
||||
|
||||
// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
|
||||
// full history are both in — folding the retired Sync column's at-a-glance status into the value it
|
||||
// describes. Full detail stays in the hover tooltip.
|
||||
@@ -289,7 +297,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
filter: {
|
||||
kind: "multi",
|
||||
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
||||
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
|
||||
test: tagsColumnTest,
|
||||
},
|
||||
render: (c) => (
|
||||
<ChannelTagsControl
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
"noneYet": "No tags yet — create one below.",
|
||||
"create": "Create tag",
|
||||
"createPlaceholder": "New tag…",
|
||||
"createFailed": "Could not create the tag."
|
||||
"createFailed": "Could not create the tag.",
|
||||
"noTag": "No tag"
|
||||
},
|
||||
"loading": "Loading channels…",
|
||||
"empty": "No channels.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"label": "Filtered by",
|
||||
"remove": "Remove filter: {{name}}",
|
||||
"contentNone": "No content types",
|
||||
"untagged": "Untagged",
|
||||
"count_one": "{{count}} filter",
|
||||
"count_other": "{{count}} filters",
|
||||
"moreCount_one": "{{count}} more filter",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"clearDates": "clear dates",
|
||||
"reshuffle": "Reshuffle",
|
||||
"noMatchingTags": "No matching tags here",
|
||||
"noTagOption": "No tag",
|
||||
"manageTags": "Manage",
|
||||
"widget": {
|
||||
"savedviews": "Saved views",
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
"noneYet": "Még nincs címke — hozz létre egyet lent.",
|
||||
"create": "Címke létrehozása",
|
||||
"createPlaceholder": "Új címke…",
|
||||
"createFailed": "A címke létrehozása nem sikerült."
|
||||
"createFailed": "A címke létrehozása nem sikerült.",
|
||||
"noTag": "Nincs címke"
|
||||
},
|
||||
"loading": "Csatornák betöltése…",
|
||||
"empty": "Nincsenek csatornák.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"label": "Szűrve",
|
||||
"remove": "Szűrő eltávolítása: {{name}}",
|
||||
"contentNone": "Nincs tartalomtípus",
|
||||
"untagged": "Címkézetlen",
|
||||
"count_one": "{{count}} szűrő",
|
||||
"count_other": "{{count}} szűrő",
|
||||
"moreCount_one": "még {{count}} szűrő",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"clearDates": "dátumok törlése",
|
||||
"reshuffle": "Újrakeverés",
|
||||
"noMatchingTags": "Nincs ide illő címke",
|
||||
"noTagOption": "Nincs címke",
|
||||
"manageTags": "Kezelés",
|
||||
"widget": {
|
||||
"savedviews": "Mentett nézetek",
|
||||
|
||||
@@ -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