# `MishkaCmsMigration.MigrationRun`
[🔗](https://github.com/mishka-group/mishka_cms_migration/blob/v0.0.1/lib/mishka_cms_migration/resources/migration_run.ex#L5)

One move of a site's content from another CMS: which source it reads (`source`, a
`MishkaCmsMigration.Source` id), which host it reads from (`source_host`, so a site may take
content from several sources, each run showing its own), how it connects
(`connection`, and the secret sealed in `connection_secret`), what the admin chose (`options`)
and how far it got.

## The admin's choices

`options` is checked by the resource's own guardedstruct (`options` is a `sub_field`): a key or a
value it does not know is refused as an invalid attribute named by its key, under the path
`[:options]`. It is stored with every choice, what the admin chose and the default of each one
left out. A job turns the stored map, whose keys are strings, into
`MishkaCmsMigration.MigrationRun.Options`, the struct the `sub_field` generates, with its
`builder/1`, and hands it to every target:

  * `:authors` — `"connect"` (the default) ties each source author to a user of the site, matched
    by email, and skips one with no match; `"create"` makes a user for one with no match. Either
    way each becomes that user's blog author (`MishkaCmsMigration.Targets.BlogBlogger`).
  * `:pages` — `"draft"` (the default) brings pages in as drafts; `"publish"` puts them live at
    their old addresses.
  * `:post_address` — `"blog"` (the default) serves posts at `/blog/<slug>`, with a 301 redirect
    from each old address; `"blog_without_redirects"` serves them there with no redirect.
    Keeping a source's own address, such as WordPress's `/2025/12/hello/`, needs Mishka Blog to
    serve a post at an address of its own first (mishka_blog#6), so it is not a choice yet.
  * `:confirm_users` — whether the users the authors choice creates have their email confirmed;
    `true` by default. Either way no "Confirm your email" mail is sent; a user left unconfirmed
    is confirmed later from the Users table's bulk Confirm email.
  * `:comments` — whether comments come too; `false` by default. A comment by a user of the
    site, or by someone whose email is one, is written on its Blog post as theirs, and a
    guest's with the name and email the guest gave.

The authors and confirm users choices are read by the Blog's author target and the post addresses by the redirect
target (`MishkaCmsMigration.Targets.PostRedirect`), the pages choice by the page target
(`MishkaCmsMigration.Targets.CmsPage`) and the comments choice by the comment target
(`MishkaCmsMigration.Targets.BlogComment`).

## Its life

    :pending ─ start_dry_run ─▶ :dry_running ─▶ :dry_run ─ start_import ─▶ :importing ─▶ :done
                                      │                                          │
                                      └───────────────────▶ :failed ◀────────────┘

    start_dry_run again: :dry_run, :done or :failed ─▶ :dry_running
    cancel: any status but :cancelled ─▶ :cancelled

An admin creates a run (`:create`), asks for a dry run (`:start_dry_run`) and, once it has
reported, for the import (`:start_import`). A run whose dry run is ready, that is done or that
failed takes `:start_dry_run` again, Run again: a fresh dry run on the same run and connection,
then the usual import, which brings what the source changed since and what an admin decided, left
out or mapped since the last import. It empties how far the last job got (`progress`), when the
run ended (`finished_at`) and why it failed (`reason`), and the dry run, once it has walked the
whole source, empties the plan of each item of the run's source host it did not meet
(`MishkaCmsMigration.Workers.Helpers.Pipeline`); the run's `report` stays until the new dry run
replaces it, and the run's log keeps each step's counts.

Each request queues the job of one of the run's AshOban triggers, `:run_dry_run` or
`:run_import`, on the `migration` queue (`MishkaCmsMigration.Workers.DryRunWorker`,
`MishkaCmsMigration.Workers.ImportWorker`), as the admin who asked
(`MishkaCmsMigration.Resources.Changes.EnqueueJob`). A trigger has no
scheduler: its job is queued by the request alone, and runs only while the run is in the status
the trigger's `where` names. One run of a site is dry running or importing at a time: the
`:one_active_per_site` identity is a unique index on the site over those two statuses, so the
database refuses a second and nothing changes. A job tries three times, an hour each
(`MishkaCmsMigration.Workers`).

The job's action walks the source (`MishkaCmsMigration.Workers.Helpers.Pipeline`) and saves its
`report` (`MishkaCmsMigration.Report`) through `:finish_dry_run` or `:finish_import`, or, for a
job of picked items (see "Picked items"), `:finish_items`, and how far it is through
`:record_progress`, whose `progress` each request empties for its new job; after its last
attempt fails, AshOban runs `:fail`, the triggers' `on_error`, which keeps the reason. Those
seven are the system's own: every actor is refused them, and only what AshOban runs, and what
its job runs with its context, passes (`AshOban.Checks.AshObanInteraction`).

An admin ends a run with `:cancel`, which frees the site for another and clears its secret and
file: its job, waiting or running, is cancelled (`MishkaCmsMigration.Resources.Changes.CancelJobs`); a job that
already left the queue finds the run out of its trigger's `where` and is cancelled, or, already
walking, stops at its next page. The job's write and the record of each item share one
transaction, so a job stopped there leaves either both or neither. A job Oban stops on its last
attempt, at its timeout, never reaches `on_error` and fails its run through Oban's own telemetry
(`MishkaCmsMigration.Workers.attach/0`); `:cancel` is how an admin ends a run whose job was lost
with its node.

Each step is an entry of the run's log (`MishkaCmsMigration.MigrationLog`), and once a dry run or
an import ends — it finished, its job failed, or an admin cancelled it while it ran — the run's
user and the admin whose job it was are told in the topbar bell
(`MishkaCmsMigration.Resources.Notifiers.Steps`).

Running again, Run again on the same run or a new run of the same host, updates rather than
duplicates: the items earlier runs wrote (`MishkaCmsMigration.MigrationItem`) are what makes it so.
`created_count`, `updated_count`, `skipped_count`, `failed_count` and `undecided_count` count the
items this run's import was the last to touch; a dry run records only each item's plan, which
they do not count.

## Picked items

An admin may dry run or import some of a run's items alone, from the Items page: Dry run this
item, Import this item and Import the picked items (`:dry_run_items`, `:import_items`, with the
ids of the picked `MishkaCmsMigration.MigrationItem`s, at most 100). Either takes a run that is
waiting, ready, done or failed, moves it to `:dry_running` or `:importing` as its own steps do,
so the site's one running job and its refusal hold the same way, and queues the job of the same
trigger, with the items' source ids by kind and the status the run is in as its arguments
(`MishkaCmsMigration.Resources.Changes.PickedItems`). The job walks those items alone and
settles them as a whole run would, reading from the source only their rows
(`c:MishkaCmsMigration.Source.rows/3`) and what they need, such as a post's files; an item whose
parent, or what its record points at, was not imported waits, it never brings that in
(`MishkaCmsMigration.Workers.Helpers.Needs`). It then saves what it did into the run's report
(`MishkaCmsMigration.Report.merge/2`) and puts the run back in the status it was in
(`:finish_items`), and the items' rows refresh from their broadcasts. Neither is a step of the
run's log. A source that cannot answer for a picked kind, or cannot be connected to, fails those
picked items alone, with the reason, and the run still goes back where it was; only an error
saving the run fails a job of picked items, and its run, as the run's own job does.

## The source's secret

The secret a source signs in with (such as a WordPress application password) is the `:create`
action's `secret` argument. The New import form types it under the connection's `"secret"` key
(`c:MishkaCmsMigration.Source.form_fields/0`); `take_secret/2` moves it from there into the
argument before the run connects, so `connection` never holds it, and a `secret` argument a
caller gives as well wins. Only its sealed form is kept, in `connection_secret`, sealed the way
`MishkaCmsCore.Settings` seals a `:secret` setting: `Phoenix.Token.encrypt/4` with the endpoint's
`secret_key_base`, under a salt of this extension's own. The argument is dropped once sealed. The
sealed secret is kept from the run's creation until an admin cancels it, and is cleared then: a
run that is done or failed keeps it, so it can run again; a job opens it with `open_secret/1` each
time it connects to the source.

A run with no secret hands the source `nil`. A secret that is stored but cannot be opened —
`secret_key_base` was rotated since it was sealed — is `{:error, :unreadable_secret}`, which fails
the run: the admin starts a new run with the secret typed again.

## The source's file

A source that reads a file a site exported (`c:MishkaCmsMigration.Source.file?/0`, such as the
WordPress export file) is handed it by the `:create` action's `file` argument: the New import
form's upload, a file Gervaz staged (`MishkaCmsCore.Runtime.Media.staged_upload/2`, so a path a
request names is never taken). `MishkaCmsMigration.Resources.Changes.SourceFile` puts that staged
file's path in the connection's `"file"` while the run is checked, so the source can read the
export's host (`c:MishkaCmsMigration.Source.host/1`), and just before the run connects copies it
into its own folder (`MishkaCmsMigration.Resources.Changes.SourceFile.files_dir/0`) under a name
of its own and points `"file"` there; a run that is then not saved takes its copy with it. A
`"file"` a caller types in the connection is always dropped, so a run only ever names a file it
was handed.

That folder is outside the one the CMS serves (`MishkaCmsCore.Runtime.Media.static_dir/0`) and
readable by the CMS's own user alone, so the export, with its drafts, private posts and
commenters' addresses, is never public. The file is deleted once an admin cancels the run
(`:cancel`), after the run is saved so: a run that is done or failed keeps it, as it keeps its
secret, so it can run again.

## Who reaches it

A run is a site's own. Reading, creating, starting and cancelling one takes
`admin:dashboard:migration` (or `admin:api:migration` over the API). A master (a user whose
`site_id` is nil) reaches every site's runs; a site user only its own site's, whatever tenant it
names. A run is created on the tenant (`:create`), which a site user may only name as its own
site (`MishkaCmsCore.Runtime.Resources.Validations.TenantContext`), or by a master on the site it
names (`:master_create`); the site filter holds reads and steps, so a create is decided before
its source is asked anything.

## In the admin

The Migration page lists a site's runs, every site's for a master, as a Gervaz table laid out
like the Pages table: the source with its icon (`source_icon/1`) and the run's short id, the
address it is moved from with the source site's name under it, where the run is as a coloured pill
(`status_label/1`, `status_class/1`), its result (`result_cell/1`), for a master the site, and
when it started. Each column is as narrow as its cell can be drawn without cutting it off, an
address truncating with the whole of it as its tooltip, and a result wrapping onto more lines,
so the table fits beside the admin's sidebar on a laptop screen without scrolling sideways. It
filters by status, source, for a master the site, and the days a run started in, and keeps them
in the address. The table follows the run's PubSub notifications, so a run moving on, and a
running job's progress, show without a reload.

A row offers what its status allows: Dry run on a run that is Connected (`:pending`), Run again on
one whose dry run is ready (`:dry_run`), that is Imported (`:done`) or that Failed, both
`:start_dry_run`, Import on a ready run (`:start_import`, once the admin confirms), and Cancel
(`:cancel`, once the admin confirms) on any run that is not cancelled. They are `:event` row actions, run by the
page (`MishkaCmsMigrationWeb.Live.Admin.Dashboard.Home.Index`). Every row also has Items, a link
to the run's Items page (`items_path/1`, `MishkaCmsMigrationWeb.Live.Admin.Dashboard.Item.Index`).
They are drawn by the domain's Tailwind adapter as the Pages table draws its own: an icon, with
the action's name as its tooltip and accessible name. The Actions column is always as wide as
the most a row shows, four 30px squares 4px apart with 16px either side (`actions_layout`'s
`width`), so a row whose status changes its buttons never moves the other columns. A row
expands to what its last dry run or import found, kind by kind: the kind in words
(`kind_label/1`), how many items the source listed, what was done with them
(`result_summary/1`) and why the skipped ones were skipped (`reason_label/1`), and its latest
items, each with links to its CMS record (`record_paths/2`, `record_links/1`).

A source's card opens the run's Gervaz form with the card's source as the form's default. Its
Connection is a `:key_map` whose keys the source names
(`MishkaCmsMigration.Source.form_fields/1`), read each time the form is drawn and its params are
read, so each source's New import asks for its own: WordPress's address, user and application
password, and a plugin's source what its `form_fields/0` names. The secret's key is a masked
password input that never shows what was typed. The field's description says where WordPress
makes an application password, as a Gervaz key carries no description of its own. A source that
reads a file asks for no Connection: the form shows its Export file instead, a dropzone taking
one `.xml` file of up to 100 MB (see "The source's file"), the reason a file is refused under
it. The window is Gervaz's `:wizard` of four steps, run by
`MishkaCmsMigrationWeb.Live.Admin.Dashboard.ImportSteps` (Next checks a step, and Where from
tries the connection, before it moves on): Where from, then What to bring, a `:key_map` of
switches, all on but the comments (`bring_choices/1`, the Blog's each only while its target is
installed), then Languages, a select of the site's languages (`language_choices/1`), whose empty
choice keeps the source's languages, then the Review. Both are arguments of `:create`, folded
into the run's `options` with the Advanced options
(`MishkaCmsMigration.Resources.Changes.Choices`). The Advanced options, a collapsed group of the
Review, are Choices for this run, a `:key_map` over `options` with a select for the authors, the pages
and the post addresses, a toggle for confirming the imported users' email, on, and a toggle for
each of the site's own settings WordPress brings, the title, logo and icon, on; its description
says why a post's old address is redirected rather than kept. A kind or setting switched off is
left out as the admin's own leave-out (`left_out?/3`): the first run that meets its items records
them as Left out by you, and the Items page brings them back. `one_language` puts every item
with no language of the site's in that language (`one_language/2`). The authors, confirm users,
post addresses and comments are the Blog's, asked only while the Blog target each is for
is installed (`run_choices/1`). For
a master the form asks for the site the content moves to as well (`:master_create`). Its button,
Start the dry run, creates the run, which connects to the source first, or reads its file
(`MishkaCmsMigration.Resources.Changes.CheckConnection`): a source that does not answer or
refuses the user returns the window to Where from with the reason under the connection, and an
error flash naming the step, and nothing is saved. While it runs the button reads "Checking the
source…" (its `loading_label`). Gervaz takes a button's label as a string or a function of no
arguments, so one label fits a connection and a file alike. A run that is not given a `source_host` takes the host its source reads from the
connection (`c:MishkaCmsMigration.Source.host/1`).

# `t`

```elixir
@type t() :: %MishkaCmsMigration.MigrationRun{
  __lateral_join_source__: term(),
  __meta__: term(),
  __metadata__: term(),
  __order__: term(),
  aggregates: term(),
  calculations: term(),
  connection: term(),
  connection_secret: term(),
  created_count: term(),
  decisions: term(),
  extra: term(),
  failed_count: term(),
  finished_at: term(),
  id: term(),
  inserted_at: term(),
  items: term(),
  options: term(),
  progress: term(),
  reason: term(),
  report: term(),
  site: term(),
  site_id: term(),
  skipped_count: term(),
  source: term(),
  source_host: term(),
  status: term(),
  undecided_count: term(),
  updated_at: term(),
  updated_count: term(),
  user: term(),
  user_id: term()
}
```

# `__guarded_change__`

Apply the full GuardedStruct pipeline (sanitize → validate → derive →
main_validator) to `attrs` and return either `{:ok, transformed_attrs}`
or `{:error, errors}`. Wire this into an `Ash.Resource.Change` to plug
guardedstruct rules into Ash's changeset pipeline.

The function is named `__guarded_change__` because it does more than
validate — it can also transform values (trim, downcase, slugify,
auto-fill, etc.) before they reach the data layer.

# `__guarded_field_meta__`

O(1) lookup of a field's compile-time metadata by name. Returns
`nil` for unknown fields.

# `__guarded_field_name_set__`

Compile-time-baked `MapSet` of every field name owned by the
`guardedstruct` block. Used by `GuardedStruct.AshResource.Change`
to decide, in O(1), whether a key in `changeset.atomics`
belongs to the pipeline.

# `bring_choices`

```elixir
@spec bring_choices(map()) :: [keyword()]
```

The New import window's "What to bring" switches: posts, pages, pictures and files, categories
and tags, authors and users, menus and comments, each only while the target it is for is
installed (`MishkaCmsMigration.Target.fetch/1`), as `run_choices/1` offers its own.

# `broadcast`

```elixir
@spec broadcast(Ash.Notifier.Notification.t()) :: Ash.Notifier.Notification.t()
```

What the run's PubSub notifications carry (`pub_sub`'s `transform`): the notification with the
run's `connection` and `connection_secret` emptied and without its changeset, whose input held
the secret as typed. A subscriber that needs the connection reads the run.

# `cancelled_words`

```elixir
@spec cancelled_words(boolean()) :: String.t()
```

What a cancelled run's alert says: that `:cancel` deleted what the run connected with, the export
file when `file_source?` (`MishkaCmsMigration.Source.file?/1`), else the WordPress password, and
that a New import brings the content again. The Items page's table shows it only for a cancelled
run (`MigrationItem`'s `:cancelled_run` notice).

# `connectable?`

```elixir
@spec connectable?(map()) :: boolean()
```

Whether `run` still holds what its source connects with, so a job can connect again: the file it
was handed, still in its folder, for a source that reads one (`MishkaCmsMigration.Source.file?/1`),
and otherwise its sealed secret when its source's form asks for one as required
(`MishkaCmsMigration.Source.form_fields/1`). A run an earlier release ended, which cleared its
secret and deleted its file, and a cancelled one hold neither: Run again and the jobs of picked
items are neither offered nor taken on them
(`MishkaCmsMigration.Resources.Validations.Connectable`), and a New import asks for them again.
Read from the run alone, with no query; a file is looked for on disk.

# `default_short_name`

# `fetch`

```elixir
@spec fetch(String.t(), map()) :: {:ok, struct() | nil} | {:error, term()}
```

The run `id` as `user` may read it: a master's on any site, a site admin's on their own site
alone. `{:ok, nil}` for a run that is not there or that `user` may not read.

# `input`

```elixir
@spec input(values :: map() | Keyword.t()) :: map() | no_return()
```

Validates that the keys in the provided input are valid for at least one action on the resource.

Raises a KeyError error at compile time if not. This exists because generally a struct should only ever
be created by Ash as a result of a successful action. You should not be creating records manually in code,
e.g `%MyResource{value: 1, value: 2}`. Generally that is fine, but often with embedded resources it is nice
to be able to validate the keys that are being provided, e.g

```elixir
Resource
|> Ash.Changeset.for_create(:create, %{embedded: EmbeddedResource.input(foo: 1, bar: 2)})
|> Ash.create()
```

# `input`

```elixir
@spec input(values :: map() | Keyword.t(), action :: atom()) :: map() | no_return()
```

Same as `input/1`, except restricts the keys to values accepted by the action provided.

# `items_path`

```elixir
@spec items_path(%{id: String.t()}) :: String.t()
```

The admin address of the page that lists the items of `run`'s source on its site.

# `kind_icon`

```elixir
@spec kind_icon(String.t()) :: String.t()
```

The heroicon that stands for the items of `kind` in the admin's lists, such as
`"hero-document-text"` for `"post"`; a kind this extension does not name is a cube.

# `kind_label`

```elixir
@spec kind_label(String.t()) :: String.t()
```

The items of `kind` as the admin reads them, such as `"Posts"` for `"post"`. A kind this
extension does not name, one a source plugin of its own lists, reads as itself, capitalized.

# `kind_nouns`

```elixir
@spec kind_nouns([String.t()]) :: String.t()
```

`kinds` named together as a sentence says them, such as "pages, posts and tags", in the reader's
language (`MishkaCmsCore.Runtime.Languages.join/1`).

# `kind_one`

```elixir
@spec kind_one(String.t()) :: String.t()
```

One item of `kind`, as a sentence names it, such as page or category.

# `kind_options`

```elixir
@spec kind_options() :: [{String.t(), String.t()}]
```

Every kind the installed sources list, as `{label, kind}` pairs in the order the sources walk
them (`kind_label/1`).

# `language_cause`

```elixir
@spec language_cause(String.t()) :: String.t()
```

What makes the links an import writes differ from the source's addresses
(`MishkaCmsMigration.Targets.Helpers.LanguageUrls.cause/2`), in words: a language plugin of the
source, or the site giving every language a prefix.

# `language_choices`

```elixir
@spec language_choices(map()) :: [{String.t(), String.t()}]
```

The New import window's Languages select: the languages of the site the run is for, its main
language first (`MishkaCmsCore.Runtime.Languages.site/1`), each as `{name, code}`. Its empty
choice keeps the source's languages. The site is the signed-in admin's, or, for a master, the one
picked in the form. It reads the site's languages from the runtime settings, no query.

# `left_out?`

```elixir
@spec left_out?(struct(), String.t(), String.t()) :: boolean()
```

Whether the run's choices (`options`, as `MishkaCmsMigration.MigrationRun.Options`) leave the item
of `kind` and `source_id` out: the kinds switched off in the New import window (`bring_users`,
`bring_terms`, `bring_media`, `bring_pages`, `bring_posts`, `bring_menus`) and the site settings
switched off in its Advanced options (`site_title`, `site_logo`, `site_icon`). The Workers decide
it for an item no run has met; the admin's own leave-out and Bring back stay what they chose.

# `one_language`

```elixir
@spec one_language(struct() | map() | nil, String.t() | nil) :: String.t() | nil
```

The language code the run puts every item of an unknown language, or of one the site does not
publish in, in (`one_language` of its options), or `nil` when the run keeps the source's
languages or the code is not one of the site's (`MishkaCmsCore.Runtime.Languages.site/1`).

# `one_language_words`

```elixir
@spec one_language_words(String.t()) :: String.t()
```

What the Items page says in one line for a run that put every item of an unknown or unpublished
language in `language` (`one_language/2`): the items it covers keep that language, and they can
be translated or edited later.

# `open_secret`

```elixir
@spec open_secret(map()) :: {:ok, String.t() | nil} | {:error, :unreadable_secret}
```

The source's secret `run` keeps, opened: `{:ok, secret}`, `{:ok, nil}` when it keeps none, or
`{:error, :unreadable_secret}` when it was sealed under another `secret_key_base`.

# `outcome_class`

```elixir
@spec outcome_class(atom()) :: String.t()
```

The pill colours of an outcome's count: green created, blue updated, grey skipped, red failed,
amber waiting for a decision.

# `outcome_count`

```elixir
@spec outcome_count(atom(), non_neg_integer()) :: String.t()
```

One outcome's count in words, as a Result pill's tooltip begins: `"3 created"`, or
`"2 need your decision"`.

# `outcome_icon`

```elixir
@spec outcome_icon(atom()) :: String.t()
```

The heroicon of an outcome's pill: a plus created, arrows updated, a minus skipped, a warning
failed, a raised hand waiting for a decision.

# `outcome_options`

```elixir
@spec outcome_options() :: [{String.t(), atom()}]
```

Every outcome as a `{word, outcome}` pair, in the order the report counts them.

# `outcome_word`

```elixir
@spec outcome_word(atom()) :: String.t()
```

An outcome as its pill reads it, such as Created or Failed.

# `primary_key_matches?`

# `reason_label`

```elixir
@spec reason_label(String.t(), String.t() | nil) :: String.t()
```

Why an item was skipped or failed, or what was written otherwise than the source has it (a
`MishkaCmsMigration.Report` entry's `reason`), as the admin reads it, each of its parts (joined by
`"; "`) in words, as one sentence: a part after the first starts in lower case, unless it starts
with an abbreviation such as URL. A plugin's own reason, and what a source or the CMS answered,
read as they are.
Given the item's `kind`, a reason about the item itself names it: `"deleted_in_cms"` reads "This
page was deleted in Mishka CMS after it was imported", and without a kind, as a run's summary of
items of every kind reads it, "Deleted in Mishka CMS after it was imported".

# `record_address`

```elixir
@spec record_address(String.t(), struct()) :: String.t() | nil
```

Where the site serves the CMS record an item of `kind` was written to, whether it is live yet or
not, as the site's own address (`MishkaCmsCore.Runtime.Languages.url/3`): a page at its path, and
a post, category, tag or blogger at its page of the Blog (`MishkaBlog.Provision.item_paths/0`), in
the record's language, or the site's first for one without. `nil` for a kind with no page of its
own. What a menu's links point at (`MishkaCmsMigration.Targets.CmsLayout`).

# `record_links`

The links to the CMS record `paths` (`record_paths/2`) names: an icon link to its place in the
admin, and one to its page on the site where it has one. Nothing for `nil`.

# `record_paths`

```elixir
@spec record_paths(String.t(), struct()) ::
  %{admin: String.t(), public: String.t() | nil} | nil
```

Where the CMS record an item of `kind` was written to is, given `record` as its target's resource
reads it: `%{admin: path, public: path | nil}`, or `nil` for a kind this extension names no pages
for, such as one a source plugin lists.

The admin has no address for one record: its pages edit in a modal over a list. `admin` is the
record's list opened on the record, the way the list's own search writes its address
(`MishkaGervaz.Table.Web.UrlSync`): a post, category, tag or blogger by its slug, a page by its
path, a file by its name, a redirect by its old address, a comment on the list of its post's
comments, a setting on the Settings page's tab of its category, a stylesheet on the Stylesheets
page, and a menu on the layout it is the header's links of. `public` is the record's page on the
site where it is live (`record_address/2`): a published post, an active category, a tag, a
blogger's page, an active page and a file; a comment, a redirect, a setting, a stylesheet and a
menu have none.

# `refresh_decisions`

```elixir
@spec refresh_decisions(map(), map()) :: :ok
```

Tells the runs of `run`'s source host on its site, as `user`, that the decisions on their items
changed (`:refresh_decisions`): one `UPDATE` of those runs, whose broadcast makes the Runs table
draw their Result chips again, with what waits for a decision and what was decided now
(`decisions` calculation). `:ok` also when no run could be told.

# `refused_while_running?`

```elixir
@spec refused_while_running?(term()) :: boolean()
```

Whether `error`, what a step of a run or an admin's choice on an item answered, refuses it only
because a dry run or an import of the site is running now: the run's own
(`MishkaCmsMigration.Resources.Validations.Status.running?/1`), another run of its site
(`:one_active_per_site`), or any while an item is changed
(`MishkaCmsMigration.Resources.Validations.SiteIdle.running?/1`). Nothing is wrong with what
was asked, and the same request holds once that job is done, so the Items page says it as a
notice, not as a failure. An error that also holds anything else is not.

# `result_cell`

```elixir
@spec result_cell(map()) :: Phoenix.LiveView.Rendered.t()
```

The Runs table's Result cell for `run`, drawn in the width its column gives it: its pills and
chips wrap onto more lines rather than widen the column.

  * While the run is dry running or importing, how far its job is (`progress`): the kind it
    walks, with its icon, the items met so far, of how many the run's last report
    counted when it has one, and a thin bar of that share. Without a report the bar fills by the
    kinds of the source walked so far. Before the job's first page it reads Starting.
  * Otherwise, what its last report counted: a pill for each outcome counted more than none of,
    in `MishkaCmsMigration.Report.outcomes/0` order (green created, blue updated, grey skipped,
    red failed, amber waiting for a decision), each an icon and its count, its tooltip the count
    in words and, for skipped, failed or undecided items, the reason most of them share
    (`reason_label/1`). Under them a grey chip for
    each kind it met, the kind's icon (`kind_icon/1`) and how many the source listed, its
    tooltip the kind in words (`kind_label/1`). "Nothing to move" when it counted nothing, and "—" before the first
    dry run.

The waiting pill counts what waits for a decision now (`decisions`), not what the dry run
counted, so it reads 0 once the admin decided everything; a Decided chip (`hero-check`) beside it
counts the items whose waiting decision the admin answered, and is not drawn while there are none.

The data the page's tests read: `data-role="run-result"` on the cell, `"run-progress"` (with
`data-kind`, `data-done` and `data-total`), `"run-outcome"` (`data-outcome`, `data-count` and
`data-reason`, the shared reason as the report keeps it), `"run-decided"` (`data-count`) and
`"run-kind"` (`data-kind`, `data-count`). The bar's width is one of the admin sheet's `w-[N%]` classes (0 to 100), so it
needs no `style` attribute.

# `result_outcomes`

```elixir
@spec result_outcomes(map() | nil) :: [{atom(), pos_integer()}]
```

The outcomes a run's `report` counts more than none of, in `MishkaCmsMigration.Report.outcomes/0`
order, each with its count: `[created: 3, skipped: 2]`. Empty before the first dry run, and for a
report whose source listed nothing.

# `result_summary`

```elixir
@spec result_summary(map() | nil) :: String.t()
```

What a run's `report` counts, in one line:
`"3 created · 1 updated · 2 skipped · 0 failed · 0 need your decision"`, or `"—"` before its
first dry run.

# `run_choices`

```elixir
@spec run_choices(map()) :: [keyword()]
```

The New import form's choices for the run (Choices for this run's keys): the authors, pages and
post addresses selects and the confirm users and comments toggles, each Blog one only while the
Blog target it is for is installed (`MishkaCmsMigration.Target.fetch/1`): Authors and Confirm
imported users' email for `"user"`, Post addresses for
`"post_redirect"` and the comments for `"comment"`. A node without the Blog asks for the pages
alone.

# `source_icon`

```elixir
@spec source_icon(String.t()) :: String.t()
```

The heroicon that stands for the source whose id is `id` in the admin's lists.

# `source_options`

```elixir
@spec source_options() :: [{String.t(), String.t()}]
```

The installed sources as `{label, id}` pairs.

# `status_class`

```elixir
@spec status_class(atom()) :: String.t()
```

The pill colours of a run with `status`; its dot takes the text colour.

# `status_label`

```elixir
@spec status_label(atom()) :: String.t()
```

Where a run with `status` is, as the admin reads it.

# `status_options`

```elixir
@spec status_options() :: [{String.t(), atom()}]
```

Every status as a `{label, status}` pair, in the order a run goes through them.

# `take_secret`

```elixir
@spec take_secret(Ash.Changeset.t(), map()) :: Ash.Changeset.t()
```

Takes the secret out of a new run's `connection`: what a form typed under its `"secret"` key (or
`:secret`, from a caller of its own) becomes the action's `secret` argument, which
`seal_secret/2` keeps only sealed, and the key is dropped, so `connection` never holds the
secret. A `secret` argument the caller gave wins over the key; a blank key is dropped alone.
`:create` and `:master_create` run it before they connect
(`MishkaCmsMigration.Resources.Changes.CheckConnection`).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
