MishkaCmsMigration.Source behaviour (Mishka CMS Migration v0.0.1)

Copy Markdown View Source

A CMS a site is moved from: one module that is a mishka_installer plugin on the "migration:sources" event and implements the callbacks below. WordPress is the first (MishkaCmsMigration.Sources.WordPress); Joomla and others come the same way and reuse the mapping, the dry run, the import job and the report.

defmodule MyExtension.Sources.Ghost do
  use MishkaCmsMigration.Source, id: "ghost", label: "Ghost"
  use Gettext, backend: MishkaCmsCore.Gettext

  @impl true
  def kinds, do: ["user", "tag", "post"]

  @impl true
  def form_fields,
    do: [
      [
        name: :url,
        label: dgettext_noop("mishka_gervaz", "Address"),
        placeholder: "https://example.com",
        required: true
      ],
      [
        name: :secret,
        type: :password,
        label: dgettext_noop("mishka_gervaz", "Admin API key"),
        required: true
      ]
    ]

  @impl true
  def connect(%{"url" => url}, key), do: {:ok, Req.new(base_url: url, params: [key: key])}

  @impl true
  def list(req, kind, cursor) do
    ...
    {:ok, rows, next_cursor_or_nil}
  end

  @impl true
  def normalize(row, "post", _context),
    do: {:ok, %{source_id: row["id"], title: row["title"], content: [%{html: row["html"]}]}}
end

use makes the module a plugin on "migration:sources" — registering is the declaration; the event is never fired, and the plugin's call/1 hands its state back — and adds id/0, label/0, an extra/1 that answers %{}, which a source overrides to keep facts about itself, a host/1 that reads the connection's "url", a form_fields/0 that asks for an address and a secret, which a source overrides to ask for what it connects with, and a file?/0 that answers false, which a source that reads a file overrides. A source registers once it is supervised: an extension that ships one lists the module among the children of its own application's supervisor, as MishkaCmsMigration.Application.plugins/0 lists this extension's. Which sources are installed is read from mishka_installer's own registry (installed/2), so a source an operator stops in the installer is no longer offered and a run of it is refused.

A run

A run names its source by id/0 (MishkaCmsMigration.MigrationRun's source); a run of a source that is not installed is refused when it is created. A job (MishkaCmsMigration.Workers.Helpers.Pipeline) asks connect/2 once, with the run's connection map and its secret opened (MishkaCmsMigration.MigrationRun.open_secret/1), then walks kinds/0 in the order the source gives them, and pages through each with list/3 until it answers no cursor. The walk does not sort the kinds: a source lists them in the order they must be imported, a kind before any kind that points at it, so authors come before the posts that name them. Each listed row goes through normalize/3, which is handed the connection connect/2 answered to read more of the source when a row needs it (context/0), and then MishkaCmsMigration.Source.Item.build/1. An error from connect/2 or list/3 fails the run; an error from normalize/3 fails only that item, and the report says why.

A job of picked items, Dry run this item or Import this item on the Items page, walks only the kinds of those items and reads only their rows: rows/3 with their source ids, where the source has it, and otherwise every page of their kind from list/3, keeping the picked ones once they are normalized. rows/3 is optional; a source that reads a site over the network should have it, so one item never costs a walk of its whole kind.

Its connection form

The Migration page's New import opens one form for every source, and the form asks for what form_fields/0 names: the keys of the run's connection, as data, each the shape a Gervaz :key_map key takes (MishkaGervaz.Form.Types.Field.KeyMap):

  • :name — the key the typed value is kept under in connection, as a string;
  • :type — :text (the default), :password, :number, :select or another type a :key_map key takes;
  • :label and :placeholder — msgids of the mishka_gervaz gettext domain, marked with dgettext_noop("mishka_gervaz", "..."), which the form translates as it draws them;
  • :required — true marks the key's label as a required field's label is marked.

The key named :secret is the secret the source signs in with. Give it type: :password: it is drawn masked and never shown again, and creating the run takes it out of connection and keeps it only sealed (MishkaCmsMigration.MigrationRun.take_secret/2), so connect/2 gets it as its secret. Every other key, a :password one too, is kept in connection as typed, so a source has one secret, under :secret. A key left blank is not saved.

use asks for an Address (:url, required, https://example.com as its placeholder) and a Password or key (:secret, a required :password key). form_fields/1 reads the keys of the source a form was opened for.

A source that reads a file

A source that reads a file a site exported, rather than the site itself, answers true to file?/0 (MishkaCmsMigration.Sources.WordPressFile does) and names no keys in form_fields/0. The New import form then asks for the file instead of a Connection, and the run keeps it in a private folder until it ends (see "The source's file" in MishkaCmsMigration.MigrationRun): host/1 and connect/2 get its path as the connection's "file", and a refusal is shown on the form's file. file?/1 tells whether the source a form was opened for is one. use answers false.

Summary

Types

What a source keeps between calls once connected: a client, a parsed file, a cursor.

What normalize/3 is told: the run's site, its source host and its options, and the connection connect/2 answered, for a row that names more of the source to read, such as the reusable blocks and gallery images a WordPress post names.

Where list/3 goes on from: nil is the first page, and answering nil ends a kind.

One key of a source's connection form: :name, :type, :label, :placeholder and :required, as a Gervaz :key_map key takes them (see "Its connection form").

Callbacks

Connects with a run's connection map and its opened secret (nil when it has none).

Facts about the source a run keeps in its extra (MishkaCmsMigration.MigrationRun), such as a WordPress site's version and plugins. The job asks right after connect/2, before it walks a kind, and the run keeps the answer when it finishes. use answers %{}.

Whether the source reads a file the admin uploads, handed to it as the connection's "file", rather than a site it connects to (see "A source that reads a file"). use answers false.

The keys the New import form asks for, in the order it draws them: what connect/2 reads from a run's connection, and its secret under :secret (see "Its connection form"). use asks for an address and a secret.

The host a run's connection points at, kept as the run's source_host when the run does not name one: with the source id and an item's id it is how a re-run finds what an earlier run wrote. use answers the host of the connection's "url", or nil without one.

The source's id, stored on a run.

The kinds of item this source lists, in the order they are imported: a kind before any kind that points at it.

The source's name, as a gettext_noop/1 msgid.

One page of kind from cursor, and the cursor of the next page (nil after the last).

A listed row as the map MishkaCmsMigration.Source.Item.build/1 takes, without :kind, which the walk adds.

The rows of kind whose source ids are source_ids, as list/3 answers them, read without listing the whole kind: what a job of picked items reads (see "A run"). A row it answers that was not asked for is left out once it is normalized, so a kind the source reads in one go, such as WordPress's menus, may answer it whole. Optional.

Functions

Every installed source (installed/2), by priority, the first of each id: what the Migration page offers to migrate from, and what a developer reads to see which sources a node has.

Connects with source (connect/2) and answers what it answers: how the New import form's check and each job connect. A refusal is also told on [:mishka_cms_migration, :connect, :refused], with metadata, the source's id and the reason in words (see "Telemetry" in MishkaCmsMigration.Workers).

The event a source plugin registers on, the one use MishkaCmsMigration.Source names: what an extension's own tests register a source for.

The installed source whose id is id.

Whether the installed source a form names (defaults: %{source: id}), or the source whose id is given, reads a file (file?/0): what shows the New import form's file. false for a form opened for no source and for a source that is not installed.

The connection keys (form_fields/0) of the installed source a form names, or of the source whose id is given: what the run's form draws under Connection. A form names its source in its defaults, as the Migration page opens it (defaults: %{source: id}). Empty for a form opened for no source and for a source that is not installed.

The plugins of a migration event that are installed now, read from mishka_installer's own registry (MishkaInstaller.Event.Event.get/2): those an operator has not stopped and that are not held (:started or :restarted), by priority, that are loaded and implement every required callback of behaviour, the first of each id. Nothing is fired and nothing is cached. Targets are found the same way (MishkaCmsMigration.Target.fetch/1).

The one plugin of installed/2 whose id/0 is id; {:error, :not_installed} for none.

The name to show for the source whose id is id: its label in the reader's language, or the id itself for a source that is no longer installed.

The candidates of an srcset attribute, each as {url, descriptor} ({"hero-300.jpg", "300w"}; the descriptor is "" for a candidate with none), in the order written. Candidates are told apart by a comma followed by white space, or by one that follows a width or density descriptor.

The srcset attribute candidates (srcset/1) written back as the attribute's value.

The host of a connection's "url", lower-cased and with its port when it is not the scheme's own (localhost:8089), or nil when it has no address with a host: what use answers as host/1.

Types

conn()

@type conn() :: term()

What a source keeps between calls once connected: a client, a parsed file, a cursor.

context()

@type context() :: %{
  site_id: String.t(),
  run_id: String.t(),
  source_host: String.t(),
  options: map(),
  conn: conn()
}

What normalize/3 is told: the run's site, its source host and its options, and the connection connect/2 answered, for a row that names more of the source to read, such as the reusable blocks and gallery images a WordPress post names.

cursor()

@type cursor() :: term() | nil

Where list/3 goes on from: nil is the first page, and answering nil ends a kind.

form_field()

@type form_field() :: keyword()

One key of a source's connection form: :name, :type, :label, :placeholder and :required, as a Gervaz :key_map key takes them (see "Its connection form").

Callbacks

connect(connection, secret)

@callback connect(connection :: map(), secret :: String.t() | nil) ::
  {:ok, conn()} | {:error, term()}

Connects with a run's connection map and its opened secret (nil when it has none).

It is asked when a run is created, so a run is only kept for a source it can reach (MishkaCmsMigration.Resources.Changes.CheckConnection), and again by each job. A refusal is shown to the admin on the run's connection; these reasons are put in words, and any other is shown as it is:

  • :invalid_address — the connection does not name an address the source can use;
  • :unreachable — nothing answered at the address;
  • :no_site — something answered, but not a site of this source;
  • :unauthorized — the site refused the user or the secret.

extra(conn)

@callback extra(conn()) :: map()

Facts about the source a run keeps in its extra (MishkaCmsMigration.MigrationRun), such as a WordPress site's version and plugins. The job asks right after connect/2, before it walks a kind, and the run keeps the answer when it finishes. use answers %{}.

file?()

@callback file?() :: boolean()

Whether the source reads a file the admin uploads, handed to it as the connection's "file", rather than a site it connects to (see "A source that reads a file"). use answers false.

form_fields()

@callback form_fields() :: [form_field()]

The keys the New import form asks for, in the order it draws them: what connect/2 reads from a run's connection, and its secret under :secret (see "Its connection form"). use asks for an address and a secret.

host(connection)

@callback host(connection :: map()) :: String.t() | nil

The host a run's connection points at, kept as the run's source_host when the run does not name one: with the source id and an item's id it is how a re-run finds what an earlier run wrote. use answers the host of the connection's "url", or nil without one.

id()

@callback id() :: String.t()

The source's id, stored on a run.

kinds()

@callback kinds() :: [String.t()]

The kinds of item this source lists, in the order they are imported: a kind before any kind that points at it.

label()

@callback label() :: String.t()

The source's name, as a gettext_noop/1 msgid.

list(conn, kind, cursor)

@callback list(conn(), kind :: String.t(), cursor()) ::
  {:ok, [term()], cursor()} | {:error, term()}

One page of kind from cursor, and the cursor of the next page (nil after the last).

normalize(row, kind, context)

@callback normalize(row :: term(), kind :: String.t(), context()) ::
  {:ok, map()} | {:error, term()}

A listed row as the map MishkaCmsMigration.Source.Item.build/1 takes, without :kind, which the walk adds.

rows(conn, kind, source_ids)

(optional)
@callback rows(conn(), kind :: String.t(), source_ids :: [String.t()]) ::
  {:ok, [term()]} | {:error, term()}

The rows of kind whose source ids are source_ids, as list/3 answers them, read without listing the whole kind: what a job of picked items reads (see "A run"). A row it answers that was not asked for is left out once it is normalized, so a kind the source reads in one go, such as WordPress's menus, may answer it whole. Optional.

Functions

all()

@spec all() :: [module()]

Every installed source (installed/2), by priority, the first of each id: what the Migration page offers to migrate from, and what a developer reads to see which sources a node has.

connect(source, connection, secret, metadata)

@spec connect(module(), map(), String.t() | nil, map()) ::
  {:ok, conn()} | {:error, term()}

Connects with source (connect/2) and answers what it answers: how the New import form's check and each job connect. A refusal is also told on [:mishka_cms_migration, :connect, :refused], with metadata, the source's id and the reason in words (see "Telemetry" in MishkaCmsMigration.Workers).

event()

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

The event a source plugin registers on, the one use MishkaCmsMigration.Source names: what an extension's own tests register a source for.

fetch(id)

@spec fetch(String.t() | nil) :: {:ok, module()} | {:error, :not_installed}

The installed source whose id is id.

file?(state_or_id)

@spec file?(map() | String.t() | nil) :: boolean()

Whether the installed source a form names (defaults: %{source: id}), or the source whose id is given, reads a file (file?/0): what shows the New import form's file. false for a form opened for no source and for a source that is not installed.

field :file, :upload do
  visible fn state -> MishkaCmsMigration.Source.file?(state) end
end

form_fields(state_or_id)

@spec form_fields(map() | String.t() | nil) :: [form_field()]

The connection keys (form_fields/0) of the installed source a form names, or of the source whose id is given: what the run's form draws under Connection. A form names its source in its defaults, as the Migration page opens it (defaults: %{source: id}). Empty for a form opened for no source and for a source that is not installed.

field :connection, :key_map do
  options fn state -> MishkaCmsMigration.Source.form_fields(state) end
end

installed(event, behaviour)

@spec installed(String.t(), module()) :: [module()]

The plugins of a migration event that are installed now, read from mishka_installer's own registry (MishkaInstaller.Event.Event.get/2): those an operator has not stopped and that are not held (:started or :restarted), by priority, that are loaded and implement every required callback of behaviour, the first of each id. Nothing is fired and nothing is cached. Targets are found the same way (MishkaCmsMigration.Target.fetch/1).

installed(event, behaviour, id)

@spec installed(String.t(), module(), String.t() | nil) ::
  {:ok, module()} | {:error, :not_installed}

The one plugin of installed/2 whose id/0 is id; {:error, :not_installed} for none.

label(id)

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

The name to show for the source whose id is id: its label in the reader's language, or the id itself for a source that is no longer installed.

srcset(value)

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

The candidates of an srcset attribute, each as {url, descriptor} ({"hero-300.jpg", "300w"}; the descriptor is "" for a candidate with none), in the order written. Candidates are told apart by a comma followed by white space, or by one that follows a width or density descriptor.

srcset_value(candidates)

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

The srcset attribute candidates (srcset/1) written back as the attribute's value.

url_host(arg1)

@spec url_host(map()) :: String.t() | nil

The host of a connection's "url", lower-cased and with its port when it is not the scheme's own (localhost:8089), or nil when it has no address with a host: what use answers as host/1.