# `PhoenixKitCatalogue.Web.Helpers`
[🔗](https://github.com/BeamLabEU/phoenix_kit_catalogue/blob/0.8.0/lib/phoenix_kit_catalogue/web/helpers.ex#L1)

Tiny utilities shared by every catalogue LiveView. Imported into LVs
via the standard `import PhoenixKitCatalogue.Web.Helpers` line.

Currently exports:

  * `actor_opts/1` — extract the current user's UUID from socket
    assigns, return `[actor_uuid: uuid]` for the `opts \\ []` keyword
    list every mutating context function accepts. Returns `[]` when
    no user is signed in (e.g. inside a test that mounts the LV with
    a bare conn). The atom is suitable to thread through
    `Catalogue.create_*` / `update_*` / `trash_*` / `restore_*` /
    `permanently_delete_*` etc.
  * `actor_uuid/1` — the raw UUID (or `nil`). Use when you need the
    value directly rather than a keyword list, e.g. when building
    activity-log metadata in a LiveView.
  * `log_operation_error/3` — engineer-visible `Logger.error` for a
    failed mutation **plus** an Activity row tagged
    `db_pending: true` so the user-visible audit feed records the
    attempted action even when it fails. The function's own docs
    describe the success-vs-failure layering in detail.

# `actor_opts`

```elixir
@type actor_opts() :: [{:actor_uuid, Ecto.UUID.t()}] | []
```

Convenience alias for the keyword list shape mutating ctx fns accept.

# `actor_opts`

```elixir
@spec actor_opts(Phoenix.LiveView.Socket.t()) :: actor_opts()
```

Extracts `[actor_uuid: uuid]` from `socket.assigns.phoenix_kit_current_user`.

Returns `[]` when no user is signed in. Pass the result straight into
any `PhoenixKitCatalogue.Catalogue` mutating function as its trailing
`opts` argument.

# `actor_uuid`

```elixir
@spec actor_uuid(Phoenix.LiveView.Socket.t()) :: Ecto.UUID.t() | nil
```

Returns the current user's UUID from socket assigns, or `nil`.

# `ai_translate_config`

# `assign_ai_translation`

See `FormGlue.assign_ai_translation/4` — wires the catalogue binding.

# `derive_activity_action`

```elixir
@spec derive_activity_action(String.t(), String.t() | nil) :: String.t() | nil
```

Maps an LV operation string + entity_type to the canonical activity
action atom the catalogue context already uses on the success path.

Falls back to `nil` when the operation doesn't follow the
`<verb>_<entity>` shape; the caller skips the audit-row write in
that case (engineer log still fires).

# `dispatch_ai_translate`

# `escape_html`

```elixir
@spec escape_html(String.t() | nil) :: String.t()
```

HTML-escapes a string for safe interpolation into raw markup.

# `format_byte_size`

```elixir
@spec format_byte_size(integer() | nil) :: String.t()
```

Human-readable byte size with B / KB / MB / GB suffixes.

# `format_time_ago`

```elixir
@spec format_time_ago(DateTime.t() | nil) :: String.t()
```

Translated relative-time label for a timestamp.

Buckets: `< 1m` → "just now", `< 1h` → "Nm ago", `< 1d` → "Nh ago",
`< 1w` → "Nd ago", else `Mon DD, YYYY` (locale-formatted via
gettext'd strftime template).

# `generate_ai_prompt`

# `handle_ai_translation_event`

# `log_operation_error`

```elixir
@spec log_operation_error(Phoenix.LiveView.Socket.t(), String.t(), map()) :: :ok
```

Logs a failed LV mutation in two places at once:

1. **Engineer log** — `Logger.error` with the operation, the
   LV-level entity context, and the changeset / atom reason. This
   is the rich-context line that production-incident triage reads.
2. **User-visible audit row** — an Activity entry with the same
   action atom the success path would have written, plus
   `metadata.db_pending: true`. The audit feed therefore records
   **what the user attempted**, not just **what succeeded** — a
   deliberate change in the post-Apr 2026 pipeline (workspace
   `AGENTS.md` C12 agent #2 — "Activity logging coverage").

The action atom is derived from `operation` via
`derive_activity_action/2`. Validation cycles (form-validate
events) never reach this helper — by construction it's only called
from `{:error, _}` handle_event branches, where the failure is a
real infrastructure / consistency error worth auditing.

## Expected `context` keys

  * `:entity_type` — `"item"` / `"category"` / `"catalogue"` /
    `"manufacturer"` / `"supplier"` (drives both the activity
    `resource_type` and the action-atom prefix).
  * `:entity_uuid` — primary-key UUID; lands as `resource_uuid`.
  * `:reason` — an `%Ecto.Changeset{}`, an atom, or any other
    `inspect`able shape. Logged engineer-side; on the audit row
    it's summarised into PII-safe `metadata.error_keys` (changeset
    field names only — never values, since user-typed strings can
    carry PII).

Activity-log failures (missing table, ownership errors, sandbox
exit) are swallowed by `ActivityLog.log/1`; they never bubble up
to the LV.

# `pdf_error_message`

```elixir
@spec pdf_error_message(map()) :: String.t() | nil
```

Pulls the error_message off a (possibly preloaded) Pdf row; nil if no error.

# `pdf_extracted_at`

```elixir
@spec pdf_extracted_at(map()) :: DateTime.t() | NaiveDateTime.t() | nil
```

Pulls the extracted_at timestamp off a (possibly preloaded) Pdf row; nil if not extracted.

# `pdf_extraction_pages`

```elixir
@spec pdf_extraction_pages(map()) :: integer() | nil
```

Pulls the page count off a (possibly preloaded) Pdf row; returns nil if unknown.

# `pdf_extraction_status`

```elixir
@spec pdf_extraction_status(map()) :: String.t()
```

Pulls the extraction status off a (possibly preloaded) Pdf row; defaults to `pending`.

# `pdf_status_badge_class`

```elixir
@spec pdf_status_badge_class(String.t()) :: String.t()
```

daisyUI badge class for an extraction status string.

# `pdf_status_label`

```elixir
@spec pdf_status_label(String.t()) :: String.t()
```

Translated label for an extraction status.

# `select_ai_endpoint`

# `select_ai_prompt`

# `select_ai_scope`

# `status_label`

```elixir
@spec status_label(String.t() | nil) :: String.t()
```

Translates a catalogue/category/item/manufacturer/supplier `status`
field value to a localised label via gettext.

Handles every status string that any catalogue schema can emit
(`active` / `inactive` / `archived` / `deleted` / `discontinued`)
with explicit literal `gettext(...)` clauses so `mix gettext.extract`
picks them up. Unknown status values pass through unchanged — never
use `String.capitalize/1` on translated text because the result
would pin English casing on a value the extractor can't see.

# `toggle_ai_modal`

---

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