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

Google Docs and Drive API client for the Document Creator module.

This module provides **direct Google Drive and Docs API access** without
touching the local database. Use it when you need raw Drive operations:
creating files, listing folders, moving files, exporting PDFs, reading
document content, and substituting template variables.

For combined Drive + DB operations, use `PhoenixKitDocumentCreator.Documents`.

## Capabilities

- **Folders**: `find_folder_by_name/2`, `create_folder/2`, `find_or_create_folder/2`,
  `ensure_folder_path/2`, `discover_folders/0`, `list_subfolders/1`
- **Files**: `list_folder_files/1`, `move_file/2`, `copy_file/3`, `create_document/2`
- **Docs**: `get_document/1`, `get_document_text/1`, `batch_update/2`, `replace_all_text/2`
- **Export**: `export_pdf/1`, `fetch_thumbnail/1`
- **Status**: `file_status/1`, `file_location/1`
- **URLs**: `get_edit_url/1`, `get_folder_url/1`

OAuth credentials and tokens are managed by `PhoenixKit.Integrations`
under the `"google"` provider. The module references the active
connection by uuid via the `"google_connection"` field in the
`"document_creator_settings"` row — `active_integration_uuid/0` is
the resolver. Pre-uuid values (`"google"` / `"google:name"` strings)
are auto-migrated to the matching integration row's uuid on first
read; the rewritten setting then drives all subsequent dispatches.
Folder configuration is stored separately under the
`"document_creator_folders"` settings key.

# `active_integration_uuid`

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

Returns the uuid of the active Google integration, or `nil` if none
has been chosen.

The settings value at `document_creator_settings.google_connection` is
expected to be a UUIDv7 (the integration row's storage uuid). Older
installs may have a legacy `"google"` or `"google:name"` string here;
this function detects that, resolves it to the matching integration's
uuid, rewrites the setting, and returns the uuid. Subsequent calls
read the migrated value directly.

# `append_template`

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

Append a template's content to an existing Google Doc via batchUpdate.

Inserts a page break followed by the full text content of `template_doc_id`
into `target_doc_id`. Returns `{:ok, {start_index, end_index}}` representing
the character range of the inserted content — callers use this for
section-scoped substitution.

# `batch_update`

```elixir
@spec batch_update(String.t(), [map()]) :: {:ok, map()} | {:error, term()}
```

Send a batchUpdate request to a Google Doc.

# `build_image_batch_requests`

```elixir
@spec build_image_batch_requests([map()], map()) :: [map()]
```

Builds the list of `batchUpdate` request maps to substitute image tags.

`fills` is a map keyed by variable name; each value carries `kind`,
`default_width_px`, `separator` (atom or nil), and `media` — a list of
`%{uri, width_px, height_px}`.

Empty media list = the tag is still deleted (cleared).

# `build_image_batch_requests`

```elixir
@spec build_image_batch_requests([map()], map(), number()) :: [map()]
```

# `build_single_image_request`

```elixir
@spec build_single_image_request(
  String.t(),
  keyword()
) :: map()
```

Builds a single image insert request map.

Options:
  - `:insertion_index` — document character index for insertion (required)
  - `:config` — map with `:default_width_px`, `:opacity`, `:z_index` (required)

When `z_index > 0`, emits a `createPositionedObject` with `layout = "WRAP_TEXT"`.
When `z_index <= 0`, emits `insertInlineImage` (default inline behaviour).
Opacity application requires a follow-up `UpdateEmbeddedObjectPropertiesRequest`
with the object ID returned by the batchUpdate response — not emitted here.
A Logger warning is written when `opacity != 1.0`. This is a documented
no-op (open risk) per the spec's "Open Risks" section: applying transparency
requires a second batchUpdate pass after the initial insert, using the
embedded object ID from the first response. Not yet implemented.

# `cached_folder_id_keys`

```elixir
@spec cached_folder_id_keys() :: [String.t()]
```

Folder-settings keys holding discovered folder IDs (cleared on config change).

# `connection_status`

```elixir
@spec connection_status() :: {:ok, %{email: String.t()}} | {:error, atom()}
```

Check if connected. Returns `{:ok, %{email: email}}` or `{:error, reason}`.

# `content_width_pt`

Page content width in points = pageSize.width − marginLeft − marginRight.
Falls back to 468pt (US Letter with 1" margins) if anything is missing.

# `copy_document`

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

Copy a Google Doc for use as the base of a composed document.

Returns `{:ok, new_doc_id}`. The copy is named by its source doc ID so it
can be identified for best-effort cleanup on rollback before a final name
is applied.

# `copy_file`

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

Copy a file in Google Drive. Returns the new file's ID.

# `create_document`

```elixir
@spec create_document(
  String.t(),
  keyword()
) ::
  {:ok, %{doc_id: String.t(), name: String.t(), url: String.t() | nil}}
  | {:error, :create_document_failed | term()}
```

Create a new blank Google Doc in a specific folder.

# `create_folder`

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

Create a folder in Google Drive. Optionally specify a parent folder.
Returns `{:ok, folder_id}`.

# `delete_document`

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

Delete (trash) a Google Doc. Used for best-effort cleanup after a failed composition.
Returns `:ok` or `{:error, reason}`.

# `discover_folders`

```elixir
@spec discover_folders() :: %{
  templates_folder_id: String.t() | nil,
  documents_folder_id: String.t() | nil,
  deleted_templates_folder_id: String.t() | nil,
  deleted_documents_folder_id: String.t() | nil
}
```

Discover templates, documents, and deleted folder IDs.
Looks for folders by name in Drive root, creating them if they don't exist.
Caches results in Settings.

# `document_content_range`

```elixir
@spec document_content_range(String.t()) :: {:ok, {1, integer()}} | {:error, term()}
```

Return the range `{1, end_index}` of the current content in a Google Doc.

Used by the Composer to pin section 0's range before any sections are appended.
The range starts at index 1 because Google Docs body content always begins at 1.

# `ensure_folder_path`

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

Walk a path like "clients/active/templates", creating folders as needed.
Returns `{:ok, leaf_folder_id}`.

# `export_pdf`

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

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

# `fetch_thumbnail`

```elixir
@spec fetch_thumbnail(term()) ::
  {:ok, String.t()}
  | {:error,
     :no_doc_id
     | :no_thumbnail
     | :thumbnail_link_failed
     | :thumbnail_fetch_failed
     | :invalid_file_id
     | term()}
```

Fetch a document thumbnail as a base64 data URI via the Drive API.

# `file_location`

```elixir
@spec file_location(term()) ::
  {:ok, %{folder_id: String.t(), path: String.t(), trashed: boolean()}}
  | {:error, :invalid_file_id | :not_found | term()}
```

Resolve the current parent folder and path for a Drive file.

# `file_status`

```elixir
@spec file_status(term()) ::
  {:ok, %{trashed: boolean(), parents: [String.t()]}}
  | {:ok, :not_found}
  | {:error, :invalid_file_id | term()}
```

Fetch Google Drive file metadata needed for sync classification.

# `fill_table_cells`

Phase B — emits insertInlineImage requests for each cell, last-first so
earlier inserts don't shift later indices. One image per cell; extra cells
beyond the media list are ignored.

# `find_folder_by_name`

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

Find a folder by name, optionally within a parent folder.
Returns `{:ok, folder_id}` or `{:error, :not_found}`.

# `find_image_tag_ranges`

```elixir
@spec find_image_tag_ranges(map(), [String.t()]) :: [
  %{name: String.t(), start_index: integer(), end_index: integer()}
]
```

Scans a `documents.get` response for image tag occurrences.

Returns a flat list of `%{name, start_index, end_index}` covering every
occurrence in body content, headers, footers, and table cells, restricted
to the names supplied.

**Offset note:** `Regex.scan(..., return: :index)` returns byte offsets;
Google Docs `startIndex` counts UTF-16 code units. The implementation
converts byte offsets to UTF-16 code-unit counts via
`:unicode.characters_to_binary/3` so supplementary-plane codepoints
(emoji, rare CJK) are counted as the two units a surrogate pair occupies.

# `find_or_create_folder`

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

Find a folder by name, or create it if it doesn't exist.
Optionally specify a parent folder.
Returns `{:ok, folder_id}`.

# `folder_settings_key`

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

The Settings key used for folder configuration.

# `get_credentials`

```elixir
@spec get_credentials() :: {:ok, map()} | {:error, atom()}
```

Get stored OAuth credentials via PhoenixKit.Integrations.

# `get_document`

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

Read a Google Doc's full content.

# `get_document_text`

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

Extract plain text content from a Google Doc (for variable detection).

# `get_edit_url`

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

Get the edit URL for a Google Doc.

# `get_folder_config`

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

Get configured folder paths and names from Settings, with defaults.

# `get_folder_ids`

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

Get cached folder IDs from Settings, or discover them.

# `get_folder_url`

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

Get the Google Drive folder URL.

# `image_width_for_columns`

Per-image width in points for N columns sharing `content_width_pt`.

# `list_folder_files`

```elixir
@spec list_folder_files(String.t()) :: {:ok, [map()]} | {:error, term()}
```

List Google Docs directly in a Drive folder (non-recursive, fully paginated).

Returns `{:ok, [%{"id" => ..., "name" => ..., "modifiedTime" => ..., "thumbnailLink" => ..., "parents" => [...]}]}`.

For recursive traversal across subfolders, use
`PhoenixKitDocumentCreator.GoogleDocsClient.DriveWalker.walk_tree/2`.

# `list_subfolders`

```elixir
@spec list_subfolders(String.t()) :: {:ok, [map()]} | {:error, term()}
```

List subfolders within a parent folder (non-recursive, fully paginated).
Returns `{:ok, [%{"id" => ..., "name" => ...}]}`.

# `match_new_tables`

```elixir
@spec match_new_tables([map()], [non_neg_integer()], [non_neg_integer()]) ::
  {:ok, [map()]} | :mismatch
```

Identifies which of `tables_asc` (table elements from the re-fetched document,
ascending by start index) are the tables Phase 1 just inserted, returning them
in slot order.

`pre_existing_starts` and `new_slot_starts` are start indices captured from the
*pre-Phase-1* document. Phase 1's deletes/inserts shift the absolute indices of
everything after a placeholder, so a startIndex set-difference misclassifies a
pre-existing table located after a placeholder (its index moves and no longer
matches the snapshot). Table *order* is never changed by inserts, though, so we
reconstruct the pre/new interleaving from the original indices and read it off
the post-Phase-1 tables positionally — robust regardless of where pre-existing
tables sit relative to the placeholders.

Returns `{:ok, new_tables}` aligned with `new_slot_starts` sorted ascending, or
`:mismatch` when the table count doesn't line up (e.g. Phase 1 partially
failed) so the caller can skip filling rather than fill the wrong tables.

# `migrate_folders_to_root`

```elixir
@spec migrate_folders_to_root(String.t()) ::
  {:ok, %{moved: [String.t()], skipped: [String.t()]}}
  | {:error, [{String.t(), term()}]}
```

Move the top-level Drive folders (templates, documents, deleted) into
`root_folder_id`. For each folder the cached ID is tried first; when absent,
the folder is located by name in the Drive root. Moving the deleted folder
carries its sub-folders along automatically.

Clears cached folder IDs on full success so they are re-discovered from the
new location on next use.

Returns `{:ok, %{moved: [labels], skipped: [labels]}}` or
`{:error, [{label, reason}]}` if any move fails.

# `move_file`

```elixir
@spec move_file(String.t(), String.t()) ::
  :ok
  | {:error,
     :invalid_file_id
     | :move_failed
     | :get_file_parents_failed
     | :drive_file_not_found
     | term()}
```

Move a file to a different folder in Google Drive.

# `rename_file`

```elixir
@spec rename_file(String.t(), String.t()) ::
  :ok | {:error, :invalid_file_id | :rename_failed | term()}
```

Rename a file in Google Drive.

# `replace_all_text`

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

Replace all `{{variable}}` placeholders in a Google Doc.
Keys are wrapped in `{{ }}` automatically.

# `resolved_folder_paths`

```elixir
@spec resolved_folder_paths(map()) :: {String.t(), String.t(), String.t()}
```

Compute the three Drive paths (templates, documents, deleted) given a folder config map.

# `substitute_all_sections`

```elixir
@spec substitute_all_sections(String.t(), [map()], %{
  required(non_neg_integer()) =&gt; {integer(), integer()}
}) :: :ok | {:error, term()}
```

Substitute all sections' variables and image params into a Google Doc in a
single atomic pass per phase (text then image).

`sections` is a list of `%{position, variable_values, image_params}` maps.
`ranges` maps each section position to its `{start_index, end_index}` in the
document. All positions must have a range entry — section 0's range must be
provided explicitly (use `document_content_range/1` after copy, before append).

Each `{{key}}` placeholder in the document is matched against the section whose
range contains it; that section's `variable_values[key]` supplies the replacement.
Placeholders outside all section ranges are left untouched.

Text substitution runs before image substitution (per image-substitution.md) and
the document is re-fetched between the two phases so image indices are accurate
after text edits. All operations within a phase are batched in a single
batchUpdate in reverse-index order so no substitution shifts the indices of
another.

# `substitute_images`

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

Two-step image substitution: GET the document, build the batch, send it.

`fills` is the same shape as `build_image_batch_requests/2`.

Options (used in tests):
  * `:get_fn` — overrides `get_document/1`
  * `:batch_fn` — overrides `batch_update/2`

# `table_image_inserts`

Phase A — emits batchUpdate requests that delete the placeholder range and
create a Google Docs table at its start index. After a doc re-fetch,
`fill_table_cells/3` populates the table.

# `upload_image_for_embedding`

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

Upload a raw image binary to Drive and return a public, embeddable URL.

Used when inserting an image into a Google Doc via `insertInlineImage`,
which requires a fetchable URL (not raw bytes). Uploads the binary to
Drive, grants anyone-with-link read access, and returns an
`lh3.googleusercontent.com/d/<id>` URL that Google's image fetcher can
read without following a redirect.

  - `data` — raw image bytes
  - `mime_type` — MIME type string, e.g. `"image/jpeg"`
  - `opts` — optional keyword list; supports `:name` (file name, defaults to
    `"embed-image"`)

# `validate_file_id`

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

Validate a Google Drive file/folder ID. Returns `{:ok, id}` or `{:error, :invalid_file_id}`.

---

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