Projects' media-reorganizer plan source.
Not compiled against core's PhoenixKit.Modules.Storage.Reorganizer.Source
behaviour — the engine ships in core 2.24.0, but the ~> 2.0 pin still
admits older cores that lack it. This module declares no @behaviour and
returns plain maps; see PhoenixKitProjects.media_reorganizer/0 for the
registration comment. plan/2's contract (plan(actor_uuid, opts) :: [map()]) already matches Source.plan/2 — once the documented core floor
reaches 2.24.0 the only follow-up is adding @behaviour/@impl.
Covers Project only — its legacy project-<uuid> folder and the
:attachments_parent_folder / :attachments_folder_name hooks
(PhoenixKitProjects.Attachments). Three things this module does NOT
cover, deliberately:
- No pointer to back-fill. Unlike catalogue, a project carries no
cached folder uuid (no
data/JSONB column for it) — its folder is always resolved by name, on every read (Attachments.find_resource_folder/2). So a:moveaction here never carries anafter_move, and a taken destination name is:reported rather than:suffixed — renaming the winner would strand this module's own lookup, which never searches for a suffixed name. - No pending-upload-folder prefix.
Attachmentsnever stages an upload before the project exists (unlike catalogue'scatalogue-attachment-pending-*), soplan/2never emits a:pendingaction. Portal/PortalSubmissionare not a separate resource. A submission's stored attachment is placed under<project folder>/Portal submissions/(Portal.store_attachments/2→Attachments.ensure_folder/2for the project), never in a folder of its own — moving the project's folder carries that nested subfolder with it for free. NeitherPortalnorPortalSubmissionever creates a top-level legacy folder, so they get noplan/2entries of their own.
A host that has not configured :attachments_parent_folder is left
entirely untouched: plan/2's move-planning half runs, and the hooks
are called, only when the env is set to a {mod, fun} that is actually
callable (T3/U7/V3 — a configured but uncallable hook, parent OR name,
e.g. a typo, is a distinct kind: :hook_error failure, never silently
"no hook") — never a single move or report for a legacy folder sitting
somewhere other than root (see "Move planning"). The orphan scan is
independent of the hook and always runs (root-only when no parent is
resolved); it is a :report, so it is produced with or without a
configured hook (E1 — a source with no working hook still emits
report-only housekeeping, never a :move/:trash). U4: the orphan
scan's parent scope is every parent a candidate's parent hook
successfully resolved, whatever that candidate's own outcome — even one
later dropped for a failing name hook still contributes its parent.
Move planning
For each live project (D4 — archived is live; only a hard-deleted project, i.e. no matching row at all, makes an orphan):
- A project is a candidate when it has a live folder anywhere named
after its legacy deterministic name (
project-<uuid>, resolved without calling any hook — one batched query for the whole plan). A project with nothing named after it is left alone: nothing exists to move, and the host's hooks are never called for it. Candidate detection is light-selected (only folder names, one query), but the record reaching a host hook is always the FULLProjectrow — loaded for candidates only, in one batchedwhere uuid in ^candidate_uuidsquery (R9 amended). - Only for candidates, the parent hook runs once, called directly and
guarded (T1: any answer is cast through
Ecto.UUID.cast/1and downcased — never a raw string forwarded into a later query) against raising/exiting/returning anything but{:ok, uuid}or an explicitnil(R2): a hook FAILURE skips the project (no move planned for it, never treated as "root") and is counted into onekind: :hook_errorreport for the whole plan; only an explicitnilmeans root. The name hook (:attachments_folder_name) is called directly too — but ONLY when the parent hook resolved a real parent (R8/T9): when it resolvesnil,find_resource_folder/2only ever looks for a host name under a parent, so without one the desired name stays the deterministicproject-<uuid>name and the (possibly writing or expensive) name hook is skipped entirely for that project. Called, a failing name hook is a hook FAILURE too (F3, folded into the samekind: :hook_errorcount) — never a silent fallback to the deterministic name (unlikeAttachments.folder_name/2, which the live UI relies on staying up even when a hook regresses). - The candidate's current folder is looked up, in the module's own
order, only at the resolved parent (host name, then deterministic
name) and at root (deterministic name) — never the unrestricted
"anywhere" scan
find_resource_folder/2falls back to for a live upload; this is also why anilparent-hook answer can never move a folder that actually lives under a real parent to root (F1) — that tier is simply never searched when the resolved parent is root. Instead, when exactly one live legacy-named folder exists anywhere and it sits under a real parent, it is recognized as the project's current folder, left untouched (no move, no rename), and counted once into an aggregatekind: :hook_nilreport for the whole plan — mirroring catalogue'sapply_nil_root_guard/1— rather than the generic:relocateda stray copy would get. Every OTHER legacy-named folder that exists live somewhere other than the project's resolved current folder (the owner moved it, it is parked under a parent the project was since unlinked from, or it is a leftover twin) is reportedkind: :relocated, never moved — one report per copy, all of them (F5) — the parent hook can be actor-dependent, so the reason notes that a different actor's hook may still resolve it (E6), and it also names the copy's actual place (U3) — at the media root, already a twin under the target parent, or under a named third-party parent (the only one of the three this module's restricted lookup can actually produce, since a copy at root or under the target is always caught byroot_match/det_parent_matchfirst — seerelocated_reason/3). - A live match at more than one of these tiers (host-named-under-parent,
deterministic-named-under-parent, deterministic-named-at-root) is
unresolvable — reported as
kind: :duplicate, nothing moved, naming every folder found. Two (or more) projects whose current folder resolves to the very same live folder (e.g. two projects sharing a parent and a colliding host name) are likewise reported askind: :duplicate, no move for either. - Two (or more) projects whose desired destination coincides (the
same resolved parent and the same desired name) even though their
current folders differ are reported
kind: :duplicateinstead of both being planned as moves — the second move would collide with the first at apply time (R7).
Summary
Functions
Builds the projects' reorganizer plan: one :move action per project
whose current folder does not already match its hooks, :report
actions (kind: :duplicate / kind: :relocated) for folders that
cannot be unambiguously resolved or moved, and a :report
(kind: :orphan) per legacy project-<uuid> folder whose project no
longer exists.
Functions
Builds the projects' reorganizer plan: one :move action per project
whose current folder does not already match its hooks, :report
actions (kind: :duplicate / kind: :relocated) for folders that
cannot be unambiguously resolved or moved, and a :report
(kind: :orphan) per legacy project-<uuid> folder whose project no
longer exists.
opts is accepted for interface parity with other Sources (e.g. the
shared pending_days convention) but unused — this module has no
pending-folder rule.