MishkaCmsMigration.Targets.CmsMedia (Mishka CMS Migration v0.0.1)

Copy Markdown View Source

The "media" items of a source, its files, written to the CMS's own media library (MishkaCmsCore.Runtime.Media), on the run's site and as the admin who pressed Import: a file is stored and its record made by the core's :upload, the action the media chooser of an admin's forms runs, and written again by the :update an admin's own media form runs. The admin needs the core's admin:dashboard:runtime:media, and the core's own rules decide the rest: which types it keeps and how big, that a file's first bytes are the type its name says (MishkaCmsCore.Runtime.FileSignature), where the file is stored, and that a name is unique within a site.

Which files

An item's "source_url" is where its file is, and its extension and "mime_type" its type. Read before anything is written, in the dry run too (existing/2), without asking for any file, an item is skipped as:

  • "not_on_source" — its address is not http or https on the run's source host (the run's source_host, its port included), such as a file a plugin moved to a CDN: the migration only downloads from the site the admin named;
  • "type_not_allowed" — its extension is not one the Media page's upload takes (the core Media's :media_files upload, read with MishkaGervaz.Resource.Info.form_uploads/1): an SVG, an AVIF, a sound, a zip. The core takes no SVG, whose markup can carry a script it does not clean out, so an SVG is never downloaded;
  • "wrong_type" — its "mime_type" is not the type the core gives its extension (MishkaCmsCore.Runtime.Media.get_content_type/1);
  • "too_big" — its "filesize" is larger than that upload takes (50 MB).

The download

In an import, a file to create is downloaded before it is written (prepare/3), outside the write's transaction, once: with Req, from its own address, sending no credential, following no redirect, retrying nothing and decompressing nothing, into a temporary file (Plug.Upload.random_file!/1) that is removed once the write is done, and with the job's process if it ends first. The checks above run again on the address, and then:

  • the download stops once more bytes have come than the Media page's upload takes, whatever the source said its size was, and the item is skipped as "too_big";
  • an answer that is not a 200, or no answer, fails the item as "not_downloaded";
  • an answer whose content type is not the one the core gives its extension is skipped as "wrong_type";
  • a file whose first bytes are not that type (MishkaCmsCore.Runtime.FileSignature.matches?/2), such as an HTML page sent as hero.jpg, is skipped as "wrong_bytes".

The core's :upload checks the same file again, its type, its size and its bytes, and has the last word.

The same file twice

WordPress media uploaded at different times has its own ids, and each is imported as its own file. Two attachments are the same file only when their bytes are: the SHA-256 of a downloaded file is kept on its item (MishkaCmsMigration.MigrationItem's file_hash), and one lookup by it, made once the file is downloaded and checked, finds an item of the site that has the same bytes. A name or a size alone never does. The source lists its files oldest first, so the earlier upload is the original. A dry run downloads nothing, so it cannot know: it plans every file to create and says the check is made when the file is downloaded ("same_file_checked_at_download").

The later attachment waits for the admin's decision in the import ("same_file: <title of the original>", the report says Same file as …), with two choices:

  • Use the original ("import") — nothing is stored. The item is connected to the original's Media record, so every address a post's or a page's content wrote for the duplicate (relinked/3) is the original's. The duplicate's old address on the source, such as /wp-content/uploads/2026/01/photo-1.jpg, is redirected with a 301 to the original's Media address through the core's redirect rule (MishkaCmsCore.Runtime.Redirect), as the admin who pressed Import, who needs the core's admin:dashboard:runtime:redirect ("same_file_used"); a rule that already sends that address to the original is reused. The run's post_address choice "blog_without_redirects" writes no redirect, and the report says so ("same_file_used_no_redirect"). The sizes WordPress made of the file (media_details.sizes, listed with the file) are redirected to the original too.
  • Keep both ("keep") — imported as its own file, as any other ("same_file_kept").

The answer holds for every later run: an item connected to the original is never downloaded again. An original the CMS no longer has is not used: the file is imported as its own.

req_options in the application environment are merged into every download, which is how the suite answers them from the recorded site:

config :mishka_cms_migration, MishkaCmsMigration.Targets.CmsMedia,
  req_options: [plug: {Req.Test, MishkaCmsMigration.Sources.WordPress}]

The record

A file is named by its "slug", made as the core slugs a name (hero, وردپرس as wrdprs), with its own extension, or media-<source id> for a slug that leaves nothing: a source's slugs are unique among its files, and a name the site already has is refused by the core, failing the item with its sentence. Its description is its "alt_text", or without one its "caption", or its "description", as text, the first 1,000 characters. It is filed in the site's top-level media folder named after the run's source, wordpress, read and, the first time, made by the admin, as the media chooser files an upload in the folder its form names; an admin who may not make one files it at the top.

A second run

A file the source did not change is "unchanged" and never downloaded again. One it changed has its description written over, and keeps the file the first run stored. A record an admin edited in the CMS is kept as the admin left it, one an admin archived waits for the admin's decision, Restore it or Leave it archived, and one deleted for good waits for the admin's decision, Import it again, downloading the file as a first import does, or Leave it out (MishkaCmsMigration.Workers.Helpers.Decision).

Where a file went

addresses/2 is where the CMS serves the files written for a source's media ids, and relinked/3 points the addresses a post's or a page's content wrote for them at those files, for the targets that write them (MishkaCmsMigration.Targets.BlogPost, MishkaCmsMigration.Targets.CmsPage).

Summary

Functions

Where the CMS serves the files the same run, or an earlier one, wrote for the source's media ids, by id as a string: each one's Media address, /uploads/<site>/media/<folder>/<file>. A file never written, and one the CMS no longer has, is left out.

Returns a specification to start this module under a supervisor.

Callback implementation for MishkaInstaller.Event.Hook.enable/0.

Callback implementation for MishkaInstaller.Event.Hook.get/0.

Optional. See MishkaInstaller.Event.Hook.health_check/0. Override to report health.

html with each src, href and poster (a video's picture) whose value is a key of moved written as that key's value, and each URL of an srcset (an image's, or a source's in a picture) that is a key of moved written as that key's value, its descriptor kept; the other attributes are kept as they are. relinked/3 and MishkaCmsMigration.Targets.CmsPage.relinked/3 point the addresses of files and of pages at where they went.

Callback implementation for MishkaInstaller.Event.Hook.register/0.

html with each src, href, poster and srcset candidate that media names, an address as the source wrote it to the source id of the file it is (MishkaCmsMigration.Sources.WordPress's media), pointing at that file's Media address (addresses/2). The address of a file that was not written, and every other, is kept as it is.

Callback implementation for MishkaInstaller.Event.Hook.restart/0.

What the report says about the srcset candidates of item's content: "srcset_file_missing" when one names a file of the source's media (the item's "media") that no run wrote, so it keeps its old address (relinked/3). A dry run writes no file and says nothing. The files are read in one query, and only when the content has such a candidate.

Callback implementation for MishkaInstaller.Event.Hook.start/0.

Callback implementation for MishkaInstaller.Event.Hook.stop/0.

Types

error_return()

@type error_return() ::
  {:error, [%{action: atom(), field: atom(), message: String.t()}]}

okey_return()

@type okey_return() :: {:ok, struct() | map() | module() | [any()]}

Functions

addresses(ids, context)

@spec addresses([term()], MishkaCmsMigration.Target.context()) :: %{
  required(String.t()) => String.t()
}

Where the CMS serves the files the same run, or an earlier one, wrote for the source's media ids, by id as a string: each one's Media address, /uploads/<site>/media/<folder>/<file>. A file never written, and one the CMS no longer has, is left out.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

config()

@spec config() :: keyword()

config(key)

@spec config(atom()) :: any()

enable()

@spec enable() :: okey_return() | error_return()

Callback implementation for MishkaInstaller.Event.Hook.enable/0.

get()

@spec get() :: keyword()

Callback implementation for MishkaInstaller.Event.Hook.get/0.

health_check()

@spec health_check() :: :ok | {:degraded, term()} | {:error, term()}

Optional. See MishkaInstaller.Event.Hook.health_check/0. Override to report health.

moved(html, moved)

@spec moved(String.t(), %{required(String.t()) => String.t()}) :: String.t()

html with each src, href and poster (a video's picture) whose value is a key of moved written as that key's value, and each URL of an srcset (an image's, or a source's in a picture) that is a key of moved written as that key's value, its descriptor kept; the other attributes are kept as they are. relinked/3 and MishkaCmsMigration.Targets.CmsPage.relinked/3 point the addresses of files and of pages at where they went.

on_dependency_error(error)

@spec on_dependency_error(term()) :: term()

Optional. See MishkaInstaller.Event.Hook.on_dependency_error/1. Override to react.

register()

@spec register() :: okey_return() | error_return()

Callback implementation for MishkaInstaller.Event.Hook.register/0.

relinked(html, media, context)

@spec relinked(String.t(), map() | nil, MishkaCmsMigration.Target.context()) ::
  String.t()

html with each src, href, poster and srcset candidate that media names, an address as the source wrote it to the source id of the file it is (MishkaCmsMigration.Sources.WordPress's media), pointing at that file's Media address (addresses/2). The address of a file that was not written, and every other, is kept as it is.

restart()

@spec restart() :: okey_return() | error_return()

Callback implementation for MishkaInstaller.Event.Hook.restart/0.

srcset_note(item, context)

What the report says about the srcset candidates of item's content: "srcset_file_missing" when one names a file of the source's media (the item's "media") that no run wrote, so it keeps its old address (relinked/3). A dry run writes no file and says nothing. The files are read in one query, and only when the content has such a candidate.

start()

@spec start() :: okey_return() | error_return()

Callback implementation for MishkaInstaller.Event.Hook.start/0.

start_link(args \\ [])

stop()

@spec stop() :: okey_return() | error_return()

Callback implementation for MishkaInstaller.Event.Hook.stop/0.

unregister()

@spec unregister() :: okey_return() | error_return()

Callback implementation for MishkaInstaller.Event.Hook.unregister/0.