feat(feed): per-category any/all tag match, in each combobox's popover footer
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).
This commit is contained in:
+11
-10
@@ -117,7 +117,7 @@ def filtered_query(
|
|||||||
user: User,
|
user: User,
|
||||||
*,
|
*,
|
||||||
tags: list[int],
|
tags: list[int],
|
||||||
tag_mode: str,
|
tag_modes: dict[str, str],
|
||||||
channel_ids: list[str],
|
channel_ids: list[str],
|
||||||
q: str | None,
|
q: str | None,
|
||||||
min_duration: int | None,
|
min_duration: int | None,
|
||||||
@@ -293,9 +293,10 @@ def filtered_query(
|
|||||||
query = query.where(or_(func.unaccent(Video.title).ilike(like), channel_match))
|
query = query.where(or_(func.unaccent(Video.title).ilike(like), channel_match))
|
||||||
|
|
||||||
if tags:
|
if tags:
|
||||||
# AND across tag categories (e.g. language AND topic narrows), OR within a category; the any/all
|
# AND across tag categories (e.g. language AND topic narrows), OR within a category — unless that
|
||||||
# toggle controls multiple topic tags. ⚠️ feed_summary.passes() reimplements this exact rule in
|
# category's mode is "and" (its per-category any/all toggle), which requires a channel to carry
|
||||||
# Python for the sidebar count/facets — keep the two in lockstep (equivalence-tested by
|
# ALL its selected tags. ⚠️ feed_summary.passes() reimplements this exact rule in Python for the
|
||||||
|
# sidebar count/facets — keep the two in lockstep (equivalence-tested by
|
||||||
# test_feed_summary_matches_count_plus_facets).
|
# test_feed_summary_matches_count_plus_facets).
|
||||||
by_category = tags_by_category(db, tags)
|
by_category = tags_by_category(db, tags)
|
||||||
# Facet counting drops the category being counted so its own chips don't zero each
|
# Facet counting drops the category being counted so its own chips don't zero each
|
||||||
@@ -305,7 +306,7 @@ def filtered_query(
|
|||||||
by_category.pop(exclude_tag_category, None)
|
by_category.pop(exclude_tag_category, None)
|
||||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||||
for category, ids in by_category.items():
|
for category, ids in by_category.items():
|
||||||
if category == "topic" and tag_mode == "and" and len(ids) > 1:
|
if tag_modes.get(category, "or") == "and" and len(ids) > 1:
|
||||||
sub = (
|
sub = (
|
||||||
select(ChannelTag.channel_id)
|
select(ChannelTag.channel_id)
|
||||||
.where(ChannelTag.tag_id.in_(ids), visible)
|
.where(ChannelTag.tag_id.in_(ids), visible)
|
||||||
@@ -363,8 +364,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
|||||||
(the tag's own category dropped, unless it's a conjunctive topic-AND — mirroring the
|
(the tag's own category dropped, unless it's a conjunctive topic-AND — mirroring the
|
||||||
old /facets exclude_tag_category logic exactly).
|
old /facets exclude_tag_category logic exactly).
|
||||||
Tag matching is channel-based, identical to filtered_query's tag block (AND across categories, OR
|
Tag matching is channel-based, identical to filtered_query's tag block (AND across categories, OR
|
||||||
within, topic-AND requires ALL selected topics)."""
|
within, a category in "and" mode requires ALL its selected tags)."""
|
||||||
tag_mode = params.get("tag_mode", "or")
|
tag_modes = params.get("tag_modes", {})
|
||||||
|
|
||||||
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
|
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
|
||||||
# is present to facet over.
|
# is present to facet over.
|
||||||
@@ -401,8 +402,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
|||||||
for cat, sel in selected_by_cat.items():
|
for cat, sel in selected_by_cat.items():
|
||||||
if cat == exclude_cat:
|
if cat == exclude_cat:
|
||||||
continue
|
continue
|
||||||
if cat == "topic" and tag_mode == "and" and len(sel) > 1:
|
if tag_modes.get(cat, "or") == "and" and len(sel) > 1:
|
||||||
if not sel <= ch_tags: # conjunctive: channel must carry ALL selected topics
|
if not sel <= ch_tags: # conjunctive: channel must carry ALL this category's selected tags
|
||||||
return False
|
return False
|
||||||
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
|
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
|
||||||
return False
|
return False
|
||||||
@@ -416,7 +417,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
|||||||
for category in ("language", "topic", "other"):
|
for category in ("language", "topic", "other"):
|
||||||
# Drop this category's own selections so its chips keep independent counts; a conjunctive
|
# Drop this category's own selections so its chips keep independent counts; a conjunctive
|
||||||
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
|
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
|
||||||
exclude = None if (category == "topic" and tag_mode == "and") else category
|
exclude = None if tag_modes.get(category, "or") == "and" else category
|
||||||
for cid, ch_tags in channel_tags.items():
|
for cid, ch_tags in channel_tags.items():
|
||||||
if not passes(ch_tags, exclude):
|
if not passes(ch_tags, exclude):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ FINISH_MARGIN_SECONDS = 10
|
|||||||
# Shared query parameters for /feed and /feed/count.
|
# Shared query parameters for /feed and /feed/count.
|
||||||
def _feed_params(
|
def _feed_params(
|
||||||
tags: list[int] = Query(default=[]),
|
tags: list[int] = Query(default=[]),
|
||||||
tag_mode: str = "or",
|
# Per-category tag match: each of language / topic / other independently ORs (any) or ANDs (all)
|
||||||
|
# its own selected tags. Defaults to "or". (Superseded the single global `tag_mode`.)
|
||||||
|
tag_mode_language: str = "or",
|
||||||
|
tag_mode_topic: str = "or",
|
||||||
|
tag_mode_other: str = "or",
|
||||||
channel_ids: list[str] = Query(default=[]),
|
channel_ids: list[str] = Query(default=[]),
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
min_duration: int | None = None,
|
min_duration: int | None = None,
|
||||||
@@ -65,7 +69,11 @@ def _feed_params(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"tag_mode": tag_mode,
|
"tag_modes": {
|
||||||
|
"language": tag_mode_language,
|
||||||
|
"topic": tag_mode_topic,
|
||||||
|
"other": tag_mode_other,
|
||||||
|
},
|
||||||
"channel_ids": channel_ids,
|
"channel_ids": channel_ids,
|
||||||
"q": q,
|
"q": q,
|
||||||
"min_duration": min_duration,
|
"min_duration": min_duration,
|
||||||
@@ -162,7 +170,7 @@ def get_facets(
|
|||||||
# independent counts (you can OR more of them in). Conjunctive (AND, topics only)
|
# independent counts (you can OR more of them in). Conjunctive (AND, topics only)
|
||||||
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
|
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
|
||||||
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
|
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
|
||||||
conjunctive = category == "topic" and params.get("tag_mode") == "and"
|
conjunctive = params.get("tag_modes", {}).get(category) == "and"
|
||||||
base, _rank = filtered_query(
|
base, _rank = filtered_query(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ _SUMMARY_CASES = [
|
|||||||
"show=all&tags={L1}",
|
"show=all&tags={L1}",
|
||||||
"show=all&tags={T1}",
|
"show=all&tags={T1}",
|
||||||
"show=all&tags={L1}&tags={T1}",
|
"show=all&tags={L1}&tags={T1}",
|
||||||
"show=all&tags={T1}&tags={T2}&tag_mode=or",
|
"show=all&tags={T1}&tags={T2}&tag_mode_topic=or",
|
||||||
"show=all&tags={T1}&tags={T2}&tag_mode=and",
|
"show=all&tags={T1}&tags={T2}&tag_mode_topic=and",
|
||||||
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
|
"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
|
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
|
||||||
"show=all&q=clip",
|
"show=all&q=clip",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { useFeedFilters } from "./FeedFiltersProvider";
|
|||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
|
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
|
||||||
import type { FeedView } from "../lib/feedView";
|
import type { FeedView } from "../lib/feedView";
|
||||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||||
import { ChannelTagsControl } from "./ChannelTags";
|
import { ChannelTagsControl } from "./ChannelTags";
|
||||||
@@ -231,7 +231,7 @@ export default function ChannelPage({
|
|||||||
// the explored, per-user-private videos show — the backend gates them to this explorer).
|
// the explored, per-user-private videos show — the backend gates them to this explorer).
|
||||||
const [filters, setFilters] = useState<FeedFilters>(() => ({
|
const [filters, setFilters] = useState<FeedFilters>(() => ({
|
||||||
tags: [],
|
tags: [],
|
||||||
tagMode: "or",
|
tagModes: DEFAULT_TAG_MODES,
|
||||||
q: "",
|
q: "",
|
||||||
sort: "newest",
|
sort: "newest",
|
||||||
scope: "all",
|
scope: "all",
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ export interface ComboboxProps {
|
|||||||
/** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed)
|
/** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed)
|
||||||
* list — the collection picker passes the stored title here so a reloaded filter still names itself. */
|
* list — the collection picker passes the stored title here so a reloaded filter still names itself. */
|
||||||
selectedLabels?: Record<string, string>;
|
selectedLabels?: Record<string, string>;
|
||||||
|
/** Optional "any/all" match segment in the popover footer (like SortControl's direction segment):
|
||||||
|
* "or" = a match on ANY selected value, "and" = must match ALL. Provide BOTH to show it; it appears
|
||||||
|
* only once 2+ values are selected (below that, any and all are the same). */
|
||||||
|
matchMode?: "or" | "and";
|
||||||
|
onMatchModeChange?: (mode: "or" | "and") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Combobox({
|
export default function Combobox({
|
||||||
@@ -73,6 +78,8 @@ export default function Combobox({
|
|||||||
maxVisible = 50,
|
maxVisible = 50,
|
||||||
renderOption,
|
renderOption,
|
||||||
selectedLabels,
|
selectedLabels,
|
||||||
|
matchMode,
|
||||||
|
onMatchModeChange,
|
||||||
}: ComboboxProps) {
|
}: ComboboxProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -282,6 +289,32 @@ export default function Combobox({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Any/all match segment (like SortControl's direction footer) — only meaningful, so only shown,
|
||||||
|
once 2+ values are picked. mousedown-preventDefault keeps the input focused so the list stays
|
||||||
|
open in the combobox's focus model. */}
|
||||||
|
{matchMode != null && onMatchModeChange && selected.length >= 2 && (
|
||||||
|
<div className="mt-1 flex items-center gap-2 border-t border-border px-1 pt-2">
|
||||||
|
<span className="text-2xs text-muted">{t("sidebar.match")}</span>
|
||||||
|
<div className="ml-auto flex gap-1">
|
||||||
|
{(["or", "and"] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={matchMode === m}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onMatchModeChange(m)}
|
||||||
|
className={`rounded-md border px-2 py-0.5 text-2xs transition ${
|
||||||
|
matchMode === m
|
||||||
|
? "border-accent bg-accent/15 text-accent"
|
||||||
|
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(m === "or" ? "sidebar.any" : "sidebar.all")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
|
import { api, getActiveAccount, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api";
|
||||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
|
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
|
||||||
@@ -32,7 +32,7 @@ import { useNavigation } from "./NavigationProvider";
|
|||||||
|
|
||||||
const DEFAULT_FILTERS: FeedFilters = {
|
const DEFAULT_FILTERS: FeedFilters = {
|
||||||
tags: [],
|
tags: [],
|
||||||
tagMode: "or",
|
tagModes: DEFAULT_TAG_MODES,
|
||||||
channelIds: [],
|
channelIds: [],
|
||||||
q: "",
|
q: "",
|
||||||
sort: "newest",
|
sort: "newest",
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
|
import {
|
||||||
|
api,
|
||||||
|
getActiveAccount,
|
||||||
|
coerceTagModes,
|
||||||
|
type FeedFilters,
|
||||||
|
type SavedView,
|
||||||
|
} from "../lib/api";
|
||||||
import { filtersToParams } from "../lib/urlState";
|
import { filtersToParams } from "../lib/urlState";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { accountKey, LS, writeJSON } from "../lib/storage";
|
import { accountKey, LS, writeJSON } from "../lib/storage";
|
||||||
@@ -37,6 +43,8 @@ const asFilters = (raw: FeedFilters): FeedFilters => ({
|
|||||||
...raw,
|
...raw,
|
||||||
tags: raw.tags ?? [],
|
tags: raw.tags ?? [],
|
||||||
channelIds: raw.channelIds ?? [],
|
channelIds: raw.channelIds ?? [],
|
||||||
|
// Migrate the legacy global `tagMode` (older blobs) into the per-category shape.
|
||||||
|
tagModes: coerceTagModes(raw),
|
||||||
});
|
});
|
||||||
|
|
||||||
function syncDefaultMirror(views: SavedView[]): void {
|
function syncDefaultMirror(views: SavedView[]): void {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { Pencil, SlidersHorizontal } from "lucide-react";
|
import { Pencil, SlidersHorizontal } from "lucide-react";
|
||||||
import { api, type FeedFilters, type Tag } from "../lib/api";
|
import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
|
||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
@@ -14,7 +14,6 @@ import FilterRail from "./FilterRail";
|
|||||||
import { type PanelItem } from "./PanelGroups";
|
import { type PanelItem } from "./PanelGroups";
|
||||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||||
import ChannelPicker from "./ChannelPicker";
|
import ChannelPicker from "./ChannelPicker";
|
||||||
import SegmentedToggle from "./SegmentedToggle";
|
|
||||||
import FilterChipGroup from "./FilterChipGroup";
|
import FilterChipGroup from "./FilterChipGroup";
|
||||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
@@ -97,6 +96,9 @@ export default function Sidebar({
|
|||||||
tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)],
|
tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Each tag category (language/topic/other) has its OWN any/all match, set from its combobox footer.
|
||||||
|
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
|
||||||
|
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
|
||||||
|
|
||||||
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
|
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
|
||||||
// disagree about what's applied.
|
// disagree about what's applied.
|
||||||
@@ -200,31 +202,22 @@ export default function Sidebar({
|
|||||||
selected={selectedIn(languages)}
|
selected={selectedIn(languages)}
|
||||||
onChange={(next) => setTagSelection(languages, next)}
|
onChange={(next) => setTagSelection(languages, next)}
|
||||||
emptyText={t("sidebar.noMatchingTags")}
|
emptyText={t("sidebar.noMatchingTags")}
|
||||||
|
matchMode={filters.tagModes.language}
|
||||||
|
onMatchModeChange={(m) => setTagMode("language", m)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "topic": {
|
case "topic": {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<Combobox
|
||||||
<SegmentedToggle
|
label={t("sidebar.widget.topic")}
|
||||||
label={t("sidebar.match")}
|
options={visibleChips(topics).map(tagOption)}
|
||||||
title={t("sidebar.tagModeTooltip")}
|
selected={selectedIn(topics)}
|
||||||
ariaLabel={t("sidebar.match")}
|
onChange={(next) => setTagSelection(topics, next)}
|
||||||
options={[
|
emptyText={t("sidebar.noMatchingTags")}
|
||||||
{ value: "or", label: t("sidebar.any") },
|
matchMode={filters.tagModes.topic}
|
||||||
{ value: "and", label: t("sidebar.all") },
|
onMatchModeChange={(m) => setTagMode("topic", m)}
|
||||||
]}
|
/>
|
||||||
value={filters.tagMode}
|
|
||||||
onChange={(tagMode) => setFilters({ ...filters, tagMode })}
|
|
||||||
/>
|
|
||||||
<Combobox
|
|
||||||
label={t("sidebar.widget.topic")}
|
|
||||||
options={visibleChips(topics).map(tagOption)}
|
|
||||||
selected={selectedIn(topics)}
|
|
||||||
onChange={(next) => setTagSelection(topics, next)}
|
|
||||||
emptyText={t("sidebar.noMatchingTags")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "tags": {
|
case "tags": {
|
||||||
@@ -236,6 +229,8 @@ export default function Sidebar({
|
|||||||
selected={selectedIn(userTags)}
|
selected={selectedIn(userTags)}
|
||||||
onChange={(next) => setTagSelection(userTags, next)}
|
onChange={(next) => setTagSelection(userTags, next)}
|
||||||
emptyText={t("sidebar.noMatchingTags")}
|
emptyText={t("sidebar.noMatchingTags")}
|
||||||
|
matchMode={filters.tagModes.other}
|
||||||
|
onMatchModeChange={(m) => setTagMode("other", m)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTagManagerOpen(true)}
|
onClick={() => setTagManagerOpen(true)}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { setupApi } from "./api/setup";
|
|||||||
export type * from "./api/setup";
|
export type * from "./api/setup";
|
||||||
import { feedApi } from "./api/feed";
|
import { feedApi } from "./api/feed";
|
||||||
export type * from "./api/feed";
|
export type * from "./api/feed";
|
||||||
|
export type * from "./api/tagModes";
|
||||||
|
export { DEFAULT_TAG_MODES, TAG_CATEGORIES, coerceTagModes } from "./api/tagModes";
|
||||||
import { channelsApi } from "./api/channels";
|
import { channelsApi } from "./api/channels";
|
||||||
export type * from "./api/channels";
|
export type * from "./api/channels";
|
||||||
import { syncApi } from "./api/sync";
|
import { syncApi } from "./api/sync";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// the shared FeedFilters into the query string the feed + facet endpoints both consume; they stay
|
// the shared FeedFilters into the query string the feed + facet endpoints both consume; they stay
|
||||||
// module-local (no consumer names them). Only touches core's req/beacon.
|
// module-local (no consumer names them). Only touches core's req/beacon.
|
||||||
import { req, beacon } from "./core";
|
import { req, beacon } from "./core";
|
||||||
|
import { TAG_CATEGORIES, type TagModes } from "./tagModes";
|
||||||
|
|
||||||
// Per-user watch status stored server-side (VALID_STATES in routes/feed.py). "in_progress" and
|
// Per-user watch status stored server-side (VALID_STATES in routes/feed.py). "in_progress" and
|
||||||
// "unwatched" are NOT statuses — they're derived feed filters (status "new" + a resume position),
|
// "unwatched" are NOT statuses — they're derived feed filters (status "new" + a resume position),
|
||||||
@@ -54,7 +55,8 @@ export interface FeedResponse {
|
|||||||
|
|
||||||
export interface FeedFilters {
|
export interface FeedFilters {
|
||||||
tags: number[];
|
tags: number[];
|
||||||
tagMode: "or" | "and";
|
/** Per-category tag match (language / topic / other each independently any/all). */
|
||||||
|
tagModes: TagModes;
|
||||||
q: string;
|
q: string;
|
||||||
sort: string;
|
sort: string;
|
||||||
// Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
|
// Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
|
||||||
@@ -83,7 +85,10 @@ export interface FeedFilters {
|
|||||||
function filterParams(f: FeedFilters): URLSearchParams {
|
function filterParams(f: FeedFilters): URLSearchParams {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
f.tags.forEach((t) => p.append("tags", String(t)));
|
f.tags.forEach((t) => p.append("tags", String(t)));
|
||||||
p.set("tag_mode", f.tagMode);
|
// Only emit the categories set to "and"; the backend defaults each to "or".
|
||||||
|
for (const cat of TAG_CATEGORIES) {
|
||||||
|
if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and");
|
||||||
|
}
|
||||||
if (f.q) p.set("q", f.q);
|
if (f.q) p.set("q", f.q);
|
||||||
p.set("scope", f.scope);
|
p.set("scope", f.scope);
|
||||||
p.set("show_normal", String(f.includeNormal));
|
p.set("show_normal", String(f.includeNormal));
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Per-category tag match — the small, dependency-free vocabulary shared by the feed filter model, the
|
||||||
|
// URL (de)serializer, the sidebar comboboxes, and the saved-view migration. Kept in its own leaf (no
|
||||||
|
// imports) so node-env tests and any module can pull it in without dragging the HTTP/i18n machinery.
|
||||||
|
|
||||||
|
/** A tag category's match: "or" = a channel with ANY selected tag passes, "and" = it must carry ALL. */
|
||||||
|
export type TagMode = "or" | "and";
|
||||||
|
/** The three tag categories, each with its OWN independent match mode. */
|
||||||
|
export type TagCategory = "language" | "topic" | "other";
|
||||||
|
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" };
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
const r = (raw ?? {}) as { tagModes?: Partial<TagModes>; tagMode?: unknown };
|
||||||
|
const legacyTopic = r.tagMode === "and" ? "and" : undefined;
|
||||||
|
const pick = (cat: TagCategory): TagMode => {
|
||||||
|
const v = r.tagModes?.[cat];
|
||||||
|
if (v === "and" || v === "or") return v;
|
||||||
|
return cat === "topic" && legacyTopic ? "and" : "or";
|
||||||
|
};
|
||||||
|
return { language: pick("language"), topic: pick("topic"), other: pick("other") };
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ const PRESETS: DatePreset[] = [
|
|||||||
|
|
||||||
const base = (over: Partial<FeedFilters> = {}): FeedFilters => ({
|
const base = (over: Partial<FeedFilters> = {}): FeedFilters => ({
|
||||||
tags: [],
|
tags: [],
|
||||||
tagMode: "or",
|
tagModes: { language: "or", topic: "or", other: "or" },
|
||||||
q: "",
|
q: "",
|
||||||
sort: "date",
|
sort: "date",
|
||||||
scope: "my",
|
scope: "my",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
filtersToParams,
|
filtersToParams,
|
||||||
paramsToFilters,
|
paramsToFilters,
|
||||||
} from "./urlState";
|
} from "./urlState";
|
||||||
import type { FeedFilters } from "./api";
|
import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
|
||||||
|
|
||||||
function setLocation(search: string) {
|
function setLocation(search: string) {
|
||||||
window.history.replaceState(null, "", `/app${search}`);
|
window.history.replaceState(null, "", `/app${search}`);
|
||||||
@@ -23,7 +23,7 @@ function queryOf(url: string): URLSearchParams {
|
|||||||
|
|
||||||
const DEFAULTS: FeedFilters = {
|
const DEFAULTS: FeedFilters = {
|
||||||
tags: [],
|
tags: [],
|
||||||
tagMode: "or",
|
tagModes: DEFAULT_TAG_MODES,
|
||||||
channelIds: [],
|
channelIds: [],
|
||||||
q: "",
|
q: "",
|
||||||
sort: "newest",
|
sort: "newest",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) 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
|
// 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.
|
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
|
||||||
import type { FeedFilters } from "./api";
|
import { TAG_CATEGORIES, type FeedFilters } from "./api";
|
||||||
|
|
||||||
const KEYS = [
|
const KEYS = [
|
||||||
"q",
|
"q",
|
||||||
@@ -16,7 +16,10 @@ const KEYS = [
|
|||||||
"shorts",
|
"shorts",
|
||||||
"live",
|
"live",
|
||||||
"tags",
|
"tags",
|
||||||
"tagmode",
|
"tagmode_language",
|
||||||
|
"tagmode_topic",
|
||||||
|
"tagmode_other",
|
||||||
|
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
|
||||||
"channel",
|
"channel",
|
||||||
"maxage",
|
"maxage",
|
||||||
"from",
|
"from",
|
||||||
@@ -38,7 +41,9 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
|
|||||||
if (f.includeShorts) p.set("shorts", "1");
|
if (f.includeShorts) p.set("shorts", "1");
|
||||||
if (f.includeLive) p.set("live", "1");
|
if (f.includeLive) p.set("live", "1");
|
||||||
if (f.tags.length) p.set("tags", f.tags.join(","));
|
if (f.tags.length) p.set("tags", f.tags.join(","));
|
||||||
if (f.tagMode === "and") p.set("tagmode", "and");
|
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.channelIds.length) p.set("channel", f.channelIds.join(","));
|
||||||
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
|
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
|
||||||
if (f.dateFrom) p.set("from", f.dateFrom);
|
if (f.dateFrom) p.set("from", f.dateFrom);
|
||||||
@@ -84,7 +89,12 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
|
|||||||
.split(",")
|
.split(",")
|
||||||
.map((s) => Number(s))
|
.map((s) => Number(s))
|
||||||
.filter((n) => Number.isInteger(n));
|
.filter((n) => Number.isInteger(n));
|
||||||
if (params.has("tagmode")) f.tagMode = params.get("tagmode") === "and" ? "and" : "or";
|
// 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.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
|
||||||
f.maxAgeDays = num(params.get("maxage"));
|
f.maxAgeDays = num(params.get("maxage"));
|
||||||
f.dateFrom = params.get("from") || undefined;
|
f.dateFrom = params.get("from") || undefined;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
import { api, type FeedFilters } from "./api";
|
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "./api";
|
||||||
import { parseSort } from "./feedSort";
|
import { parseSort } from "./feedSort";
|
||||||
|
|
||||||
/** One narrowing the user has applied, with the label to show and the way to undo just this one. */
|
/** One narrowing the user has applied, with the label to show and the way to undo just this one. */
|
||||||
@@ -14,7 +14,7 @@ export interface ActiveFilter {
|
|||||||
/** What the feed looks like with nothing applied — the baseline every "active" check reads against. */
|
/** What the feed looks like with nothing applied — the baseline every "active" check reads against. */
|
||||||
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
||||||
tags: [],
|
tags: [],
|
||||||
tagMode: "or",
|
tagModes: DEFAULT_TAG_MODES,
|
||||||
channelIds: [],
|
channelIds: [],
|
||||||
sort: "newest",
|
sort: "newest",
|
||||||
includeNormal: true,
|
includeNormal: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user