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 nothttporhttpson the run's source host (the run'ssource_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_filesupload, read withMishkaGervaz.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 ashero.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'sadmin:dashboard:runtime:redirect("same_file_used"); a rule that already sends that address to the original is reused. The run'spost_addresschoice"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.
Optional. See MishkaInstaller.Event.Hook.on_dependency_error/1. Override to react.
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.
Callback implementation for MishkaInstaller.Event.Hook.unregister/0.
Types
Functions
@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.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec config() :: keyword()
@spec enable() :: okey_return() | error_return()
Callback implementation for MishkaInstaller.Event.Hook.enable/0.
@spec get() :: keyword()
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.
Optional. See MishkaInstaller.Event.Hook.on_dependency_error/1. Override to react.
@spec register() :: okey_return() | error_return()
Callback implementation for MishkaInstaller.Event.Hook.register/0.
@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.
@spec restart() :: okey_return() | error_return()
Callback implementation for MishkaInstaller.Event.Hook.restart/0.
@spec srcset_note( MishkaCmsMigration.Source.Item.t(), MishkaCmsMigration.Target.context() ) :: String.t() | nil
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.
@spec start() :: okey_return() | error_return()
Callback implementation for MishkaInstaller.Event.Hook.start/0.
@spec stop() :: okey_return() | error_return()
Callback implementation for MishkaInstaller.Event.Hook.stop/0.
@spec unregister() :: okey_return() | error_return()
Callback implementation for MishkaInstaller.Event.Hook.unregister/0.