MishkaCmsMigration.MigrationRun (Mishka CMS Migration v0.0.1)

Copy Markdown View Source

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.MigrationItems, 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 (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 (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 (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 (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 (MishkaCmsMigration.Source.host/1).

Summary

Functions

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.

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

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.

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.

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.

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).

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.

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.

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

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

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

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.

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.

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

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

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

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.

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.

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.

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).

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.

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.

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

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

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.

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

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

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".

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).

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.

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.

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.

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.

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.

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.

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.

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.

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

The installed sources as {label, id} pairs.

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

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

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

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).

Types

t()

@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()
}

Functions

__guarded_change__(attrs, error? \\ false)

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__(name)

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(state)

@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(notification)

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(bool)

@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?(run)

@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(id, user)

@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(opts)

@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

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

input(opts, action)

@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(map)

@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(arg1)

@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(kind)

@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(kinds)

@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(kind)

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

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

kind_options()

@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(binary)

@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(state)

@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?(options, kind, source_id)

@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(arg1, site_id)

@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(language)

@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(arg1)

@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(atom)

@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(outcome, count)

@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(atom)

@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()

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

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

outcome_word(atom)

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

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

primary_key_matches?(left, right)

reason_label(reason, kind \\ nil)

@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(kind, page)

@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(assigns)

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(arg1, post)

@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(map, user)

@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?(error)

@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(run)

@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(arg1)

@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(arg1)

@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(state)

@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(arg1)

@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()

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

The installed sources as {label, id} pairs.

status_class(status)

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

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

status_label(atom)

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

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

status_options()

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

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

take_secret(changeset, context)

@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).