# `PhoenixKitDocumentCreator.Documents`
[🔗](https://github.com/BeamLabEU/phoenix_kit_document_creator/blob/0.4.8/lib/phoenix_kit_document_creator/documents.ex#L1)

Context module for managing templates and documents via Google Drive.

Google Drive is the single source of truth for file content. This module
mirrors file metadata (name, google_doc_id, status, thumbnails, variables)
to the local database for fast listing and audit tracking.

## API layers

This module provides **combined** operations (Drive + DB). For direct access:

- **Drive-only** — Use `PhoenixKitDocumentCreator.GoogleDocsClient` for raw Google
  Drive/Docs API calls (create files, list folders, export PDF, move files) without
  touching the local database.
- **DB-only** — Use `list_templates_from_db/0`, `list_documents_from_db/0`,
  `load_cached_thumbnails/1`, `persist_thumbnail/2` for local DB queries.
- **Combined** — Use `create_template/2`, `create_document/2`, `sync_from_drive/0`,
  `delete_template/2`, etc. which coordinate between Drive and DB.

# `apply_preset`

```elixir
@spec apply_preset(binary()) ::
  {:ok,
   [
     %{
       template_uuid: binary(),
       position: non_neg_integer(),
       variable_values: map(),
       image_params: map()
     }
   ]}
  | {:error, :not_found}
```

Applies a preset by UUID, returning its sections as plain maps.

Sections whose `template_uuid` no longer exists in the database are silently
dropped (a warning is logged listing the removed UUIDs). The remaining
sections are returned in position order.

NOTE: Deliberate deviation from spec line 110 — this function returns
`{:ok, [map]} | {:error, :not_found}` instead of the spec's bare `[map]`.
This gives callers a clean error path for stale preset references from UI
state (e.g. a preset UUID that was deleted server-side). The spec should be
updated to match after implementation review.

# `broadcast_files_changed`

```elixir
@spec broadcast_files_changed() :: :ok
```

Broadcast `{:files_changed, self()}` on the `document_creator:files` topic.

Use this after a bulk `register_existing_document/2` / `register_existing_template/2`
call that passed `emit_pubsub: false`, to trigger a single resync in any
connected admin LiveViews.

Silently no-ops if the PubSub system isn't available (e.g. background
jobs or tests without a running PubSub registry).

# `create_composed_document`

```elixir
@spec create_composed_document(
  [PhoenixKitDocumentCreator.Documents.Composer.section_input()],
  keyword()
) :: {:ok, PhoenixKitDocumentCreator.Schemas.Document.t()} | {:error, term()}
```

Compose N template sections into a single Google Doc, persisting the recipe.

Required opts: `:created_by_uuid`, `:name`. Optional: `:separator` (default `:page_break`).

Variable substitution is range-scoped per section: each section's `variable_values`
are applied only within the character range that section occupies in the composed doc.
Identical placeholder keys in different sections (e.g. `{{name}}` in section 0 and
section 1) resolve independently.

# `create_document`

```elixir
@spec create_document(
  String.t(),
  keyword()
) :: {:ok, map()} | {:error, term()}
```

Create a blank document in the documents folder. Returns `{:ok, %{doc_id, name, url}}`.

## Options

- `:actor_uuid` — UUID of the user performing the action (for activity logging)

# `create_document_from_template`

```elixir
@spec create_document_from_template(String.t(), map(), keyword()) ::
  {:ok, map()} | {:error, term()}
```

Create a document from a template by copying and filling variables.

1. Copies the template Google Doc into the target folder
2. Replaces all `{{variable}}` placeholders with values
3. Persists the document record with variable_values and template link
4. Returns `{:ok, %{doc_id, url}}`

## Options

- `:name` — document name (default `"New Document"`)
- `:actor_uuid` — UUID of the user performing the action (activity log)
- `:parent_folder_id` — Drive folder ID to copy into. Defaults to the
  managed documents folder. Supply this to place the new document in a
  subfolder (e.g. `order-123/sub-4/`) you manage yourself.
- `:path` — human-readable path string to store on the record. Only
  meaningful when `:parent_folder_id` is also supplied. If omitted when
  `:parent_folder_id` is given, the stored `path` is left unset and the
  next `sync_from_drive/0` fills it from the walker.

# `create_template`

```elixir
@spec create_template(
  String.t(),
  keyword()
) :: {:ok, map()} | {:error, term()}
```

Create a blank template in the templates folder. Returns `{:ok, %{doc_id, name, url}}`.

## Options

- `:actor_uuid` — UUID of the user performing the action (for activity logging)
- `:language` — locale code to tag the template with. Defaults to the
  project's primary language from `PhoenixKit.Modules.Languages`.
  Pass `nil` to leave it unset; pass an explicit code (e.g. `"et-EE"`)
  to override.

# `delete_document`

```elixir
@spec delete_document(
  String.t(),
  keyword()
) :: :ok | {:error, term()}
```

Move a document to the deleted/documents folder.

## Options

- `:actor_uuid` — UUID of the user performing the action (for activity logging)

# `delete_preset`

```elixir
@spec delete_preset(PhoenixKitDocumentCreator.Schemas.TemplatePreset.t()) ::
  {:ok, PhoenixKitDocumentCreator.Schemas.TemplatePreset.t()}
  | {:error, Ecto.Changeset.t()}
```

Permanently deletes a preset (hard delete — no trash in Stage 1).

# `delete_template`

```elixir
@spec delete_template(
  String.t(),
  keyword()
) :: :ok | {:error, term()}
```

Move a template to the deleted/templates folder.

## Options

- `:actor_uuid` — UUID of the user performing the action (for activity logging)

# `detect_variables`

```elixir
@spec detect_variables(String.t()) ::
  {:ok,
   %{
     text: [String.t()],
     image: [%{name: String.t(), kind: :image | :image_list}]
   }}
  | {:error, term()}
```

Detect `{{ variables }}` in a Google Doc's text content.

# `documents_folder_url`

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

Get the Google Drive URL for the documents folder.

# `export_pdf`

```elixir
@spec export_pdf(
  String.t(),
  keyword()
) :: {:ok, binary()} | {:error, term()}
```

Export a Google Doc to PDF. Returns `{:ok, pdf_binary}`.

## Options

- `:actor_uuid` — UUID of the user performing the action (for activity logging)
- `:name` — document name (for activity metadata)

# `fetch_thumbnails_async`

```elixir
@spec fetch_thumbnails_async([map()], pid()) :: :ok
```

Fetch thumbnails for a list of Drive files asynchronously.

Spawns a single supervised parent task under `PhoenixKit.TaskSupervisor`
that fans out via `Task.async_stream/3` with a bounded `max_concurrency`
so opening a folder with hundreds of files doesn't fire hundreds of
simultaneous Drive requests. Each completion sends `{:thumbnail_result,
file_id, data_uri}` back to `caller_pid` and persists the thumbnail to
the DB. The parent is `restart: :temporary` so it dies cleanly if the
caller LV closes mid-fetch — but in-flight persists still complete.

# `get_folder_ids`

```elixir
@spec get_folder_ids() :: map()
```

Get the folder IDs (auto-discovers if not cached).

# `get_preset`

```elixir
@spec get_preset(binary()) ::
  PhoenixKitDocumentCreator.Schemas.TemplatePreset.t() | nil
```

Fetches a preset by uuid, or `nil`.

# `get_template_from_db`

```elixir
@spec get_template_from_db(String.t()) :: {:ok, map()} | {:error, :not_found}
```

Fetches a single template row by its Google Doc ID.

Returns `{:ok, %{"id" => ..., "name" => ...}}` or `{:error, :not_found}`.

# `get_template_variables_from_db`

```elixir
@spec get_template_variables_from_db(String.t()) :: [
  PhoenixKitDocumentCreator.Variable.t()
]
```

Returns the DB-cached variable definitions for a template as a list of
`Variable.t()` structs, without making any Drive API calls.

Returns `[]` if the template has no cached variables.

# `image_slots_for_template`

```elixir
@spec image_slots_for_template(UUIDv7.t()) ::
  {:ok, [%{name: String.t(), kind: :image | :image_list, config: map()}]}
  | {:error, :not_found | term()}
```

Returns the image variable slots defined in a template's Google Doc.

Fetches the current document text via the Google Docs client and extracts
all `{{ image: name }}` / `{{ images: name }}` tags, returning a list of
`%{name: String.t(), kind: :image | :image_list, config: map()}` maps sorted
by name.

The `:config` map is sourced from the saved variable in `template.variables`
(if present); otherwise it falls back to `Variable.default_image_config(kind)`.
Keys are uniformly string-keyed (e.g. `"default_width_px"`, `"annotated"`).

## The `"annotated"` flag is a host-app contract

This module stores and surfaces `config["annotated"]` but never consumes it —
by design. The host app is responsible for honoring it: when building the
`image_params` media URLs it submits back to `substitute_images/3`, it should
supply the *annotated* image (drawn overlays flattened in) when the flag is
truthy, or the *raw* photo when it is `false`. Defaults to `true` (see
`Variable.default_image_config/1`). This library only carries the URLs it is
given; it does not flatten annotations itself.

Returns `{:error, :not_found}` if no template exists for the given UUID.

# `list_documents_from_db`

```elixir
@spec list_documents_from_db() :: [map()]
```

List documents from the local DB. Returns maps compatible with the LiveView.

# `list_enabled_languages`

```elixir
@spec list_enabled_languages() :: [%{code: String.t(), name: String.t()}]
```

Lookup the project's enabled languages, sorted by configured position.

Returns `[%{code: "en-US", name: "English (United States)"}, ...]` or
`[]` when core's `PhoenixKit.Modules.Languages` is disabled or the
settings table isn't reachable. Safe to call from LiveView mount —
failure is swallowed, never crashes the caller.

# `list_presets`

```elixir
@spec list_presets(%{
  optional(:scope_type) =&gt; String.t(),
  optional(:scope_id) =&gt; String.t()
}) :: [
  PhoenixKitDocumentCreator.Schemas.TemplatePreset.t()
]
```

Lists presets, optionally filtered by `:scope_type` and `:scope_id`.
Results are ordered by name ascending.

# `list_templates_for_category`

```elixir
@spec list_templates_for_category(binary()) :: [
  PhoenixKitDocumentCreator.Schemas.Template.t()
]
```

Lists published `Template` structs for a category, ordered by name.

Returns full schema structs (not file maps) so callers get `variables`,
`status`, and atom-key access. Used by the preset section editor.

# `list_templates_from_db`

```elixir
@spec list_templates_from_db() :: [map()]
```

List templates from the local DB. Returns maps compatible with the LiveView.

# `list_trashed_documents_from_db`

```elixir
@spec list_trashed_documents_from_db() :: [map()]
```

List trashed documents from the local DB.

# `list_trashed_templates_from_db`

```elixir
@spec list_trashed_templates_from_db() :: [map()]
```

List trashed templates from the local DB.

# `load_cached_thumbnails`

```elixir
@spec load_cached_thumbnails([String.t()] | any()) :: %{
  required(String.t()) =&gt; String.t()
}
```

Load cached thumbnails from DB for a list of google_doc_ids.

# `log_manual_action`

```elixir
@spec log_manual_action(
  String.t(),
  keyword()
) :: :ok
```

Log a manual user action to the activity feed.

# `move_to_documents`

```elixir
@spec move_to_documents(
  String.t(),
  keyword()
) :: :ok | {:error, term()}
```

Move a file into the managed documents folder and classify it as a document.

## Options

- `:actor_uuid` — UUID of the user performing the action (for activity logging)

# `move_to_templates`

```elixir
@spec move_to_templates(
  String.t(),
  keyword()
) :: :ok | {:error, term()}
```

Move a file into the managed templates folder and classify it as a template.

## Options

- `:actor_uuid` — UUID of the user performing the action (for activity logging)

# `persist_thumbnail`

```elixir
@spec persist_thumbnail(String.t(), String.t()) :: :ok
```

Persist a thumbnail data URI to the DB by google_doc_id.

# `preset_stale_info`

```elixir
@spec preset_stale_info(PhoenixKitDocumentCreator.Schemas.TemplatePreset.t()) :: %{
  broken_count: non_neg_integer(),
  broken_template_uuids: [binary()]
}
```

Returns staleness info for a preset.

A section is "broken" when its `template_uuid` references a template that
no longer exists, or whose `status` is `trashed` or `lost`.

Returns `%{broken_count: non_neg_integer(), broken_template_uuids: [binary()]}`.

# `preset_stale_info_map`

```elixir
@spec preset_stale_info_map([PhoenixKitDocumentCreator.Schemas.TemplatePreset.t()]) ::
  %{
    required(binary()) =&gt; %{
      broken_count: non_neg_integer(),
      broken_template_uuids: [binary()]
    }
  }
```

Batched `preset_stale_info/1` for a list of presets.

Runs a single `Template` query covering every referenced template uuid
and returns a map of `preset.uuid => stale_info`, avoiding the N+1 of
calling `preset_stale_info/1` once per preset.

# `pubsub_topic`

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

The PubSub topic on which `{:files_changed, self()}` messages are
broadcast whenever a template or document DB record is mutated.

Admin LiveViews subscribe to this topic in `mount/3`. Prefer calling
this helper over hard-coding the topic string so the two stay in sync.

# `recipe_for`

```elixir
@spec recipe_for(PhoenixKitDocumentCreator.Schemas.Document.t()) :: [
  %{
    template_uuid: binary() | nil,
    position: non_neg_integer(),
    variable_values: map(),
    image_params: map()
  }
]
```

Returns the sections of a document as an ordered list of plain maps.

Each map contains `:template_uuid`, `:position`, `:variable_values`, and
`:image_params`. The list is ordered by position ascending and represents
a point-in-time snapshot — it does not check whether templates still exist.

# `refresh_folders`

```elixir
@spec refresh_folders() :: map()
```

Re-discover folder IDs from Drive.

# `register_existing_document`

```elixir
@spec register_existing_document(
  map(),
  keyword()
) ::
  {:ok, PhoenixKitDocumentCreator.Schemas.Document.t()}
  | {:error, Ecto.Changeset.t() | term()}
```

Register a Drive document that the caller has already created (or already
knows about) into the local DB.

Use this when your own wrapper code handles the Drive-side work (copy,
placement in a subfolder, variable substitution) and you just need the
file to appear in `list_documents_from_db/0` and be classified correctly
by future `sync_from_drive/0` runs.

**Makes no Drive API calls.** This is a pure DB upsert — garbage inputs
do not error, they self-correct on the next sync (the walker rewrites
`path`/`folder_id`, and files that are not actually in the managed tree
get classified `:unfiled` or `:lost` per the usual reconciliation rules).

## `attrs` — map keyed by atoms or strings

| Key               | Required | Notes                                                     |
| ----------------- | -------- | --------------------------------------------------------- |
| `google_doc_id`   | yes      | Drive file ID                                             |
| `name`            | yes      | Display name                                              |
| `template_uuid`   | no       | UUID of the source template, if applicable                |
| `variable_values` | no       | Map of variables substituted during generation            |
| `folder_id`       | no       | Actual Drive folder holding the file. Defaults to managed |
| `path`            | no       | Human-readable path. Defaults to managed root path        |
| `status`          | no       | Defaults to `"published"`                                 |
| `thumbnail`       | no       | Optional data URI                                         |

If `:folder_id` points outside the managed documents tree, the next
`sync_from_drive/0` will classify the record as `:unfiled` and surface
the resolution popup in the admin UI.

## `opts`

- `:actor_uuid` — user UUID for the activity log
- `:emit_pubsub` — default `true`. Broadcasts `:files_changed` on the
  `document_creator:files` topic so connected admin LiveViews re-sync.
  Bulk callers (e.g. a backfill script registering hundreds of rows)
  should pass `false` and trigger **one** broadcast or sync at the end:

      Enum.each(rows, &Documents.register_existing_document(&1, emit_pubsub: false))
      Documents.broadcast_files_changed()

# `register_existing_template`

```elixir
@spec register_existing_template(
  map(),
  keyword()
) ::
  {:ok, PhoenixKitDocumentCreator.Schemas.Template.t()}
  | {:error, Ecto.Changeset.t() | term()}
```

Register a Drive template that the caller has already created or knows about.

Symmetric to `register_existing_document/2` — see its documentation for the
`attrs` shape and options. Unlike documents, template registration does not
accept `template_uuid` or `variable_values`.

# `rename_document`

```elixir
@spec rename_document(binary(), String.t()) ::
  {:ok, PhoenixKitDocumentCreator.Schemas.Document.t()} | {:error, term()}
```

Rename a document: updates its name in Google Drive and in the local DB.

Validates that `new_name` is non-blank (reuses `Document.changeset/2`'s
length validation). Returns `{:ok, %Document{}}` on success or
`{:error, term()}` on failure (including `{:error, :not_found}` and
`{:error, :blank_name}`).

# `resolve_image_fills`

```elixir
@spec resolve_image_fills([map()], map()) :: {:ok, map()} | {:error, term()}
```

Resolves image value specs against template variable defs and media module.

# `restore_document`

```elixir
@spec restore_document(
  String.t(),
  keyword()
) :: :ok | {:error, term()}
```

Restore a trashed document back to the documents folder.

# `restore_template`

```elixir
@spec restore_template(
  String.t(),
  keyword()
) :: :ok | {:error, term()}
```

Restore a trashed template back to the templates folder.

# `save_preset`

```elixir
@spec save_preset(map()) ::
  {:ok, PhoenixKitDocumentCreator.Schemas.TemplatePreset.t()}
  | {:error, Ecto.Changeset.t()}
```

Persists a named, reusable preset (template composition recipe).

Required attrs: `:name`, `:created_by_uuid`. Optional: `:description`,
`:scope_type`, `:scope_id`, `:sections`.

# `set_correct_location`

```elixir
@spec set_correct_location(
  String.t(),
  keyword()
) :: :ok | {:error, term()}
```

Persist the file's current parent folder as its accepted location.

## Options

- `:actor_uuid` — UUID of the user performing the action (for activity logging)

# `split_text_and_image_values`

```elixir
@spec split_text_and_image_values(map()) :: {map(), map()}
```

Splits variable_values into text values and image specs.

# `sync_from_drive`

```elixir
@spec sync_from_drive() :: :ok | {:error, :sync_failed}
```

Sync local DB with Google Drive.

Recursively walks both managed trees (templates and documents), upserts every
Google Doc found (including those nested in subfolders) with its actual
parent `folder_id` and human-readable `path`, then reconciles DB records
against the walk — records whose `google_doc_id` is missing from the walk
are re-classified via a per-file Drive API call as `trashed`, `lost`, or
`unfiled` according to their current Drive parents.

Files in any descendant of a managed folder are treated as `published`.

# `templates_folder_url`

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

Get the Google Drive URL for the templates folder.

# `update_document_taxonomy`

```elixir
@spec update_document_taxonomy(String.t(), map(), keyword()) ::
  {:ok, map()} | {:error, term()}
```

Like `update_template_taxonomy/3` but for a Document.

# `update_preset`

```elixir
@spec update_preset(PhoenixKitDocumentCreator.Schemas.TemplatePreset.t(), map()) ::
  {:ok, PhoenixKitDocumentCreator.Schemas.TemplatePreset.t()}
  | {:error, Ecto.Changeset.t()}
```

Updates an existing preset from `attrs`.

# `update_template_language`

```elixir
@spec update_template_language(String.t(), String.t() | nil, keyword()) ::
  {:ok, PhoenixKitDocumentCreator.Schemas.Template.t()}
  | {:error, :not_found | Ecto.Changeset.t()}
```

Set the locale on a template, looked up by `google_doc_id`.

Pass `nil` (or an empty string) to clear the language. Otherwise the
full locale code (e.g. `"en-US"`, `"et-EE"`) — typically sourced from
`PhoenixKit.Modules.Languages.get_enabled_languages/0`.

Logs `template.language_updated` with the from/to pair on success and
broadcasts `:files_changed` so connected admin LiveViews resync.

## Options

- `:actor_uuid` — UUID of the user performing the action (activity log)

# `update_template_taxonomy`

```elixir
@spec update_template_taxonomy(String.t(), map(), keyword()) ::
  {:ok, map()} | {:error, term()}
```

Sets (or clears) the category and type of a template identified by
`google_doc_id`. `taxonomy` is a map with optional `:category_uuid`
and `:type_uuid` keys (`nil` clears). Logs a
`template.taxonomy_updated` activity row.

# `update_template_variable_config`

```elixir
@spec update_template_variable_config(String.t(), String.t(), map()) ::
  {:ok, PhoenixKitDocumentCreator.Schemas.Template.t()}
  | {:error, :not_found | :unknown_variable}
```

Update the `config` map of a single variable on a template's `variables` jsonb.

Merges `new_config` (string-keyed map) into the existing variable's config, coercing
integer-shaped strings to integers (for inputs from HTML form fields).

Uses an atomic SQL UPDATE with `jsonb_agg` to avoid the read-modify-write race
condition that would occur when two concurrent requests update different variables
on the same template.

Returns `{:ok, template}` on success, `{:error, :not_found}` if no template
matches the given file_id, or `{:error, :unknown_variable}` if the variable
name doesn't exist in the template's variables array.

# `upsert_document_from_drive`

```elixir
@spec upsert_document_from_drive(map(), map()) ::
  {:ok, PhoenixKitDocumentCreator.Schemas.Document.t()}
  | {:error, Ecto.Changeset.t()}
```

Upsert a document record from a Google Drive file map.

# `upsert_template_from_drive`

```elixir
@spec upsert_template_from_drive(map(), map()) ::
  {:ok, PhoenixKitDocumentCreator.Schemas.Template.t()}
  | {:error, Ecto.Changeset.t()}
```

Upsert a template record from a Google Drive file map.

---

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