From a3a13b5288a84009e24e7a87a58a1dd2524142d9 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:43:16 +0300 Subject: [PATCH 1/8] feat: filter the admin content list by byline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds byline filtering alongside the existing status, author, and date filters. Selecting several bylines matches entries credited to any of them; "No byline assigned" matches entries with no credit. Credits inferred from an entry's author (rendered when an entry has no explicit credit) are excluded unless opted into, so the filter matches assigned bylines by default. No migration is required. The UNIQUE(collection_slug, content_id, byline_id) index from migration 031 covers every filter shape: EXPLAIN QUERY PLAN shows a covering seek for the include, exclude, and no-byline probes while the outer query keeps its sort-ordered composite index, so LIMIT still short-circuits without a temp B-tree. Correlating EXISTS from the content table is what makes that hold — driving from the pivot side cannot use the index for the byline and forces a temp sort — hence the note in applyBylineFilter. Filter values are translation_groups (what the junction has stored since migration 040), so a selection matches a byline across every locale. Co-Authored-By: Claude Opus 5 --- .changeset/content-list-byline-filter.md | 6 + .../admin/src/components/BylineFilter.tsx | 184 ++++++++++++++++++ packages/admin/src/components/ContentList.tsx | 46 ++++- packages/admin/src/lib/api/content.ts | 22 +++ packages/admin/src/router.tsx | 17 ++ packages/core/src/api/handlers/content.ts | 44 +++++ packages/core/src/api/schemas/content.ts | 50 +++++ packages/core/src/astro/types.ts | 3 + .../core/src/database/repositories/content.ts | 89 +++++++++ .../core/src/database/repositories/types.ts | 31 +++ packages/core/src/emdash-runtime.ts | 3 + .../content-list-byline-filter.test.ts | 179 +++++++++++++++++ 12 files changed, 669 insertions(+), 5 deletions(-) create mode 100644 .changeset/content-list-byline-filter.md create mode 100644 packages/admin/src/components/BylineFilter.tsx create mode 100644 packages/core/tests/integration/content/content-list-byline-filter.test.ts diff --git a/.changeset/content-list-byline-filter.md b/.changeset/content-list-byline-filter.md new file mode 100644 index 0000000000..e9643ae6fd --- /dev/null +++ b/.changeset/content-list-byline-filter.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Adds a byline filter to the admin content list. Pick one or more bylines to see entries credited to any of them, or filter to entries with no byline assigned. Bylines inferred from an entry's author are ignored unless you turn on "Include inferred bylines". diff --git a/packages/admin/src/components/BylineFilter.tsx b/packages/admin/src/components/BylineFilter.tsx new file mode 100644 index 0000000000..d1ede2fc70 --- /dev/null +++ b/packages/admin/src/components/BylineFilter.tsx @@ -0,0 +1,184 @@ +import { Badge, Button, Checkbox, Input, Popover, Switch } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { CaretDown } from "@phosphor-icons/react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import * as React from "react"; + +import { fetchBylines, type BylineSummary } from "../lib/api"; +import { useDebouncedValue } from "../lib/hooks.js"; + +/** + * Byline filter state for the content list. + * + * `bylineIds` are translation groups, so a selection matches a byline across + * every locale it exists in. `none` is exclusive: it matches entries with no + * byline rather than a particular one. + */ +export interface BylineFilterState { + bylineIds: string[]; + none: boolean; + includeInferred: boolean; +} + +export const EMPTY_BYLINE_FILTER: BylineFilterState = { + bylineIds: [], + none: false, + includeInferred: false, +}; + +export function isBylineFilterActive(filter: BylineFilterState): boolean { + return filter.none || filter.bylineIds.length > 0; +} + +/** Server-side cap on how many bylines one filter may name. */ +const MAX_SELECTED = 25; + +/** The junction stores translation groups, so a filter matches every locale. */ +const groupOf = (byline: BylineSummary) => byline.translationGroup ?? byline.id; + +interface BylineFilterProps { + value: BylineFilterState; + onChange: (value: BylineFilterState) => void; + /** Locale the list is showing, so the picker offers matching byline rows. */ + locale?: string; +} + +/** + * Multi-select byline filter. Selecting several bylines matches entries + * credited to any of them; "No byline" matches entries with no credit at all. + * + * Bylines are searched server-side rather than listed exhaustively — the + * directory can be far longer than one page, and this is the one query in the + * feature that isn't index-served. + */ +export function BylineFilter({ value, onChange, locale }: BylineFilterProps) { + const { t } = useLingui(); + const [open, setOpen] = React.useState(false); + const [search, setSearch] = React.useState(""); + const debouncedSearch = useDebouncedValue(search, 300); + const trimmedSearch = debouncedSearch.trim(); + + const { data, isLoading } = useQuery({ + queryKey: ["bylines", "content-filter", locale ?? null, trimmedSearch], + queryFn: () => fetchBylines({ search: trimmedSearch || undefined, locale, limit: 20 }), + enabled: open, + placeholderData: keepPreviousData, + }); + + const options = data?.items ?? []; + + // Selected bylines are remembered by group so their names keep rendering + // once the search moves on and the rows are no longer in `options`. + const [labels, setLabels] = React.useState>({}); + React.useEffect(() => { + if (options.length === 0) return; + setLabels((prev) => { + const next = { ...prev }; + for (const byline of options) next[groupOf(byline)] = byline.displayName; + return next; + }); + }, [options]); + + const toggle = (group: string) => { + const selected = value.bylineIds.includes(group); + if (!selected && value.bylineIds.length >= MAX_SELECTED) return; + onChange({ + ...value, + // Picking a byline leaves the "no byline" mode; the two are + // mutually exclusive. + none: false, + bylineIds: selected + ? value.bylineIds.filter((id) => id !== group) + : [...value.bylineIds, group], + }); + }; + + const toggleNone = () => { + const none = !value.none; + onChange({ ...value, none, bylineIds: none ? [] : value.bylineIds }); + }; + + const label = value.none + ? t`No byline` + : value.bylineIds.length === 0 + ? t`All bylines` + : value.bylineIds.length === 1 + ? (labels[value.bylineIds[0]!] ?? t`1 byline`) + : t`${value.bylineIds.length} bylines`; + + const atLimit = value.bylineIds.length >= MAX_SELECTED; + + return ( + + + + + + + setSearch(e.target.value)} + /> + +
+ +
+ +
+ {isLoading &&

{t`Loading…`}

} + + {!isLoading && options.length === 0 && ( +

{t`No bylines found`}

+ )} + + {options.map((byline) => { + const group = groupOf(byline); + const checked = value.bylineIds.includes(group); + return ( +
+ toggle(group)} + label={{byline.displayName}} + /> +
+ ); + })} + + {data?.nextCursor && ( +

{t`Search to narrow the list`}

+ )} +
+ + {atLimit && ( + + {t`Up to ${MAX_SELECTED} bylines can be selected`} + + )} + +
+ onChange({ ...value, includeInferred: checked })} + label={{t`Include inferred bylines`}} + /> +

+ {t`Also match the byline linked to an entry's author when it has none assigned.`} +

+
+
+
+ ); +} diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 67e87bc7e4..8927351eab 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -33,6 +33,12 @@ import { useDebouncedValue } from "../lib/hooks.js"; import { contentUrl } from "../lib/url.js"; import { cn } from "../lib/utils"; import { CaretNext, CaretPrev } from "./ArrowIcons.js"; +import { + BylineFilter, + EMPTY_BYLINE_FILTER, + isBylineFilterActive, + type BylineFilterState, +} from "./BylineFilter.js"; import { ContentStatusBadge, ContentStatusLabel, @@ -127,6 +133,9 @@ export interface ContentListProps { /** Controlled date-range filter state. */ dateFilter?: ContentDateFilter; onDateFilterChange?: (filter: ContentDateFilter) => void; + /** Controlled byline filter state. */ + bylineFilter?: BylineFilterState; + onBylineFilterChange?: (filter: BylineFilterState) => void; /** * Bulk actions. Each is opt-in: the selection checkboxes only appear when at * least one bulk handler is provided, and each toolbar button renders only @@ -190,6 +199,8 @@ export function ContentList({ onAuthorFilterChange, dateFilter = EMPTY_DATE_FILTER, onDateFilterChange, + bylineFilter = EMPTY_BYLINE_FILTER, + onBylineFilterChange, onBulkPublish, onBulkUnpublish, onBulkDelete, @@ -399,6 +410,9 @@ export function ContentList({ onAuthorFilterChange={onAuthorFilterChange} dateFilter={dateFilter} onDateFilterChange={onDateFilterChange} + bylineFilter={bylineFilter} + onBylineFilterChange={onBylineFilterChange} + locale={activeLocale ?? undefined} /> )} @@ -709,13 +723,18 @@ interface FilterBarProps { onAuthorFilterChange?: (authorId: string) => void; dateFilter: ContentDateFilter; onDateFilterChange?: (filter: ContentDateFilter) => void; + bylineFilter: BylineFilterState; + onBylineFilterChange?: (filter: BylineFilterState) => void; + /** Locale the list is showing, so the byline picker offers matching rows. */ + locale?: string; } /** - * Filter controls for the content list: status, author, and a date range over - * a chosen timestamp column (#1288). All controls report changes to the - * parent, which owns the state and refetches. Filtering happens server-side, - * so it works across the whole collection rather than the loaded page. + * Filter controls for the content list: status, author, byline, and a date + * range over a chosen timestamp column (#1288). All controls report changes to + * the parent, which owns the state and refetches. Filtering happens + * server-side, so it works across the whole collection rather than the loaded + * page. */ function FilterBar({ statusFilter, @@ -725,6 +744,9 @@ function FilterBar({ onAuthorFilterChange, dateFilter, onDateFilterChange, + bylineFilter, + onBylineFilterChange, + locale, }: FilterBarProps) { const { t } = useLingui(); @@ -748,12 +770,22 @@ function FilterBar({ }; const hasActiveFilter = - statusFilter !== "all" || authorFilter !== "" || !!dateFilter.from || !!dateFilter.to; + statusFilter !== "all" || + authorFilter !== "" || + !!dateFilter.from || + !!dateFilter.to || + isBylineFilterActive(bylineFilter); const handleClear = () => { onStatusFilterChange("all"); onAuthorFilterChange?.(""); onDateFilterChange?.(EMPTY_DATE_FILTER); + // Clearing drops the selection but keeps the inferred-byline + // preference, which is a display choice rather than an active filter. + onBylineFilterChange?.({ + ...EMPTY_BYLINE_FILTER, + includeInferred: bylineFilter.includeInferred, + }); }; return ( @@ -795,6 +827,10 @@ function FilterBar({ )} + {onBylineFilterChange && ( + + )} + {showDateFilter && (
setSearch(e.target.value)} + /> + +
+ {isLoading &&

{t`Loading…`}

} + + {!isLoading && options.length === 0 && ( +

{t`No bylines found`}

+ )} + + {options.map((byline) => { + const checked = selected.includes(byline.id); + return ( +
+ toggle(byline.id)} + label={{byline.displayName}} + /> +
+ ); + })} + + {data?.nextCursor && ( +

{t`Search to narrow the list`}

+ )} +
+ + {atLimit && ( + + {t`Up to ${MAX_SELECTED} bylines can be selected`} + + )} + +
+ + {t`Replaces the credits on ${count} entries`} + + +
+ + + ); +} diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 8927351eab..c4d0cfc525 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -33,6 +33,7 @@ import { useDebouncedValue } from "../lib/hooks.js"; import { contentUrl } from "../lib/url.js"; import { cn } from "../lib/utils"; import { CaretNext, CaretPrev } from "./ArrowIcons.js"; +import { BulkBylineApply } from "./BulkBylineApply.js"; import { BylineFilter, EMPTY_BYLINE_FILTER, @@ -146,6 +147,8 @@ export interface ContentListProps { onBulkPublish?: BulkActionHandler; onBulkUnpublish?: BulkActionHandler; onBulkDelete?: BulkActionHandler; + /** Replaces every selected entry's credits with the picked bylines (row ids). */ + onBulkSetBylines?: (ids: string[], bylineIds: string[]) => Promise; } type BulkActionHandler = (ids: string[]) => Promise; @@ -204,6 +207,7 @@ export function ContentList({ onBulkPublish, onBulkUnpublish, onBulkDelete, + onBulkSetBylines, }: ContentListProps) { const { t } = useLingui(); const [activeTab, setActiveTab] = React.useState("all"); @@ -213,7 +217,7 @@ export function ContentList({ // Bulk selection is opt-in: the checkbox column + toolbar only render when // the parent wired at least one bulk handler. - const bulkEnabled = !!(onBulkPublish || onBulkUnpublish || onBulkDelete); + const bulkEnabled = !!(onBulkPublish || onBulkUnpublish || onBulkDelete || onBulkSetBylines); // Server-side search mode: the caller refetches based on the (debounced) // query, so `items`/`total` already reflect the filter and we must not @@ -446,6 +450,14 @@ export function ContentList({ {t`Set to draft`} )} + {onBulkSetBylines && ( + runBulk((ids) => onBulkSetBylines(ids, bylineIds))} + /> + )} {onBulkDelete && ( page.items) || []; }, [data]); + // Bulk byline apply: temporary tooling for exercising the byline filter, not + // part of the shipped feature. The picked bylines replace each entry's whole + // credit set — merging client-side isn't sound, because list items hydrate + // credits with strict locale matching and an entry whose byline has no row + // in its locale comes back with an empty `bylines` array. + const bulkSetBylinesMutation = useMutation({ + mutationFn: async ({ ids, bylineIds }: { ids: string[]; bylineIds: string[] }) => { + const bylines = bylineIds.map((bylineId) => ({ bylineId })); + const { failedIds } = await runBulkAction(ids, (id) => + updateContent(collection, id, { bylines }, { locale: activeLocale }), + ); + return { total: ids.length, failedIds }; + }, + onSuccess: ({ total, failedIds }) => { + if (failedIds.length === 0) { + toastManager.add({ title: t`Updated bylines on ${total} items`, type: "success" }); + } else { + toastManager.add({ + title: t`Failed to update bylines`, + description: t`${failedIds.length} of ${total} could not be updated`, + type: "error", + }); + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ["content", collection] }); + }, + }); + // Server returns `total` on every page; the first page is authoritative // because filters don't change within a fetch cycle. Fall back to the // loaded count so old servers (pre-total) still render a denominator. @@ -631,6 +660,9 @@ function ContentListPage() { onBulkPublish={(ids) => bulkPublishMutation.mutateAsync(ids).then((r) => r.failedIds)} onBulkUnpublish={(ids) => bulkUnpublishMutation.mutateAsync(ids).then((r) => r.failedIds)} onBulkDelete={(ids) => bulkDeleteMutation.mutateAsync(ids).then((r) => r.failedIds)} + onBulkSetBylines={(ids, bylineIds) => + bulkSetBylinesMutation.mutateAsync({ ids, bylineIds }).then((r) => r.failedIds) + } /> ); } From 0186cfd2593c4bf842d377901f82b6269ab27429 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:15:29 +0300 Subject: [PATCH 3/8] Revert "tmp: bulk-apply bylines from the content list" This reverts commit 28101afdf5f32b88c5939387e7f5768fdf4aea80. --- .../admin/src/components/BulkBylineApply.tsx | 124 ------------------ packages/admin/src/components/ContentList.tsx | 14 +- packages/admin/src/router.tsx | 32 ----- 3 files changed, 1 insertion(+), 169 deletions(-) delete mode 100644 packages/admin/src/components/BulkBylineApply.tsx diff --git a/packages/admin/src/components/BulkBylineApply.tsx b/packages/admin/src/components/BulkBylineApply.tsx deleted file mode 100644 index 376b83124b..0000000000 --- a/packages/admin/src/components/BulkBylineApply.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Badge, Button, Checkbox, Input, Popover } from "@cloudflare/kumo"; -import { useLingui } from "@lingui/react/macro"; -import { CaretDown } from "@phosphor-icons/react"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import * as React from "react"; - -import { fetchBylines } from "../lib/api"; -import { useDebouncedValue } from "../lib/hooks.js"; - -/** Matches the server's cap on credits per entry. */ -const MAX_SELECTED = 25; - -interface BulkBylineApplyProps { - /** How many entries the credits will be set on. */ - count: number; - disabled?: boolean; - /** Locale the list is showing, so the picker offers matching byline rows. */ - locale?: string; - /** Receives the chosen byline row ids (not translation groups). */ - onApply: (bylineIds: string[]) => void; -} - -/** - * Bulk byline picker for the content list's selection toolbar. The picked - * bylines become each selected entry's whole credit set — an entry's existing - * credits are replaced, not merged into. - */ -export function BulkBylineApply({ count, disabled, locale, onApply }: BulkBylineApplyProps) { - const { t } = useLingui(); - const [open, setOpen] = React.useState(false); - const [search, setSearch] = React.useState(""); - const [selected, setSelected] = React.useState([]); - const debouncedSearch = useDebouncedValue(search, 300); - const trimmedSearch = debouncedSearch.trim(); - - const { data, isLoading } = useQuery({ - queryKey: ["bylines", "bulk-apply", locale ?? null, trimmedSearch], - queryFn: () => fetchBylines({ search: trimmedSearch || undefined, locale, limit: 20 }), - enabled: open, - placeholderData: keepPreviousData, - }); - - const options = data?.items ?? []; - const atLimit = selected.length >= MAX_SELECTED; - - const toggle = (id: string) => { - setSelected((prev) => { - if (prev.includes(id)) return prev.filter((value) => value !== id); - if (prev.length >= MAX_SELECTED) return prev; - return [...prev, id]; - }); - }; - - const apply = () => { - if (selected.length === 0) return; - onApply(selected); - setSelected([]); - setSearch(""); - setOpen(false); - }; - - return ( - - - - - - - setSearch(e.target.value)} - /> - -
- {isLoading &&

{t`Loading…`}

} - - {!isLoading && options.length === 0 && ( -

{t`No bylines found`}

- )} - - {options.map((byline) => { - const checked = selected.includes(byline.id); - return ( -
- toggle(byline.id)} - label={{byline.displayName}} - /> -
- ); - })} - - {data?.nextCursor && ( -

{t`Search to narrow the list`}

- )} -
- - {atLimit && ( - - {t`Up to ${MAX_SELECTED} bylines can be selected`} - - )} - -
- - {t`Replaces the credits on ${count} entries`} - - -
-
-
- ); -} diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index c4d0cfc525..8927351eab 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -33,7 +33,6 @@ import { useDebouncedValue } from "../lib/hooks.js"; import { contentUrl } from "../lib/url.js"; import { cn } from "../lib/utils"; import { CaretNext, CaretPrev } from "./ArrowIcons.js"; -import { BulkBylineApply } from "./BulkBylineApply.js"; import { BylineFilter, EMPTY_BYLINE_FILTER, @@ -147,8 +146,6 @@ export interface ContentListProps { onBulkPublish?: BulkActionHandler; onBulkUnpublish?: BulkActionHandler; onBulkDelete?: BulkActionHandler; - /** Replaces every selected entry's credits with the picked bylines (row ids). */ - onBulkSetBylines?: (ids: string[], bylineIds: string[]) => Promise; } type BulkActionHandler = (ids: string[]) => Promise; @@ -207,7 +204,6 @@ export function ContentList({ onBulkPublish, onBulkUnpublish, onBulkDelete, - onBulkSetBylines, }: ContentListProps) { const { t } = useLingui(); const [activeTab, setActiveTab] = React.useState("all"); @@ -217,7 +213,7 @@ export function ContentList({ // Bulk selection is opt-in: the checkbox column + toolbar only render when // the parent wired at least one bulk handler. - const bulkEnabled = !!(onBulkPublish || onBulkUnpublish || onBulkDelete || onBulkSetBylines); + const bulkEnabled = !!(onBulkPublish || onBulkUnpublish || onBulkDelete); // Server-side search mode: the caller refetches based on the (debounced) // query, so `items`/`total` already reflect the filter and we must not @@ -450,14 +446,6 @@ export function ContentList({ {t`Set to draft`} )} - {onBulkSetBylines && ( - runBulk((ids) => onBulkSetBylines(ids, bylineIds))} - /> - )} {onBulkDelete && ( page.items) || []; }, [data]); - // Bulk byline apply: temporary tooling for exercising the byline filter, not - // part of the shipped feature. The picked bylines replace each entry's whole - // credit set — merging client-side isn't sound, because list items hydrate - // credits with strict locale matching and an entry whose byline has no row - // in its locale comes back with an empty `bylines` array. - const bulkSetBylinesMutation = useMutation({ - mutationFn: async ({ ids, bylineIds }: { ids: string[]; bylineIds: string[] }) => { - const bylines = bylineIds.map((bylineId) => ({ bylineId })); - const { failedIds } = await runBulkAction(ids, (id) => - updateContent(collection, id, { bylines }, { locale: activeLocale }), - ); - return { total: ids.length, failedIds }; - }, - onSuccess: ({ total, failedIds }) => { - if (failedIds.length === 0) { - toastManager.add({ title: t`Updated bylines on ${total} items`, type: "success" }); - } else { - toastManager.add({ - title: t`Failed to update bylines`, - description: t`${failedIds.length} of ${total} could not be updated`, - type: "error", - }); - } - }, - onSettled: () => { - void queryClient.invalidateQueries({ queryKey: ["content", collection] }); - }, - }); - // Server returns `total` on every page; the first page is authoritative // because filters don't change within a fetch cycle. Fall back to the // loaded count so old servers (pre-total) still render a denominator. @@ -660,9 +631,6 @@ function ContentListPage() { onBulkPublish={(ids) => bulkPublishMutation.mutateAsync(ids).then((r) => r.failedIds)} onBulkUnpublish={(ids) => bulkUnpublishMutation.mutateAsync(ids).then((r) => r.failedIds)} onBulkDelete={(ids) => bulkDeleteMutation.mutateAsync(ids).then((r) => r.failedIds)} - onBulkSetBylines={(ids, bylineIds) => - bulkSetBylinesMutation.mutateAsync({ ids, bylineIds }).then((r) => r.failedIds) - } /> ); } From b9d991fd7670a95677fa689e887aa647cdf4d616 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:50:18 +0300 Subject: [PATCH 4/8] fix: resolve inferred bylines at the list's locale The inferred-byline branch of the content-list filter matched on `author_id` alone, against a set of users resolved from every locale row of the selected bylines. Byline hydration is strict per locale, so an entry whose author owns a byline with no row at the list's locale renders as uncredited while still matching a filter on that byline -- and matched "No byline" at the same time, since that branch did correlate on locale. Resolve the credit the same way the list renders it: a correlated EXISTS on `_emdash_bylines` scoped to the locale the list is showing, falling back to each entry's own locale when the list spans locales. This drops the pre-resolution query the handler ran for `includeInferredBylines`, so the opt-in no longer costs an extra round-trip. Also pluralize the selected-byline count through Lingui's `plural` rather than a bare interpolation, which only reads correctly in languages with a single plural form. Co-Authored-By: Claude Opus 5 --- .../admin/src/components/BylineFilter.tsx | 5 +- packages/core/src/api/handlers/content.ts | 39 +++++---------- .../core/src/database/repositories/content.ts | 50 ++++++++++--------- .../core/src/database/repositories/types.ts | 8 +-- .../content-list-byline-filter.test.ts | 41 +++++++++++++++ 5 files changed, 87 insertions(+), 56 deletions(-) diff --git a/packages/admin/src/components/BylineFilter.tsx b/packages/admin/src/components/BylineFilter.tsx index d1ede2fc70..93e65f2ff8 100644 --- a/packages/admin/src/components/BylineFilter.tsx +++ b/packages/admin/src/components/BylineFilter.tsx @@ -1,4 +1,5 @@ import { Badge, Button, Checkbox, Input, Popover, Switch } from "@cloudflare/kumo"; +import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { CaretDown } from "@phosphor-icons/react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; @@ -103,8 +104,8 @@ export function BylineFilter({ value, onChange, locale }: BylineFilterProps) { : value.bylineIds.length === 0 ? t`All bylines` : value.bylineIds.length === 1 - ? (labels[value.bylineIds[0]!] ?? t`1 byline`) - : t`${value.bylineIds.length} bylines`; + ? (labels[value.bylineIds[0]!] ?? plural(1, { one: "# byline", other: "# bylines" })) + : plural(value.bylineIds.length, { one: "# byline", other: "# bylines" }); const atLimit = value.bylineIds.length >= MAX_SELECTED; diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 9e2ea83608..0073f6a509 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -413,40 +413,24 @@ function normalizeDateBound(value: string | undefined, edge: "start" | "end"): s } /** - * Build the repository's byline filter from the wire params, resolving the - * users behind the selected bylines when inferred credits are opted in. + * Build the repository's byline filter from the wire params. * - * The extra lookup runs only for `includeInferredBylines`, so the default - * explicit-only filter costs no additional query. + * `locale` is the locale the list is scoped to, which is the locale an + * inferred credit has to resolve at — the admin list is always scoped to the + * locale picked in its switcher. */ -async function resolveBylineFilter( - db: Kysely, +function resolveBylineFilter( params: { bylines?: string[]; bylinesNone?: boolean; includeInferredBylines?: boolean }, -): Promise { + locale: string | undefined, +): ContentBylineFilter | undefined { const includeInferred = params.includeInferredBylines === true; - if (params.bylinesNone) return { mode: "none", includeInferred }; + if (params.bylinesNone) return { mode: "none", includeInferred, locale }; const bylineIds = params.bylines ?? []; if (bylineIds.length === 0) return undefined; - const filter: ContentBylineFilter = { mode: "any", bylineIds, includeInferred }; - if (!includeInferred) return filter; - - // A byline's `user_id` is shared across its locale siblings, so selecting - // by translation_group and de-duplicating gives every user whose implicit - // credit should match. - const rows = await db - .selectFrom("_emdash_bylines") - .select("user_id") - .where("translation_group", "in", bylineIds) - .where("user_id", "is not", null) - .execute(); - filter.inferredAuthorIds = [ - ...new Set(rows.map((row) => row.user_id).filter((id): id is string => id !== null)), - ]; - - return filter; + return { mode: "any", bylineIds, includeInferred, locale }; } /** @@ -476,10 +460,11 @@ export async function handleContentList( const repo = new ContentRepository(db); const where: FindManyOptions["where"] = {}; if (params.status) where.status = params.status; - if (params.locale) where.locale = resolveConfiguredLocale(params.locale); + const locale = params.locale ? resolveConfiguredLocale(params.locale) : undefined; + if (locale) where.locale = locale; if (params.authorId) where.authorId = params.authorId; - const bylineFilter = await resolveBylineFilter(db, params); + const bylineFilter = resolveBylineFilter(params, locale); if (bylineFilter) where.bylineFilter = bylineFilter; // A date range requires a target column; ignore stray from/to without diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index e756479b5b..a23f385116 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -977,17 +977,25 @@ export class ContentRepository { return eb.exists(sub); }; - // The entry's author owns a byline row at the entry's own locale — - // the same strict-locale rule `hydrateBylinesMany` applies before it - // renders an inferred credit. - const authorHasByline = (eb: any) => - eb.exists( - eb - .selectFrom("_emdash_bylines as b") - .select("b.id") - .whereRef("b.user_id", "=", authorColumn) - .whereRef("b.locale", "=", localeColumn), - ); + // The entry's author owns a byline row — optionally within a given set + // of translation groups — at the locale the list is scoped to. Matching + // the locale is what keeps the filter agreeing with the list: an + // inferred credit renders only when the author's byline has a row at + // that locale (`hydrateBylinesMany` -> `findByUserIds`), and byline + // translations start life with a null `user_id`, so a group translated + // into the locale but not re-linked resolves to no credit. `locale` + // falls back to each entry's own when the list spans locales. + const authorHasByline = (eb: any, bylineIds?: string[]) => { + let sub = eb + .selectFrom("_emdash_bylines as b") + .select("b.id") + .whereRef("b.user_id", "=", authorColumn); + sub = filter.locale + ? sub.where("b.locale", "=", filter.locale) + : sub.whereRef("b.locale", "=", localeColumn); + if (bylineIds) sub = sub.where("b.translation_group", "in", bylineIds); + return eb.exists(sub); + }; if (filter.mode === "none") { return query.where((eb: any) => { @@ -1001,8 +1009,7 @@ export class ContentRepository { } const bylineIds = filter.bylineIds ?? []; - const inferredAuthorIds = filter.includeInferred ? (filter.inferredAuthorIds ?? []) : []; - if (bylineIds.length === 0 && inferredAuthorIds.length === 0) { + if (bylineIds.length === 0) { // A filter that resolved to no ids must match nothing rather than // silently degrade to "no filter" and return the whole collection. // `1 = 0` rather than a bound `false`: better-sqlite3 refuses to @@ -1011,16 +1018,13 @@ export class ContentRepository { } return query.where((eb: any) => { - const branches = []; - if (bylineIds.length > 0) branches.push(credited(eb, bylineIds)); - if (inferredAuthorIds.length > 0) { - // Inference applies only where no explicit credit exists, so an - // entry credited to someone else never matches on its author. - branches.push( - eb.and([eb.not(credited(eb)), eb(authorColumn as any, "in", inferredAuthorIds)]), - ); - } - return branches.length === 1 ? branches[0] : eb.or(branches); + if (!filter.includeInferred) return credited(eb, bylineIds); + // Inference applies only where no explicit credit exists, so an + // entry credited to someone else never matches on its author. + return eb.or([ + credited(eb, bylineIds), + eb.and([eb.not(credited(eb)), authorHasByline(eb, bylineIds)]), + ]); }); } diff --git a/packages/core/src/database/repositories/types.ts b/packages/core/src/database/repositories/types.ts index 240ba2061e..4fe220aef2 100644 --- a/packages/core/src/database/repositories/types.ts +++ b/packages/core/src/database/repositories/types.ts @@ -167,11 +167,11 @@ export interface ContentBylineFilter { bylineIds?: string[]; includeInferred?: boolean; /** - * Users whose linked byline falls in `bylineIds`, resolved by the handler - * so the repository stays free of byline lookups. Only read when - * `includeInferred` is set and `mode` is `"any"`. + * Locale an inferred credit has to resolve at — the locale the list is + * scoped to. Only read when `includeInferred` is set. Defaults to each + * entry's own locale when the list spans locales. */ - inferredAuthorIds?: string[]; + locale?: string; } export interface FindManyOptions { diff --git a/packages/core/tests/integration/content/content-list-byline-filter.test.ts b/packages/core/tests/integration/content/content-list-byline-filter.test.ts index 61390bb67a..dcd00cc379 100644 --- a/packages/core/tests/integration/content/content-list-byline-filter.test.ts +++ b/packages/core/tests/integration/content/content-list-byline-filter.test.ts @@ -1,3 +1,4 @@ +import { sql } from "kysely"; import { beforeEach, afterEach, expect, it } from "vitest"; import { handleContentCreate, handleContentList } from "../../../src/api/handlers/content.js"; @@ -141,6 +142,46 @@ describeEachDialect("content list byline filter", (dialect) => { expect(slugsOf(result)).toEqual([]); }); + it("resolves inferred credits at the locale the list is scoped to", async () => { + // A byline translated into `fr` starts with a null user_id (the + // translations route makes linking an explicit step), so the Turing + // byline is user-linked at the default locale only. Move `inferred` + // to `fr` and the list renders no byline against it — the author + // fallback is strict per locale. The filter has to agree, or it + // returns an entry the list shows as uncredited. + const id = await idOfSlug("inferred"); + const bylines = new BylineRepository(ctx.db); + const turing = await bylines.findBySlug("turing"); + if (!turing) throw new Error("turing byline missing"); + await bylines.create({ + slug: "turing-fr", + displayName: "Alan Turing", + locale: "fr", + translationOf: turing.id, + }); + await sql`UPDATE ${sql.ref("ec_posts")} SET locale = 'fr' WHERE id = ${id}`.execute(ctx.db); + + const list = await handleContentList(ctx.db, "posts", { locale: "fr" }); + if (!list.success) throw new Error("list failed"); + expect(list.data.items.find((i) => i.slug === "inferred")?.bylines).toEqual([]); + + const matched = await handleContentList(ctx.db, "posts", { + locale: "fr", + bylines: [turingGroup], + includeInferredBylines: true, + }); + expect(slugsOf(matched)).toEqual([]); + + // The same entry must not fall through the gap either: with nothing + // rendered against it, it belongs under "no byline". + const none = await handleContentList(ctx.db, "posts", { + locale: "fr", + bylinesNone: true, + includeInferredBylines: true, + }); + expect(slugsOf(none)).toEqual(["inferred"]); + }); + it("composes with the status filter", async () => { const result = await handleContentList(ctx.db, "posts", { bylines: [adaGroup, graceGroup], From 10f415d4574f5747cadec3e8cb9d7291c40dc31a Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:00:47 +0300 Subject: [PATCH 5/8] docs: trim the byline filter doc comment to its invariants Drop the rejected-alternative narrative and the migration reference; keep why the EXISTS correlates from the content table and why "none" tests the junction rather than primary_byline_id. Co-Authored-By: Claude Opus 5 --- .../core/src/database/repositories/content.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index a23f385116..2c495e8b8c 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -942,18 +942,10 @@ export class ContentRepository { * Apply the optional byline filter as a correlated (NOT) EXISTS against * `_emdash_content_bylines`. * - * The shape is load-bearing. Correlating from the content table lets the - * outer query keep its sort-ordered composite index — so `LIMIT` still - * short-circuits — while each probe is an index-only seek on the - * `(collection_slug, content_id, byline_id)` unique index. Driving the - * other way (`FROM _emdash_content_bylines JOIN ec_*`) cannot use that - * index for the byline and forces a temp B-tree for the ORDER BY. - * - * `mode: "none"` tests the junction rather than `primary_byline_id`. The - * two agree — both junction write paths stamp the column in the same call - * — but they are not written atomically (D1 has no transactions), so the - * junction stays authoritative, as migration 051 treats the denormalized - * taxonomy columns. + * Correlating from the content table preserves the outer sort index so + * `LIMIT` can short-circuit. `mode: "none"` tests the junction rather than + * `primary_byline_id` because the two are written in the same call but + * are not atomically consistent, so the junction is authoritative. */ private applyBylineFilter unknown) => QB }>( query: QB, From 763e417ba44de5c5ec46d1a75668287087b22ee8 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:36:47 +0300 Subject: [PATCH 6/8] docs: drop the default-justification line from ContentBylineFilter Co-Authored-By: Claude Opus 5 --- packages/core/src/database/repositories/types.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/database/repositories/types.ts b/packages/core/src/database/repositories/types.ts index 4fe220aef2..45c72cc9b1 100644 --- a/packages/core/src/database/repositories/types.ts +++ b/packages/core/src/database/repositories/types.ts @@ -157,9 +157,7 @@ export interface ContentDateFilter { * * By default only explicit credits count. `includeInferred` widens the filter * to the byline the list actually renders, which for an entry with no credits - * is the one linked to its `author_id` (see `hydrateBylinesMany`). It is off by - * default because filtering usually means "who is credited", not "whose name - * happens to show". + * is the one linked to its `author_id` (see `hydrateBylinesMany`). */ export interface ContentBylineFilter { mode: "any" | "none"; From 6d39c21ad87f411df5c1a56625acb79d69bd8b08 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:50:07 +0300 Subject: [PATCH 7/8] docs: drop the design rationale from the BylineFilter docstring Co-Authored-By: Claude Opus 5 --- packages/admin/src/components/BylineFilter.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/admin/src/components/BylineFilter.tsx b/packages/admin/src/components/BylineFilter.tsx index 93e65f2ff8..ee8ccac03b 100644 --- a/packages/admin/src/components/BylineFilter.tsx +++ b/packages/admin/src/components/BylineFilter.tsx @@ -47,10 +47,6 @@ interface BylineFilterProps { /** * Multi-select byline filter. Selecting several bylines matches entries * credited to any of them; "No byline" matches entries with no credit at all. - * - * Bylines are searched server-side rather than listed exhaustively — the - * directory can be far longer than one page, and this is the one query in the - * feature that isn't index-served. */ export function BylineFilter({ value, onChange, locale }: BylineFilterProps) { const { t } = useLingui(); From b74cb659fbc0025155771b2e8a9fbbce620284a4 Mon Sep 17 00:00:00 2001 From: MA2153 <26630797+MA2153@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:59:53 +0300 Subject: [PATCH 8/8] Update packages/admin/src/components/ContentList.tsx Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com> --- packages/admin/src/components/ContentList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 8927351eab..efeecade38 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -731,7 +731,7 @@ interface FilterBarProps { /** * Filter controls for the content list: status, author, byline, and a date - * range over a chosen timestamp column (#1288). All controls report changes to + * range over a chosen timestamp column. All controls report changes to * the parent, which owns the state and refetches. Filtering happens * server-side, so it works across the whole collection rather than the loaded * page.