PhoenixKit.Modules.Publishing.Categories (PhoenixKitPublishing v0.6.0)

Copy Markdown View Source

Hierarchical, per-group categories (WordPress-parity taxonomy) and the assignments of posts to them. The category rows come from core migration V159.

Assignments are version-level

A post's filing lives in version.data["category_uuids"], next to the version's tags, excerpt and SEO fields — not in a post-level join table. A post's subject genuinely changes as it is revised (a release note that grows into a guide), and a reader on ?v=2 should see how v2 was filed, not how the live version is filed today. A new version inherits the whole data map from the one it was created from, so the filing carries forward by default and only differs where somebody changed it.

This is why there is no foreign key behind an assignment: delete_category/2 has to unfile the category by hand (unfile_deleted_category/1).

The V159 join table predates this and is drained on first use by backfill_version_categories/1; nothing writes it any more.

Post-level helpers here (replace_post_categories/3, categories_of_post/1, category_uuids_for_post/1) mean "the live version, or the newest draft if it has never been published". Callers that hold a specific version should read category_uuids off the post map and resolve it with categories_by_uuids/2.

Context-layer rules the DB doesn't enforce

  • Same-group parents — a category's parent must belong to the same group.
  • No cycles — a category cannot become its own descendant; checked inside the update transaction so concurrent re-parents can't sneak a loop through.
  • Assignment scope — only categories of the post's own group are accepted; foreign uuids are dropped rather than raising.

Summary

Functions

Moves the legacy post-level assignments onto the versions, once per group.

A group's category by slug.

Full category structs for a set of uuids within a group, position-ordered.

Category uuids per post, from each post's ACTIVE version — %{post_uuid => [category_uuid]}.

Full category structs on a post's ACTIVE version, position-ordered.

Category uuids on a post's ACTIVE version.

Creates a category in a group. attrs may carry "name", "slug" (auto-derived from the name when blank), "parent_uuid", "description", "position", "name_i18n".

Deletes a category. The DB lifts its children to the root (ON DELETE SET NULL) and cascades the post assignments.

A category by uuid.

Flat category list for a group, ordered by position then name. Degrades to [] when the backing table is missing (host core < V159) — every public read path must survive the release-gated window.

All categories of a group, tree-ordered: roots by position/name, each followed by its (recursively ordered) children. Returns %PublishingCategory{} structs with a virtual-ish :depth in the returned tuples: [{category, depth}].

Re-parents a category, appending it at the END of the new sibling group (the catalogue move_folder convention) — keeping its old position would drop it mid-group unpredictably. Cycle/scope rules are update_category/3's.

Published-post counts per category of a group (for the admin tree and archive headers): %{category_uuid => count}. Counts posts with an active published version, not trashed.

Persists a drag-reorder. ordered_uuids is the client's DOM order of the whole flattened tree; rows are grouped by their EXISTING parent and renumbered within each sibling group — a sortable drop can only reorder siblings, never reparent (reparenting is update_category/3, which cycle-checks). Unknown/foreign uuids are ignored rather than erroring. Capped at 500 rows (client-misbehavior guard, same convention as the assignment cap). Returns {:ok, changed_count}.

Files a post's ACTIVE version under category_uuids (max 100 — a client-misbehavior guard, same convention as the reorder fns). Only categories belonging to the post's group are accepted; unknown/foreign uuids are silently dropped rather than erroring, so a stale selection can't fail the whole save.

The uuids of a category and all its descendants within a group — the WordPress archive rule (a parent category's archive includes posts filed under its children). One query for the group's categories, then an in-memory walk; cycle-safe via the seen set.

Updates a category. Re-parenting is cycle-checked inside a transaction — the new parent must be same-group and not the category itself or any of its descendants.

Functions

backfill_version_categories(group_slug)

Moves the legacy post-level assignments onto the versions, once per group.

Categories used to be a post-level join table, so every existing post's filing lives there and nowhere else. Versions are the source of truth now, and a version whose data has no category_uuids key is indistinguishable from one deliberately filed under nothing — so without this, upgrading silently unfiles the entire archive.

Copied onto EVERY version of the post, not just the active one, because that is what the old model meant: the assignment applied to whatever version you were looking at. Versions that already carry the key are left alone, so a real edit can never be overwritten.

The legacy rows are then dropped, which is what makes this self-limiting: no rows means nothing to move, so the steady state is a single cheap query rather than a scan that repeats forever. It also leaves the core-owned table empty, so whoever eventually drops it isn't deleting live data.

Returns the number of versions written.

by_slug(group_slug, category_slug)

A group's category by slug.

categories_by_uuids(group_slug, uuids)

Full category structs for a set of uuids within a group, position-ordered.

The lookup categories now go through: assignments live on the version, so a reader has uuids and needs rows. Foreign or deleted uuids simply don't come back — a version filed under a category somebody later deleted renders with one fewer chip instead of erroring.

categories_for_posts(post_uuids)

Category uuids per post, from each post's ACTIVE version — %{post_uuid => [category_uuid]}.

The listing cache no longer needs this (it has the version in hand while building each entry), but bulk callers that only hold post uuids still do.

categories_of_post(post_uuid)

Full category structs on a post's ACTIVE version, position-ordered.

For a specific version, read category_uuids off the post map you already have and resolve with categories_by_uuids/2 — the public post page does that, so ?v=2 shows how v2 was filed.

category_uuids_for_post(post_uuid)

Category uuids on a post's ACTIVE version.

create_category(group_slug, attrs, opts \\ [])

Creates a category in a group. attrs may carry "name", "slug" (auto-derived from the name when blank), "parent_uuid", "description", "position", "name_i18n".

delete_category(uuid, opts \\ [])

Deletes a category. The DB lifts its children to the root (ON DELETE SET NULL) and cascades the post assignments.

get_category(uuid)

A category by uuid.

list_categories(group_slug)

Flat category list for a group, ordered by position then name. Degrades to [] when the backing table is missing (host core < V159) — every public read path must survive the release-gated window.

list_tree(group_slug)

All categories of a group, tree-ordered: roots by position/name, each followed by its (recursively ordered) children. Returns %PublishingCategory{} structs with a virtual-ish :depth in the returned tuples: [{category, depth}].

move_category(uuid, new_parent_uuid, opts \\ [])

Re-parents a category, appending it at the END of the new sibling group (the catalogue move_folder convention) — keeping its old position would drop it mid-group unpredictably. Cycle/scope rules are update_category/3's.

published_post_counts(group_slug)

Published-post counts per category of a group (for the admin tree and archive headers): %{category_uuid => count}. Counts posts with an active published version, not trashed.

reorder_categories(group_slug, ordered_uuids, opts \\ [])

Persists a drag-reorder. ordered_uuids is the client's DOM order of the whole flattened tree; rows are grouped by their EXISTING parent and renumbered within each sibling group — a sortable drop can only reorder siblings, never reparent (reparenting is update_category/3, which cycle-checks). Unknown/foreign uuids are ignored rather than erroring. Capped at 500 rows (client-misbehavior guard, same convention as the assignment cap). Returns {:ok, changed_count}.

replace_post_categories(post_uuid, category_uuids, opts \\ [])

Files a post's ACTIVE version under category_uuids (max 100 — a client-misbehavior guard, same convention as the reorder fns). Only categories belonging to the post's group are accepted; unknown/foreign uuids are silently dropped rather than erroring, so a stale selection can't fail the whole save.

Assignments are version-level now, and this writes the version the public sees. The editor doesn't use it — a writer edits the version they have open, through the form — but it stays as the way to file a post when you have a post uuid and mean "the live one": seeds, imports, bulk tools.

subtree_uuids(group_slug, root_uuid)

The uuids of a category and all its descendants within a group — the WordPress archive rule (a parent category's archive includes posts filed under its children). One query for the group's categories, then an in-memory walk; cycle-safe via the seen set.

update_category(uuid, attrs, opts \\ [])

Updates a category. Re-parenting is cycle-checked inside a transaction — the new parent must be same-group and not the category itself or any of its descendants.