PhoenixKitProjects.MediaReorganizer (PhoenixKitProjects v0.26.0)

Copy Markdown View Source

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 :move action here never carries an after_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. Attachments never stages an upload before the project exists (unlike catalogue's catalogue-attachment-pending-*), so plan/2 never emits a :pending action.
  • Portal/PortalSubmission are not a separate resource. A submission's stored attachment is placed under <project folder>/Portal submissions/ (Portal.store_attachments/2 → Attachments.ensure_folder/2 for the project), never in a folder of its own — moving the project's folder carries that nested subfolder with it for free. Neither Portal nor PortalSubmission ever creates a top-level legacy folder, so they get no plan/2 entries 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):

  1. 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 FULL Project row — loaded for candidates only, in one batched where uuid in ^candidate_uuids query (R9 amended).
  2. Only for candidates, the parent hook runs once, called directly and guarded (T1: any answer is cast through Ecto.UUID.cast/1 and downcased — never a raw string forwarded into a later query) against raising/exiting/returning anything but {:ok, uuid} or an explicit nil (R2): a hook FAILURE skips the project (no move planned for it, never treated as "root") and is counted into one kind: :hook_error report for the whole plan; only an explicit nil means 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 resolves nil, find_resource_folder/2 only ever looks for a host name under a parent, so without one the desired name stays the deterministic project-<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 same kind: :hook_error count) — never a silent fallback to the deterministic name (unlike Attachments.folder_name/2, which the live UI relies on staying up even when a hook regresses).
  3. 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/2 falls back to for a live upload; this is also why a nil parent-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 aggregate kind: :hook_nil report for the whole plan — mirroring catalogue's apply_nil_root_guard/1 — rather than the generic :relocated a 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 reported kind: :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 by root_match/det_parent_match first — see relocated_reason/3).
  4. 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 as kind: :duplicate, no move for either.
  5. 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: :duplicate instead 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

plan(actor_uuid, opts \\ [])

@spec plan(String.t() | nil, keyword()) :: [map()]

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.