# `MishkaCmsMigration.Targets.CmsMedia`
[🔗](https://github.com/mishka-group/mishka_cms_migration/blob/v0.0.1/lib/mishka_cms_migration/targets/cms_media.ex#L5)

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

# `error_return`

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

# `okey_return`

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

# `addresses`

```elixir
@spec addresses([term()], MishkaCmsMigration.Target.context()) :: %{
  required(String.t()) =&gt; 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`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `config`

```elixir
@spec config() :: keyword()
```

# `config`

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

# `enable`

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

# `get`

```elixir
@spec get() :: keyword()
```

# `health_check`

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

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

# `moved`

```elixir
@spec moved(String.t(), %{required(String.t()) =&gt; 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`

```elixir
@spec on_dependency_error(term()) :: term()
```

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

# `register`

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

# `relinked`

```elixir
@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`

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

# `srcset_note`

```elixir
@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.

# `start`

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

# `start_link`

# `stop`

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

# `unregister`

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

---

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