diff --git a/.changeset/fair-taxis-listen.md b/.changeset/fair-taxis-listen.md new file mode 100644 index 0000000000..c7817d94e9 --- /dev/null +++ b/.changeset/fair-taxis-listen.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": patch +"emdash": minor +--- + +Fixes localized taxonomy navigation and editor choices so each admin surface follows the active content locale. Public taxonomy helpers now report visible term counts for the active content locale instead of combining assignments across translations. diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index 21bb026d43..3b1fe1ee22 100644 --- a/packages/admin/src/components/ContentSettingsPanel.tsx +++ b/packages/admin/src/components/ContentSettingsPanel.tsx @@ -414,7 +414,12 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ const [publishedDate, setPublishedDate] = React.useState(storedPublishedDate); const [isReorderingSections, setIsReorderingSections] = React.useState(false); const showDiscard = !isNew && supportsDrafts && hasPendingChanges && !!onDiscardDraft; - const hasApplicableTaxonomies = useHasApplicableTaxonomies(collection); + const activeEntryLocale = item?.locale ?? entryLocale ?? undefined; + const hasApplicableTaxonomies = useHasApplicableTaxonomies( + collection, + activeEntryLocale, + i18n?.defaultLocale, + ); const canUpdatePublishedDate = item?.publishedAt != null && (currentUser?.role ?? 0) >= ROLE_EDITOR && !!onPublishedAtChange; const contentLocale = item?.locale ?? entryLocale ?? manifest?.contentLocale?.defaultLocale; @@ -703,7 +708,8 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ className="p-4" collection={collection} entryId={item.id} - entryLocale={item.locale ?? entryLocale} + entryLocale={activeEntryLocale} + defaultLocale={i18n?.defaultLocale} canManageTaxonomies={(currentUser?.role ?? 0) >= ROLE_EDITOR} /> diff --git a/packages/admin/src/components/Shell.tsx b/packages/admin/src/components/Shell.tsx index bdc80f46ee..5fbd2d419d 100644 --- a/packages/admin/src/components/Shell.tsx +++ b/packages/admin/src/components/Shell.tsx @@ -31,9 +31,13 @@ export interface ShellProps { } >; taxonomies: Array<{ + id?: string; name: string; label: string; + locale?: string; + translationGroup?: string | null; }>; + i18n?: { defaultLocale: string; locales: string[] }; version?: string; }; } diff --git a/packages/admin/src/components/Sidebar.tsx b/packages/admin/src/components/Sidebar.tsx index 52915399bf..cb154ed976 100644 --- a/packages/admin/src/components/Sidebar.tsx +++ b/packages/admin/src/components/Sidebar.tsx @@ -8,6 +8,10 @@ import * as React from "react"; import { fetchCommentCounts } from "../lib/api/comments"; import { useCurrentUser } from "../lib/api/current-user"; import { resolvePluginPagePath, usePluginAdmins } from "../lib/plugin-context"; +import { + resolveTaxonomyDefinitions, + type LocalizedTaxonomyDefinition, +} from "../lib/taxonomy-definitions.js"; import { ADMIN_NAV_ICONS, getCollectionNavIcon, @@ -91,9 +95,13 @@ export interface SidebarNavProps { } >; taxonomies: Array<{ + id?: string; name: string; label: string; + locale?: string; + translationGroup?: string | null; }>; + i18n?: { defaultLocale: string; locales: string[] }; version?: string; commit?: string; marketplace?: string; @@ -108,11 +116,21 @@ export interface SidebarNavProps { }; } +/** Locale-normalized taxonomy rows used by the global Manage navigation. */ +export function getSidebarTaxonomies( + taxonomies: readonly T[], + activeLocale?: string, + defaultLocale?: string, +): T[] { + return resolveTaxonomyDefinitions(taxonomies, activeLocale, defaultLocale); +} + interface NavItem { to: string; label: string; icon: React.ElementType; params?: Record; + search?: Record; /** Minimum role level required to see this item */ minRole?: number; /** Optional badge count (e.g., pending comments) */ @@ -177,21 +195,26 @@ export function resolvePluginPageLabel( } /** Resolves a nav item's route path by substituting $param placeholders. */ -function resolveItemPath(item: NavItem): string { +export function resolveItemPath(item: NavItem): string { let path = item.to; if (item.params) { for (const [key, value] of Object.entries(item.params)) { path = path.replace(`$${key}`, value); } } + if (item.search && Object.keys(item.search).length > 0) { + path += `?${new URLSearchParams(item.search).toString()}`; + } return path; } /** Checks if a nav item is active based on the current router path. */ -function isItemActive(itemPath: string, currentPath: string): boolean { - return itemPath === "/" +export function isItemActive(itemPath: string, currentPath: string): boolean { + const queryIndex = itemPath.indexOf("?"); + const path = queryIndex === -1 ? itemPath : itemPath.slice(0, queryIndex); + return path === "/" ? currentPath === "/" - : currentPath === itemPath || currentPath.startsWith(`${itemPath}/`); + : currentPath === path || currentPath.startsWith(`${path}/`); } /** @@ -201,6 +224,8 @@ export function SidebarNav({ manifest }: SidebarNavProps) { const { t, i18n } = useLingui(); const location = useLocation(); const currentPath = location.pathname; + const routeLocale = + new URL(location.href, "http://emdash.local").searchParams.get("locale") ?? undefined; const pluginAdmins = usePluginAdmins(); const { data: user } = useCurrentUser(); @@ -247,13 +272,16 @@ export function SidebarNav({ manifest }: SidebarNavProps) { }, { to: "/widgets", label: t`Widgets`, icon: ADMIN_NAV_ICONS.widgets, minRole: ROLE_EDITOR }, { to: "/sections", label: t`Sections`, icon: ADMIN_NAV_ICONS.sections, minRole: ROLE_EDITOR }, - ...manifest.taxonomies.map((tax) => ({ - to: "/taxonomies/$taxonomy" as const, - label: tax.label, - icon: getTaxonomyNavIcon(tax.name), - params: { taxonomy: tax.name }, - minRole: ROLE_EDITOR, - })), + ...getSidebarTaxonomies(manifest.taxonomies, routeLocale, manifest.i18n?.defaultLocale).map( + (tax) => ({ + to: "/taxonomies/$taxonomy" as const, + label: tax.label, + icon: getTaxonomyNavIcon(tax.name), + params: { taxonomy: tax.name }, + search: routeLocale ? { locale: routeLocale } : undefined, + minRole: ROLE_EDITOR, + }), + ), { to: "/bylines", label: t`Bylines`, icon: ADMIN_NAV_ICONS.bylines, minRole: ROLE_EDITOR }, ]; diff --git a/packages/admin/src/components/TaxonomySidebar.tsx b/packages/admin/src/components/TaxonomySidebar.tsx index 7f322c0e4a..cf7f27e3c1 100644 --- a/packages/admin/src/components/TaxonomySidebar.tsx +++ b/packages/admin/src/components/TaxonomySidebar.tsx @@ -16,6 +16,7 @@ import * as React from "react"; import { apiFetch, parseApiResponse, throwResponseError } from "../lib/api/client.js"; import { createTerm, createTermTranslation, withLocale } from "../lib/api/taxonomies.js"; +import { resolveTaxonomyDefinitions } from "../lib/taxonomy-definitions.js"; import { rankTermMatches, termExactMatches } from "../lib/taxonomy-match.js"; import { cn } from "../lib/utils.js"; @@ -51,6 +52,8 @@ interface TaxonomyDef { labelSingular?: string; hierarchical: boolean; collections: string[]; + locale?: string; + translationGroup?: string | null; } interface TaxonomySidebarProps { @@ -60,6 +63,8 @@ interface TaxonomySidebarProps { /** Locale of the entry being edited. Scopes term reads/writes so only the * matching translation variants are shown — see issue #1218. */ entryLocale?: string; + /** Site default used when this logical taxonomy has no entry-locale definition. */ + defaultLocale?: string; onChange?: (taxonomyName: string, termIds: string[]) => void; /** Applied to the root when the section renders. Omitted when the section * is empty so the caller doesn't need to guess whether to draw chrome. */ @@ -83,17 +88,27 @@ async function fetchTaxonomyDefs(): Promise { return data.taxonomies; } -function useApplicableTaxonomies(collection: string): TaxonomyDef[] { +function useApplicableTaxonomies( + collection: string, + activeLocale?: string, + defaultLocale?: string, +): TaxonomyDef[] { const { data: taxonomies = [] } = useQuery({ queryKey: ["taxonomy-defs"], queryFn: fetchTaxonomyDefs, }); - return taxonomies.filter((taxonomy) => taxonomy.collections.includes(collection)); + return resolveTaxonomyDefinitions(taxonomies, activeLocale, defaultLocale).filter((taxonomy) => + taxonomy.collections.includes(collection), + ); } /** Whether the editor should include a taxonomy settings section. */ -export function useHasApplicableTaxonomies(collection: string): boolean { - return useApplicableTaxonomies(collection).length > 0; +export function useHasApplicableTaxonomies( + collection: string, + activeLocale?: string, + defaultLocale?: string, +): boolean { + return useApplicableTaxonomies(collection, activeLocale, defaultLocale).length > 0; } /** @@ -649,12 +664,13 @@ export function TaxonomySidebar({ collection, entryId, entryLocale, + defaultLocale, canManageTaxonomies, onChange, className, }: TaxonomySidebarProps) { const { t } = useLingui(); - const applicableTaxonomies = useApplicableTaxonomies(collection); + const applicableTaxonomies = useApplicableTaxonomies(collection, entryLocale, defaultLocale); if (applicableTaxonomies.length === 0) { return null; diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index 5dc65f4217..c8acad2727 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -189,11 +189,14 @@ export interface AdminManifest { * Taxonomy definitions for the admin sidebar. */ taxonomies: Array<{ + id?: string; name: string; label: string; labelSingular?: string; hierarchical: boolean; collections: string[]; + locale?: string; + translationGroup?: string | null; }>; /** * Marketplace registry URL. Present when `marketplace` is configured diff --git a/packages/admin/src/lib/taxonomy-definitions.ts b/packages/admin/src/lib/taxonomy-definitions.ts new file mode 100644 index 0000000000..9379e3cbb9 --- /dev/null +++ b/packages/admin/src/lib/taxonomy-definitions.ts @@ -0,0 +1,59 @@ +export interface LocalizedTaxonomyDefinition { + id?: string; + name: string; + label: string; + locale?: string; + translationGroup?: string | null; +} + +function normalizedLocale(locale: string | undefined): string | undefined { + return locale?.trim().toLowerCase() || undefined; +} + +/** + * Collapse localized definition rows to one row per logical taxonomy. + * + * Selection prefers the active locale, then the configured default locale. + * If neither exists, the lexically first locale/id/label wins so incomplete + * translation groups remain usable and produce stable manifests and UI. + * Legacy definitions without translation metadata are grouped by `name`. + */ +export function resolveTaxonomyDefinitions( + definitions: readonly T[], + activeLocale?: string, + defaultLocale?: string, +): T[] { + const active = normalizedLocale(activeLocale); + const fallback = normalizedLocale(defaultLocale); + const groups = new Map(); + + for (const definition of definitions) { + const group = definition.translationGroup?.trim() || definition.name; + const variants = groups.get(group); + if (variants) variants.push(definition); + else groups.set(group, [definition]); + } + + return Array.from(groups.values(), (variants) => { + const exact = active + ? variants.find((definition) => normalizedLocale(definition.locale) === active) + : undefined; + if (exact) return exact; + + const defaultVariant = fallback + ? variants.find((definition) => normalizedLocale(definition.locale) === fallback) + : undefined; + if (defaultVariant) return defaultVariant; + + return variants.toSorted((left, right) => { + const leftKey = [normalizedLocale(left.locale) ?? "", left.id ?? "", left.name, left.label]; + const rightKey = [ + normalizedLocale(right.locale) ?? "", + right.id ?? "", + right.name, + right.label, + ]; + return leftKey.join("\0").localeCompare(rightKey.join("\0")); + })[0]!; + }); +} diff --git a/packages/admin/tests/components/Sidebar.test.tsx b/packages/admin/tests/components/Sidebar.test.tsx index 12f11449de..6aad546204 100644 --- a/packages/admin/tests/components/Sidebar.test.tsx +++ b/packages/admin/tests/components/Sidebar.test.tsx @@ -36,6 +36,9 @@ import { describe, it, expect } from "vitest"; import { BYLINE_SCHEMA_NAV_ITEM, filterNavItemsByRole, + getSidebarTaxonomies, + isItemActive, + resolveItemPath, resolveNavIcon, resolvePluginPageLabel, toPhosphorIconName, @@ -51,6 +54,51 @@ const ROLE_AUTHOR = 30; const ROLE_EDITOR = 40; const ROLE_ADMIN = 50; +describe("getSidebarTaxonomies", () => { + const taxonomies = [ + { id: "course-en", name: "course", label: "Courses", locale: "en", translationGroup: "course" }, + { id: "course-de", name: "course", label: "Gänge", locale: "de", translationGroup: "course" }, + { + id: "course-fr", + name: "course", + label: "Types de plats", + locale: "fr", + translationGroup: "course", + }, + ]; + + it("renders one logical taxonomy using the active route locale", () => { + expect(getSidebarTaxonomies(taxonomies, "de").map((taxonomy) => taxonomy.label)).toEqual([ + "Gänge", + ]); + }); + + it("falls back to the configured default locale, then deterministically", () => { + expect(getSidebarTaxonomies(taxonomies, "it", "fr")[0]?.label).toBe("Types de plats"); + expect(getSidebarTaxonomies(taxonomies, "it")[0]?.label).toBe("Gänge"); + }); +}); + +describe("resolveItemPath", () => { + it("preserves the active locale on taxonomy-management links", () => { + expect( + resolveItemPath({ + to: "/taxonomies/$taxonomy", + label: "Gänge", + icon: Gear, + params: { taxonomy: "course" }, + search: { locale: "de" }, + }), + ).toBe("/taxonomies/course?locale=de"); + }); +}); + +describe("isItemActive", () => { + it("matches taxonomy links independently of their locale query", () => { + expect(isItemActive("/taxonomies/course?locale=de", "/taxonomies/course")).toBe(true); + }); +}); + describe("BYLINE_SCHEMA_NAV_ITEM invariants", () => { it("points to the /byline-schema route", () => { expect(BYLINE_SCHEMA_NAV_ITEM.to).toBe("/byline-schema"); diff --git a/packages/admin/tests/components/TaxonomySidebar.test.tsx b/packages/admin/tests/components/TaxonomySidebar.test.tsx index 1e38d6bf2e..6ba6fae812 100644 --- a/packages/admin/tests/components/TaxonomySidebar.test.tsx +++ b/packages/admin/tests/components/TaxonomySidebar.test.tsx @@ -21,6 +21,8 @@ interface TestTaxonomy { id: string; name: string; label: string; + locale?: string; + translationGroup?: string; labelSingular?: string; hierarchical: boolean; collections: string[]; @@ -451,6 +453,43 @@ describe("TaxonomySidebar", () => { expect(screen.getByLabelText("Add Categories").query()).toBeNull(); }); + it("renders only the entry-locale definition for a translated taxonomy", async () => { + mockApiFetch({ + taxonomies: [ + { ...tagsTaxonomy, id: "tags-en", label: "Tags", locale: "en", translationGroup: "tags" }, + { + ...tagsTaxonomy, + id: "tags-de", + label: "Schlagwörter", + locale: "de", + translationGroup: "tags", + }, + { + ...tagsTaxonomy, + id: "tags-fr", + label: "Étiquettes", + locale: "fr", + translationGroup: "tags", + }, + ], + }); + + const screen = await render( + , + { wrapper: Wrapper }, + ); + + await expect.element(screen.getByText("Schlagwörter", { exact: true })).toBeInTheDocument(); + expect(screen.getByText("Tags").query()).toBeNull(); + expect(screen.getByText("Étiquettes").query()).toBeNull(); + await expect.element(screen.getByLabelText("Add Schlagwörter")).toBeInTheDocument(); + }); + it("selects Arabic matches when the interface direction is RTL", async () => { const previousDirection = document.documentElement.dir; document.documentElement.dir = "rtl"; diff --git a/packages/core/src/api/handlers/taxonomies.ts b/packages/core/src/api/handlers/taxonomies.ts index 1a7688d68b..784db7e5be 100644 --- a/packages/core/src/api/handlers/taxonomies.ts +++ b/packages/core/src/api/handlers/taxonomies.ts @@ -141,31 +141,54 @@ function buildTree(flatTerms: TermWithCount[], resolveByGroup = false): TermWith return roots; } -/** - * Look up a taxonomy definition by name (optionally scoped to a locale). - * Returns the lowest-locale match when no locale is provided. - */ +type TaxonomyDefLookup = + | { success: true; def: Selectable } + | { success: false; error: { code: string; message: string } }; + +function taxonomyDefNotFound(name: string, locale?: string): TaxonomyDefLookup { + return { + success: false, + error: { + code: "NOT_FOUND", + message: `Taxonomy '${name}' not found${locale !== undefined ? ` in locale '${locale}'` : ""}`, + }, + }; +} + +/** Look up the exact definition addressed by a mutation. */ async function requireTaxonomyDef( db: Kysely, name: string, locale?: string, -): Promise< - | { success: true; def: Selectable } - | { success: false; error: { code: string; message: string } } -> { +): Promise { let query = db.selectFrom("_emdash_taxonomy_defs").selectAll().where("name", "=", name); if (locale !== undefined) query = query.where("locale", "=", locale); - const def = await query.orderBy("locale", "asc").executeTakeFirst(); - if (!def) { - return { - success: false, - error: { - code: "NOT_FOUND", - message: `Taxonomy '${name}' not found${locale !== undefined ? ` in locale '${locale}'` : ""}`, - }, - }; - } - return { success: true, def }; + const def = await query.orderBy("locale", "asc").orderBy("id", "asc").executeTakeFirst(); + return def ? { success: true, def } : taxonomyDefNotFound(name, locale); +} + +/** + * Prefer the requested locale, then the configured default. Incomplete + * translation groups fall back deterministically so their terms remain usable. + */ +async function requireTaxonomyDefWithFallback( + db: Kysely, + name: string, + locale?: string, +): Promise { + const defs = await db + .selectFrom("_emdash_taxonomy_defs") + .selectAll() + .where("name", "=", name) + .orderBy("locale", "asc") + .orderBy("id", "asc") + .execute(); + const defaultLocale = getI18nConfig()?.defaultLocale; + const def = + (locale ? defs.find((candidate) => candidate.locale === locale) : undefined) ?? + (defaultLocale ? defs.find((candidate) => candidate.locale === defaultLocale) : undefined) ?? + defs[0]; + return def ? { success: true, def } : taxonomyDefNotFound(name, locale); } /** The subset of `slugs` that still has a row in `_emdash_collections`. */ @@ -291,7 +314,7 @@ export async function handleTaxonomyGet( ): Promise> { try { const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; - const lookup = await requireTaxonomyDef(db, name, locale); + const lookup = await requireTaxonomyDefWithFallback(db, name, locale); if (!lookup.success) return lookup; return { success: true, data: await toTaxonomyResponse(db, lookup.def) }; @@ -595,12 +618,12 @@ export async function handleTermList( }; } // Definitions are per-locale but terms aren't bound to the def's locale — - // just ensure the taxonomy exists somewhere. - const lookup = await requireTaxonomyDef(db, taxonomyName); + // use the active definition for its collection scope. + const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; + const lookup = await requireTaxonomyDefWithFallback(db, taxonomyName, locale); if (!lookup.success) return lookup; const repo = new TaxonomyRepository(db); - const locale = options.locale ? resolveConfiguredLocale(options.locale) : undefined; const terms = options.resolveFallback && locale ? await repo.findByNameResolved( @@ -617,7 +640,7 @@ export async function handleTermList( // look up by group and map back to each term's id. const includeCounts = options.includeCounts ?? true; const countsByGroup = includeCounts - ? await fetchVisibleTermCounts(db, taxonomyName, defCollections(lookup.def)) + ? await fetchVisibleTermCounts(db, taxonomyName, defCollections(lookup.def), locale) : undefined; const termData: TermWithCount[] = terms.map((term) => ({ @@ -994,12 +1017,14 @@ export async function handleTermGet( // Count matches public visibility (published or scheduled-and-due, not // soft-deleted) scoped to the def's declared collections. The def lookup - // is lenient: a term whose def is missing still resolves, with count 0. - const lookup = await requireTaxonomyDef(db, taxonomyName); + // falls back for incomplete translations; a term with no def at all still + // resolves with count 0. + const lookup = await requireTaxonomyDefWithFallback(db, taxonomyName, locale); const counts = await fetchVisibleTermCounts( db, taxonomyName, lookup.success ? defCollections(lookup.def) : [], + locale ?? term.locale, ); const count = counts.get(term.translationGroup ?? term.id) ?? 0; // Children share this term's translation_group as their parent_id; scope diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 1decd14f0f..1fb48878dd 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -160,11 +160,14 @@ export interface EmDashManifest { * Taxonomy definitions for the admin sidebar. */ taxonomies: Array<{ + id: string; name: string; label: string; labelSingular?: string; hierarchical: boolean; collections: string[]; + locale: string; + translationGroup: string; }>; /** * Whether the plugin marketplace is configured. diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 012ec89e71..6c3780c9bc 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2682,11 +2682,14 @@ export class EmDashRuntime { // Build taxonomies from database let manifestTaxonomies: Array<{ + id: string; name: string; label: string; labelSingular?: string; hierarchical: boolean; collections: string[]; + locale: string; + translationGroup: string; }> = []; let taxonomyDefinitionLocales: string[] = []; try { @@ -2697,11 +2700,14 @@ export class EmDashRuntime { .execute(); taxonomyDefinitionLocales = rows.map((row) => row.locale); manifestTaxonomies = rows.map((row) => ({ + id: row.id, name: row.name, label: row.label, labelSingular: row.label_singular ?? undefined, hierarchical: row.hierarchical === 1, collections: parseStringArray(row.collections).toSorted(), + locale: row.locale, + translationGroup: row.translation_group ?? row.id, })); } catch (error) { console.debug("EmDash: Could not load taxonomy definitions:", error); diff --git a/packages/core/src/taxonomies/index.ts b/packages/core/src/taxonomies/index.ts index 0bef88e9ad..da7a2f4ab5 100644 --- a/packages/core/src/taxonomies/index.ts +++ b/packages/core/src/taxonomies/index.ts @@ -313,7 +313,7 @@ export async function getTaxonomyTerms( // The two are independent, so run them concurrently to save a round trip. const [terms, counts] = await Promise.all([ getTermList(def, locale), - getVisibleTermCounts(def.name, def.collections), + getVisibleTermCounts(def.name, def.collections, locale), ]); return withCounts(terms, counts); } @@ -386,28 +386,30 @@ async function loadTaxonomyTerms( /** * Per-translation-group visible-usage counts for one taxonomy, in a single - * round-trip (see `fetchVisibleTermCounts`). Counts are locale-independent - * (the pivot stores translation_group), and the request-cached map is shared - * by every consumer in the render — the widget (`getTaxonomyTerms`) and the - * single-term page (`getTerm`) never issue separate count queries. + * round-trip (see `fetchVisibleTermCounts`). The pivot identity is the + * translation group, while entry rows are scoped to the resolved locale. + * Request and object cache keys include that locale so translated views never + * reuse each other's visible counts. */ function getVisibleTermCounts( taxonomyName: string, collections: string[], + locale?: string, ): Promise> { // The collection scope is part of the key: per-locale rows of the same def // can drift in their declared collections, and a caller may pass a narrower // scope. Identical inputs (the widget + term-page hot path) still share one // entry. const scope = [...new Set(collections)].toSorted().join(","); - return requestCached(`taxonomy-term-counts:${taxonomyName}:${scope}`, async () => { + const localeScope = locale ?? "*"; + return requestCached(`taxonomy-term-counts:${taxonomyName}:${scope}:${localeScope}`, async () => { // A Map is not JSON-representable — cache the entries, rebuild on read. const entries = await cachedQuery({ namespace: termCountNamespaces(collections), - key: `termCounts:${taxonomyName}:${scope}`, + key: `termCounts:${taxonomyName}:${scope}:${localeScope}`, load: async (): Promise> => { const db = await getDb(); - return [...(await fetchVisibleTermCounts(db, taxonomyName, collections))]; + return [...(await fetchVisibleTermCounts(db, taxonomyName, collections, locale))]; }, }); return new Map(entries); @@ -482,7 +484,7 @@ async function loadTerm( // databases. The counts map is request-cached per taxonomy — on a page // that also renders the taxonomy widget it's a free Map lookup. const [counts, childRows] = await Promise.all([ - getVisibleTermCounts(taxonomyName, collections), + getVisibleTermCounts(taxonomyName, collections, chain[0] ?? row.locale), childrenQuery.execute(), ]); const count = counts.get(row.translation_group ?? row.id) ?? 0; diff --git a/packages/core/src/taxonomies/term-counts.ts b/packages/core/src/taxonomies/term-counts.ts index 60268e9a44..494909ca97 100644 --- a/packages/core/src/taxonomies/term-counts.ts +++ b/packages/core/src/taxonomies/term-counts.ts @@ -30,8 +30,8 @@ interface CountRow { /** * Per-collection count branch. `taxonomy_id` stores the term's * translation_group, so results are keyed by group (locale-independent) and - * each assignment is counted once no matter how many content or term locales - * belong to either translation group. + * each assignment is counted once per content translation group. When a + * locale is provided, only that group's matching content row contributes. * * Scoping to the taxonomy uses `translation_group IN (...)` rather than a * join on `taxonomies.id` — the anchor row (id == group) can be deleted while @@ -47,6 +47,7 @@ function collectionBranch( db: Kysely, taxonomyName: string, collection: string, + locale?: string, ): ReturnType { return sql` SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count @@ -55,6 +56,7 @@ function collectionBranch( WHERE e.translation_group = ct.entry_id AND ct.collection = ${collection} AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ${taxonomyName}) + ${locale ? sql`AND e.locale = ${locale}` : sql``} AND ${buildStatusCondition(db, "published", "e")} AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id`; @@ -64,8 +66,11 @@ async function runCounts( db: Kysely, taxonomyName: string, collections: string[], + locale?: string, ): Promise> { - const branches = collections.map((collection) => collectionBranch(db, taxonomyName, collection)); + const branches = collections.map((collection) => + collectionBranch(db, taxonomyName, collection, locale), + ); const union = sql.join(branches, sql` UNION ALL `); const result = await sql` SELECT taxonomy_id, SUM(count) AS count @@ -89,9 +94,10 @@ async function runBatch( db: Kysely, taxonomyName: string, collections: string[], + locale?: string, ): Promise> { try { - return await runCounts(db, taxonomyName, collections); + return await runCounts(db, taxonomyName, collections, locale); } catch (error) { if (!isMissingTableError(error)) throw error; } @@ -99,7 +105,7 @@ async function runBatch( const counts = new Map(); for (const collection of collections) { try { - addCounts(counts, await runCounts(db, taxonomyName, [collection])); + addCounts(counts, await runCounts(db, taxonomyName, [collection], locale)); } catch (error) { if (!isMissingTableError(error)) throw error; } @@ -110,6 +116,8 @@ async function runBatch( /** * Count publicly-visible term assignments for one taxonomy, keyed by the * term's translation_group (what `content_taxonomies.taxonomy_id` stores). + * When `locale` is provided, only entries in that locale contribute. Omitting + * it preserves the locale-agnostic API used by legacy callers. * * Counts are scoped to the taxonomy's declared collections — pass * `TaxonomyDef.collections` (`_emdash_taxonomy_defs.collections`). Collections @@ -132,6 +140,7 @@ export async function fetchVisibleTermCounts( db: Kysely, taxonomyName: string, collections: string[], + locale?: string, ): Promise> { const unique = [...new Set(collections)]; for (const collection of unique) validateIdentifier(collection, "collection slug"); @@ -139,7 +148,9 @@ export async function fetchVisibleTermCounts( const limit = compoundSelectLimit(db); const batched = limit === null ? [unique] : chunks(unique, limit); - const batches = await Promise.all(batched.map((batch) => runBatch(db, taxonomyName, batch))); + const batches = await Promise.all( + batched.map((batch) => runBatch(db, taxonomyName, batch, locale)), + ); const counts = new Map(); for (const batch of batches) addCounts(counts, batch); diff --git a/packages/core/tests/integration/taxonomy-term-counts-plan.test.ts b/packages/core/tests/integration/taxonomy-term-counts-plan.test.ts index 678a629a35..c312ea4a61 100644 --- a/packages/core/tests/integration/taxonomy-term-counts-plan.test.ts +++ b/packages/core/tests/integration/taxonomy-term-counts-plan.test.ts @@ -87,7 +87,7 @@ function explain(query: CapturedQuery): string { async function countQueryPlan(): Promise { captured = []; - await fetchVisibleTermCounts(db, "category", ["post"]); + await fetchVisibleTermCounts(db, "category", ["post"], "en"); const query = captured.find((q) => q.sql.includes("content_taxonomies")); expect(query, "expected a term-count query against the pivot").toBeDefined(); return explain(query!); @@ -106,7 +106,7 @@ it("seeks content rows by translation group", async () => { const plan = await countQueryPlan(); expect(plan).toMatch( - /SEARCH e USING (COVERING )?INDEX idx_ec_post_tg_locale \(translation_group=\?\)/, + /SEARCH e USING (COVERING )?INDEX idx_ec_post_del_tg_locale \(deleted_at=\? AND translation_group=\? AND locale=\?\)/, ); expect(plan).not.toContain("SCAN e"); }); diff --git a/packages/core/tests/unit/runtime/manifest-build.test.ts b/packages/core/tests/unit/runtime/manifest-build.test.ts index 84a0179be4..469dd3dbd3 100644 --- a/packages/core/tests/unit/runtime/manifest-build.test.ts +++ b/packages/core/tests/unit/runtime/manifest-build.test.ts @@ -180,6 +180,19 @@ describe("EmDashRuntime.getManifest()", () => { } }); + it("includes taxonomy locale identity for admin-side normalization", async () => { + const runtime = buildRuntime(db); + const manifest = await runtime.getManifest(); + const category = manifest.taxonomies.find((taxonomy) => taxonomy.name === "category"); + + expect(category).toMatchObject({ + id: expect.any(String), + locale: "en", + translationGroup: expect.any(String), + }); + expect(category?.translationGroup).toBe(category?.id); + }); + it("publishes only supported, existing list columns and caps them at four", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); const registry = new SchemaRegistry(db); diff --git a/packages/core/tests/unit/taxonomies/taxonomy-crud.test.ts b/packages/core/tests/unit/taxonomies/taxonomy-crud.test.ts index 7414de9ac1..4e0e28d6c6 100644 --- a/packages/core/tests/unit/taxonomies/taxonomy-crud.test.ts +++ b/packages/core/tests/unit/taxonomies/taxonomy-crud.test.ts @@ -21,6 +21,7 @@ import { runMigrations } from "../../../src/database/migrations/runner.js"; import { ContentRepository } from "../../../src/database/repositories/content.js"; import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js"; import type { Database as DatabaseSchema } from "../../../src/database/types.js"; +import { setI18nConfig } from "../../../src/i18n/config.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import { describeEachDialect, @@ -97,6 +98,7 @@ describeEachDialect("single-taxonomy CRUD", (dialect) => { }); afterEach(async () => { + setI18nConfig(null); await teardownForDialect(ctx); }); @@ -123,13 +125,23 @@ describeEachDialect("single-taxonomy CRUD", (dialect) => { expect(result.error.code).toBe("NOT_FOUND"); }); - it("reports NOT_FOUND when the taxonomy has no definition in the requested locale", async () => { - const result = await handleTaxonomyGet(db, "genre", { locale: "es" }); + it("falls back to the default locale when the requested locale has no definition", async () => { + const source = await handleTaxonomyGet(db, "genre"); + expect(source.success).toBe(true); + if (!source.success) return; + await handleTaxonomyCreate(db, { + name: "genre", + label: "Géneros", + locale: "es", + translationOf: source.data.taxonomy.id, + }); + setI18nConfig({ defaultLocale: "es", locales: ["en", "es", "fr"] }); - expect(result.success).toBe(false); - if (result.success) return; - expect(result.error.code).toBe("NOT_FOUND"); - expect(result.error.message).toContain("es"); + const result = await handleTaxonomyGet(db, "genre", { locale: "fr" }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.taxonomy).toMatchObject({ label: "Géneros", locale: "es" }); }); it("returns the requested locale's definition", async () => { @@ -244,6 +256,22 @@ describeEachDialect("single-taxonomy CRUD", (dialect) => { expect(english.success && english.data.taxonomy.label).toBe("Genres"); }); + it("does not fall back when the addressed locale has no definition", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "fr"] }); + + const result = await handleTaxonomyUpdate(db, "genre", { + label: "Genres français", + locale: "fr", + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("NOT_FOUND"); + + const english = await handleTaxonomyGet(db, "genre", { locale: "en" }); + expect(english.success && english.data.taxonomy.label).toBe("Genres"); + }); + it("reports NOT_FOUND for an unknown name", async () => { const result = await handleTaxonomyUpdate(db, "nope", { label: "x" }); diff --git a/packages/core/tests/unit/taxonomies/term-counts.test.ts b/packages/core/tests/unit/taxonomies/term-counts.test.ts index a44dae5139..6f2a6c508a 100644 --- a/packages/core/tests/unit/taxonomies/term-counts.test.ts +++ b/packages/core/tests/unit/taxonomies/term-counts.test.ts @@ -216,7 +216,6 @@ describeEachDialect("visible term counts (#581)", (dialect) => { translation_group: enDef.translation_group ?? enDef.id, }) .execute(); - const enTerm = await taxRepo.create({ name: "category", slug: "news", @@ -267,6 +266,40 @@ describeEachDialect("visible term counts (#581)", (dialect) => { expect(frTerms.find((term) => term.id === frTerm.id)?.count).toBe(1); }); + it("counts only entry rows in the requested locale, keyed by translation_group", async () => { + const enTerm = await taxRepo.create({ + name: "category", + slug: "news", + label: "News", + locale: "en", + }); + const enPost = await contentRepo.create({ + type: "post", + slug: "hello", + status: "published", + data: { title: "Hello" }, + locale: "en", + }); + await contentRepo.create({ + type: "post", + slug: "bonjour", + status: "published", + data: { title: "Bonjour" }, + locale: "fr", + translationOf: enPost.id, + }); + await taxRepo.attachToEntry("post", enPost.id, enTerm.id); + + const enCounts = await fetchVisibleTermCounts(ctx.db, "category", ["post"], "en"); + const frCounts = await fetchVisibleTermCounts(ctx.db, "category", ["post"], "fr"); + const deCounts = await fetchVisibleTermCounts(ctx.db, "category", ["post"], "de"); + const group = enTerm.translationGroup ?? enTerm.id; + + expect(enCounts.get(group)).toBe(1); + expect(frCounts.get(group)).toBe(1); + expect(deCounts.has(group)).toBe(false); + }); + it("skips missing ec_* tables and returns a partial count", async () => { // A declared collection whose table was never created (pre-migration // drift) must not break counting for the collections that do exist. @@ -310,8 +343,20 @@ describeEachDialect("visible term counts (#581)", (dialect) => { }); const post = await createEntry("post", "p1"); - const page1 = await createEntry("page", "g1"); - const page2 = await createEntry("page", "g2"); + const page1 = await contentRepo.create({ + type: "page", + slug: "g1", + status: "published", + data: { title: "g1" }, + locale: "fr", + }); + const page2 = await contentRepo.create({ + type: "page", + slug: "g2", + status: "published", + data: { title: "g2" }, + locale: "fr", + }); await taxRepo.attachToEntry("post", post.id, enTerm.id); await taxRepo.attachToEntry("page", page1.id, enTerm.id); await taxRepo.attachToEntry("page", page2.id, enTerm.id); @@ -323,6 +368,47 @@ describeEachDialect("visible term counts (#581)", (dialect) => { expect(enTerms[0]!.count).toBe(1); expect(frTerms[0]!.count).toBe(2); }); + + const frList = await handleTermList(ctx.db, "drifty", { locale: "fr" }); + if (!frList.success) throw new Error(frList.error.message); + expect(frList.data.terms[0]!.count).toBe(2); + + const frTerm = await handleTermGet(ctx.db, "drifty", "partage", { locale: "fr" }); + if (!frTerm.success) throw new Error(frTerm.error.message); + expect(frTerm.data.term.count).toBe(2); + }); + + it("falls back to an existing definition when the requested locale has none", async () => { + await insertDef("partial", ["post"], "en"); + const enTerm = await taxRepo.create({ + name: "partial", + slug: "shared", + label: "Shared", + locale: "en", + }); + const frTerm = await taxRepo.create({ + name: "partial", + slug: "partage", + label: "Partagé", + locale: "fr", + translationOf: enTerm.id, + }); + const frPost = await contentRepo.create({ + type: "post", + slug: "bonjour", + status: "published", + data: { title: "Bonjour" }, + locale: "fr", + }); + await taxRepo.attachToEntry("post", frPost.id, frTerm.id); + + const list = await handleTermList(ctx.db, "partial", { locale: "fr" }); + const term = await handleTermGet(ctx.db, "partial", "partage", { locale: "fr" }); + + expect([list, term]).toMatchObject([ + { success: true, data: { terms: [{ slug: "partage", count: 1 }] } }, + { success: true, data: { term: { slug: "partage", count: 1 } } }, + ]); }); it("returns an empty map when the taxonomy declares no collections", async () => { diff --git a/scripts/query-counts.queries.d1.json b/scripts/query-counts.queries.d1.json index 4e24de5eca..a30b63abb2 100644 --- a/scripts/query-counts.queries.d1.json +++ b/scripts/query-counts.queries.d1.json @@ -43,7 +43,7 @@ "select count(*) as \"count\" from \"_emdash_collections\"": 1, "SELECT COUNT(*) as count FROM \"_emdash_migrations\"": 1, "select distinct \"translation_group\" from \"taxonomies\" where \"name\" = ? and \"slug\" in (...)": 1, - "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, + "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND e.locale = ? AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, "UPDATE _emdash_cron_tasks SET status = 'idle', locked_at = NULL WHERE status = 'running' AND locked_at < ?": 1, "WITH picked AS ( SELECT r.id AS entry_id, \"r\".\"published_at\" AS sortval FROM content_taxonomies ct CROSS JOIN \"ec_posts\" AS r ON r.translation_group = ct.entry_id WHERE ct.collection = ? AND \"ct\".\"taxonomy_id\" = ? AND r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY sortval DESC, r.id DESC ) SELECT r.*, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"r\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"r\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"r\".id AND b.locale = \"r\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM picked JOIN \"ec_posts\" AS r ON r.id = picked.entry_id WHERE r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY picked.sortval DESC, picked.entry_id DESC": 1 }, @@ -57,7 +57,7 @@ "select * from \"taxonomies\" where \"parent_id\" = ? and \"locale\" = ? order by \"sort_order\" asc, \"label\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"ec_pages\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND \"status\" = ? ORDER BY \"created_at\" DESC, \"id\" DESC": 1, "select distinct \"translation_group\" from \"taxonomies\" where \"name\" = ? and \"slug\" in (...)": 1, - "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, + "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND e.locale = ? AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, "WITH picked AS ( SELECT r.id AS entry_id, \"r\".\"published_at\" AS sortval FROM content_taxonomies ct CROSS JOIN \"ec_posts\" AS r ON r.translation_group = ct.entry_id WHERE ct.collection = ? AND \"ct\".\"taxonomy_id\" = ? AND r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY sortval DESC, r.id DESC ) SELECT r.*, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"r\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"r\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"r\".id AND b.locale = \"r\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM picked JOIN \"ec_posts\" AS r ON r.id = picked.entry_id WHERE r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY picked.sortval DESC, picked.entry_id DESC": 1 }, "GET /contributors (cold)": { @@ -273,7 +273,7 @@ "select count(*) as \"count\" from \"_emdash_collections\"": 1, "SELECT COUNT(*) as count FROM \"_emdash_migrations\"": 1, "select distinct \"translation_group\" from \"taxonomies\" where \"name\" = ? and \"slug\" in (...)": 1, - "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, + "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND e.locale = ? AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, "UPDATE _emdash_cron_tasks SET status = 'idle', locked_at = NULL WHERE status = 'running' AND locked_at < ?": 1, "WITH picked AS ( SELECT r.id AS entry_id, \"r\".\"published_at\" AS sortval FROM content_taxonomies ct CROSS JOIN \"ec_posts\" AS r ON r.translation_group = ct.entry_id WHERE ct.collection = ? AND \"ct\".\"taxonomy_id\" = ? AND r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY sortval DESC, r.id DESC ) SELECT r.*, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"r\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"r\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"r\".id AND b.locale = \"r\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM picked JOIN \"ec_posts\" AS r ON r.id = picked.entry_id WHERE r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY picked.sortval DESC, picked.entry_id DESC": 1 }, @@ -287,7 +287,7 @@ "select * from \"taxonomies\" where \"parent_id\" = ? and \"locale\" = ? order by \"sort_order\" asc, \"label\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"ec_pages\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND \"status\" = ? ORDER BY \"created_at\" DESC, \"id\" DESC": 1, "select distinct \"translation_group\" from \"taxonomies\" where \"name\" = ? and \"slug\" in (...)": 1, - "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, + "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND e.locale = ? AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, "WITH picked AS ( SELECT r.id AS entry_id, \"r\".\"published_at\" AS sortval FROM content_taxonomies ct CROSS JOIN \"ec_posts\" AS r ON r.translation_group = ct.entry_id WHERE ct.collection = ? AND \"ct\".\"taxonomy_id\" = ? AND r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY sortval DESC, r.id DESC ) SELECT r.*, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"r\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"r\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"r\".id AND b.locale = \"r\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM picked JOIN \"ec_posts\" AS r ON r.id = picked.entry_id WHERE r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY picked.sortval DESC, picked.entry_id DESC": 1 } } diff --git a/scripts/query-counts.queries.sqlite.json b/scripts/query-counts.queries.sqlite.json index c9d36be237..a4a3be57c7 100644 --- a/scripts/query-counts.queries.sqlite.json +++ b/scripts/query-counts.queries.sqlite.json @@ -26,7 +26,7 @@ "select * from \"taxonomies\" where \"parent_id\" = ? and \"locale\" = ? order by \"sort_order\" asc, \"label\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"ec_pages\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND \"status\" = ? ORDER BY \"created_at\" DESC, \"id\" DESC": 1, "select distinct \"translation_group\" from \"taxonomies\" where \"name\" = ? and \"slug\" in (...)": 1, - "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, + "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND e.locale = ? AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, "WITH picked AS ( SELECT r.id AS entry_id, \"r\".\"published_at\" AS sortval FROM content_taxonomies ct CROSS JOIN \"ec_posts\" AS r ON r.translation_group = ct.entry_id WHERE ct.collection = ? AND \"ct\".\"taxonomy_id\" = ? AND r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY sortval DESC, r.id DESC ) SELECT r.*, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"r\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"r\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"r\".id AND b.locale = \"r\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM picked JOIN \"ec_posts\" AS r ON r.id = picked.entry_id WHERE r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY picked.sortval DESC, picked.entry_id DESC": 1 }, "GET /category/development (warm)": { @@ -39,7 +39,7 @@ "select * from \"taxonomies\" where \"parent_id\" = ? and \"locale\" = ? order by \"sort_order\" asc, \"label\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"ec_pages\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND \"status\" = ? ORDER BY \"created_at\" DESC, \"id\" DESC": 1, "select distinct \"translation_group\" from \"taxonomies\" where \"name\" = ? and \"slug\" in (...)": 1, - "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, + "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND e.locale = ? AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, "WITH picked AS ( SELECT r.id AS entry_id, \"r\".\"published_at\" AS sortval FROM content_taxonomies ct CROSS JOIN \"ec_posts\" AS r ON r.translation_group = ct.entry_id WHERE ct.collection = ? AND \"ct\".\"taxonomy_id\" = ? AND r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY sortval DESC, r.id DESC ) SELECT r.*, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"r\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"r\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"r\".id AND b.locale = \"r\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM picked JOIN \"ec_posts\" AS r ON r.id = picked.entry_id WHERE r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY picked.sortval DESC, picked.entry_id DESC": 1 }, "GET /contributors (cold)": { @@ -184,7 +184,7 @@ "select * from \"taxonomies\" where \"parent_id\" = ? and \"locale\" = ? order by \"sort_order\" asc, \"label\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"ec_pages\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND \"status\" = ? ORDER BY \"created_at\" DESC, \"id\" DESC": 1, "select distinct \"translation_group\" from \"taxonomies\" where \"name\" = ? and \"slug\" in (...)": 1, - "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, + "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND e.locale = ? AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, "WITH picked AS ( SELECT r.id AS entry_id, \"r\".\"published_at\" AS sortval FROM content_taxonomies ct CROSS JOIN \"ec_posts\" AS r ON r.translation_group = ct.entry_id WHERE ct.collection = ? AND \"ct\".\"taxonomy_id\" = ? AND r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY sortval DESC, r.id DESC ) SELECT r.*, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"r\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"r\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"r\".id AND b.locale = \"r\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM picked JOIN \"ec_posts\" AS r ON r.id = picked.entry_id WHERE r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY picked.sortval DESC, picked.entry_id DESC": 1 }, "GET /tag/webdev (warm)": { @@ -197,7 +197,7 @@ "select * from \"taxonomies\" where \"parent_id\" = ? and \"locale\" = ? order by \"sort_order\" asc, \"label\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"ec_pages\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND \"status\" = ? ORDER BY \"created_at\" DESC, \"id\" DESC": 1, "select distinct \"translation_group\" from \"taxonomies\" where \"name\" = ? and \"slug\" in (...)": 1, - "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, + "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(DISTINCT e.translation_group) AS count FROM content_taxonomies AS ct CROSS JOIN \"ec_posts\" AS e WHERE e.translation_group = ct.entry_id AND ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND e.locale = ? AND \"e\".\"status\" = ? AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1, "WITH picked AS ( SELECT r.id AS entry_id, \"r\".\"published_at\" AS sortval FROM content_taxonomies ct CROSS JOIN \"ec_posts\" AS r ON r.translation_group = ct.entry_id WHERE ct.collection = ? AND \"ct\".\"taxonomy_id\" = ? AND r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY sortval DESC, r.id DESC ) SELECT r.*, (SELECT json_group_array(json_object('id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group))) FILTER (WHERE coalesce(exact_term.id, default_term.id) IS NOT NULL) FROM \"content_taxonomies\" AS ct LEFT JOIN \"taxonomies\" AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = \"r\".locale LEFT JOIN \"taxonomies\" AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ? WHERE ct.collection = ? AND ct.entry_id = \"r\".translation_group) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"r\".id AND b.locale = \"r\".locale) AS \"_emdash_bylines\", (SELECT 1 FROM \"_emdash_bylines\" LIMIT 1) AS \"_emdash_bylines_exist\" FROM picked JOIN \"ec_posts\" AS r ON r.id = picked.entry_id WHERE r.deleted_at IS NULL AND \"r\".\"status\" = ? ORDER BY picked.sortval DESC, picked.entry_id DESC": 1 } }