PhoenixKitDocumentCreator.GoogleDocsClient (PhoenixKitDocumentCreator v0.9.8)

Copy Markdown View Source

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

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.

Summary

Functions

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

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

Send a batchUpdate request to a Google Doc.

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

Builds a single image insert request map.

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

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

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

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

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

Create a new blank Google Doc in a specific folder.

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

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

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.

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

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

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

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

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

Fetch Google Drive file metadata needed for sync classification.

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 a folder by name, optionally within a parent folder. Returns {:ok, folder_id} or {:error, :not_found}.

Scans a documents.get response for image tag occurrences.

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

Phase 1a of the append-with-tables pipeline (see append_template/3): locate flatten_template_with_table_markers/1 marker tokens in an already-fetched document. Mirrors find_text_var_ranges/2's UTF-16 index arithmetic (Regex.scan(..., return: :index) yields byte offsets; Google Docs indices count UTF-16 code units).

Phase 0 of the append-with-tables pipeline (see append_template/3).

Same walk as flatten_template_with_table_markers/1, additionally capturing per-run character style (bold, italic, font size, foreground color) and per-paragraph style (alignment, line spacing, space above/below, named style type, indentation, list bullet) — see append_template/3's "Formatting fidelity" doc section.

The Settings key used for folder configuration.

Get stored OAuth credentials via PhoenixKit.Integrations.

Read a Google Doc's full content.

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

Get the edit URL for a Google Doc.

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

Get cached folder IDs from Settings, or discover them.

Get the Google Drive folder URL.

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

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

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

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.

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.

Move a file to a different folder in Google Drive.

Builds createParagraphBullets requests replaying captured list membership (see extract_bullet_info/2's doc for what "replaying" means here — glyph family, not exact glyph/format). Contiguous spans (no gap in offsets, same source listId) are merged into a single request spanning the whole run, so consecutive list items land in one target list (numbered items continuing count 1, 2, 3, ... instead of each restarting at 1) rather than length(spans) separate single-paragraph lists.

Builds updateParagraphStyle requests replaying captured paragraph style (alignment, line spacing, space above/below, named style type, start/first-line indentation — see flatten_template_with_table_markers_and_styles/1's cell_paragraphs / body_paragraphs), anchored at base_index the same way text_style_requests/2 anchors character runs.

Rename a file in Google Drive.

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

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

Builds the updateSectionStyle request that gives an appended section its template's own page margins. section_index is any index inside the section — append_template/3 passes the section's content_start.

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

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

Column-width companion used by build_table_fill_requests/3 (Phase 2, NOT the Phase 1b skeleton batch — see below). table_start_index must be a table's real, post-insert startIndex from a re-fetched document (the same value extract_table_cells/1's caller already has via match_new_tables/3's matched table element), never the location.index an insertTable request was given. column_properties is a table's captured column_properties list (see flatten_template_with_table_markers_and_styles/1), index-aligned to the table's columns.

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.

Phase 1b of the append-with-tables pipeline (see append_template/3): for each located marker, delete the marker text and insert a bare table of its captured dimensions at that position. Sorted descending by start_index (same convention as collect_text_replacements/3 and build_image_batch_requests/3) so earlier replacements in the list don't shift the indices of markers still to be processed.

Builds updateTextStyle requests replaying captured per-run character style (see flatten_template_with_table_markers_and_styles/1's cell_runs / body_runs), anchored at base_index — the same index the corresponding insertText used. Adjacent runs sharing identical style are merged into one range first, but every non-empty run is still covered, including a plain run's explicit bold: false/italic: false — this is deliberate: a freshly inserted blob of text otherwise inherits its style from whatever character precedes it in the target document, so leaving a plain run unstyled would silently pick up bold/italic from neighboring content (seen live: an appended section's plain paragraph inheriting bold from an adjacent heading).

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

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

Functions

active_integration_uuid()

@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(target_doc_id, template_doc_id, opts \\ [])

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

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

Inserts a section break (next page), then the content of template_doc_id into target_doc_id, then gives the new section the template's own page margins. Returns {:ok, {start_index, end_index}} representing the character range of the inserted content — callers use this for section-scoped substitution.

Paragraph text is inserted via a single insertText, same as before this function also handled tables. Tables are NOT part of that flattened text — there is no Docs API primitive for "paste another document's table here" — so they are rebuilt in two extra batchUpdate passes, reusing the exact pattern already proven for image-grid tables (table_image_inserts/3 -> re-fetch -> match_new_tables/3 -> fill_table_cells/3):

  1. flatten_template_with_table_markers/1 walks the template like get_document_text/1 does, but emits a unique marker token at each table's position (instead of silently dropping it) and captures the table's {rows, columns, cell text} separately, in document order. The marked-up text is inserted via the same single insertText as before.
  2. Re-fetch, locate the markers (find_table_marker_ranges/1), and replace each with a bare table of the right size (table_skeleton_requests/2) in one batchUpdate.
  3. Re-fetch again, identify the newly-inserted tables (match_new_tables/3, reused as-is), and fill every cell with its captured text, character and paragraph style, and column widths (build_table_fill_requests/3, private) in one batchUpdate. Any {{var}} placeholder that lived inside a table cell is now physically present in the document, so it substitutes normally in Composer.apply_substitutions/4 like any other text.

Templates with no tables skip steps 2-3 entirely — this reduces to exactly the previous single-insertText behaviour, with no extra Docs API calls.

Formatting fidelity: table column widths (tableStyle.tableColumnProperties, fixed-width columns only — evenly distributed is insertTable's own default) are captured during flatten and replayed via updateTableColumnProperties in the Phase 2 (cell-fill) batch, targeting the table's real post-insert startIndex from the re-fetch already done for cell matching — NOT the Phase 1b skeleton batch, since insertTable's own location index cannot be trusted as the resulting table's position (verified live: Google inserts an implicit paragraph break ahead of a table landing mid-paragraph, shifting its real startIndex by one from the requested location — see table_column_width_requests/2's doc). Per-run character style — bold, italic, font size, foreground color — is captured for both table cell text and the section's own (non-table) body text, and replayed via updateTextStyle in the same batch as the corresponding insertText (after that text's paragraph style — see below). Every inserted character is covered by an explicit range, including bold: false/italic: false for plain runs, so freshly inserted text can never silently inherit formatting from neighboring content already in the target document.

Paragraph-level style — alignment, line spacing, space above/below, named style type (headings), start/first-line indentation — is captured the same way and replayed via updateParagraphStyle (paragraph_style_requests/2), same anti-inheritance guarantee: every field is always in the mask — with the template's value, or unset so it resolves against the paragraph's named style in the target document. Paragraph style is always sent BEFORE character style (paragraph_then_text_style_requests/3): an updateParagraphStyle whose mask includes namedStyleType resets the paragraph's text style, even when the named style doesn't change, so the opposite order silently stripped every appended section of its font sizes and bold. That reset is not in Google's public API reference — verified live 2026-09-21, and no mock-based test can guard it. List bullets are replayed via createParagraphBullets (paragraph_bullet_requests/2), resolving bulleted vs numbered from the source and mapping to Google's own default preset for that family — this reproduces glyph family, not an arbitrary custom glyph/format exactly (see extract_bullet_info/2's doc).

Each appended template becomes its own document SECTION: the content is preceded by insertSectionBreak (NEXT_PAGE, so it still starts on a new page) rather than a page break, and the section then gets the template's own page margins via updateSectionStyle (section_margin_requests/2). Margins are a document-level setting otherwise, so a contract laid out for 72pt margins used to be poured into whatever the first template's were. The margins ride in the same atomic batch as the content, on purpose: a composed document with the wrong margins is the very defect this exists to prevent, so a margin request Google rejects fails the append (and the compose) loudly rather than leaving a quietly mis-laid-out document behind. Traps (the first two verified live 2026-09-21):

  • A section break inserts a newline ahead of itself, so the appended content starts at insert_index + 2 — in a fresh, empty paragraph of the new section. That fresh paragraph is what makes paragraph-level styling safe for the section's own first paragraph: updateParagraphStyle/createParagraphBullets target whole paragraphs, and content that merely continued the target's last paragraph would reformat the preceding section's trailing text too. (The target's closing character is the document's shared terminal marker, not a shiftable paragraph separator — a page break alone, an inline element, never split it, which is why this used to insert its own "\n" first.)
  • updateDocumentStyle on margins overwrites the margins of EVERY section, silently. Nothing here sends it; anything that ever does must run before the section margins are set.
  • Page size is document-wide in the API — a section cannot have its own.

Known limitations: cell shading, borders, and merged cells are not restored — insertTable creates a bare table beyond the column widths above. Nested tables (a table inside a table cell) are not supported.

Options (used in tests):

  • :get_fn — overrides get_document/1 (used for both the template fetch and every target-document re-fetch)
  • :batch_fn — overrides batch_update/2

batch_update(doc_id, requests)

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

Send a batchUpdate request to a Google Doc.

build_image_batch_requests(ranges, fills)

@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(ranges, fills, content_width_pt)

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

build_single_image_request(uri, opts)

@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()

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

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

connection_status()

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

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

content_width_pt(document)

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

copy_document(source_doc_id, opts \\ [])

@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(file_id, new_name, opts \\ [])

@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(title, opts \\ [])

@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(name, opts \\ [])

@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(doc_id)

@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()

@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(doc_id)

@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(path, opts \\ [])

@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(doc_id)

@spec export_pdf(String.t()) ::
  {:ok, binary()}
  | {:error,
     :invalid_file_id
     | :drive_export_too_large
     | :drive_file_not_found
     | :drive_forbidden
     | :drive_rate_limited
     | :pdf_export_failed
     | term()}

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

On failure the reason names why Drive refused the export instead of collapsing every case into :pdf_export_failed:

  • :drive_file_not_found — Drive returned 404. Usually the file was deleted, but Drive also answers 404 for a live file the current connection is not allowed to see (an unshare, or a re-pointed Google connection), so callers must not treat it as proof the file is gone.
  • :drive_forbidden — Drive returned 403 because the connected Google account lacks permission to read the file
  • :drive_rate_limited — Drive returned 403 because of a rate/quota limit (retrying later can succeed)
  • :drive_export_too_large — the Doc is past the export endpoint's size cap (about 10 MB of PDF) AND the fallback below failed too
  • :pdf_export_failed — any other non-200 response, including a 403 with an unrecognized reason

Past that cap files.export answers 403 exportSizeLimitExceeded; the same PDF is then downloaded from the file's exportLinks (a docs.google.com URL that has no such cap) with the same credentials. The token is only ever sent to an https://docs.google.com link.

fetch_thumbnail(doc_id)

@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(file_id)

@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(file_id)

@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(cells, media, map)

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(name, opts \\ [])

@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(doc, names)

@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(name, opts \\ [])

@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}.

find_table_marker_ranges(doc)

@spec find_table_marker_ranges(map()) :: [
  %{marker_index: integer(), start_index: integer(), end_index: integer()}
]

Phase 1a of the append-with-tables pipeline (see append_template/3): locate flatten_template_with_table_markers/1 marker tokens in an already-fetched document. Mirrors find_text_var_ranges/2's UTF-16 index arithmetic (Regex.scan(..., return: :index) yields byte offsets; Google Docs indices count UTF-16 code units).

flatten_template_with_table_markers(doc)

@spec flatten_template_with_table_markers(map()) :: {String.t(), [map()]}

Phase 0 of the append-with-tables pipeline (see append_template/3).

Flattens a template document's body the same way get_document_text/1 does for paragraphs, but instead of silently skipping table blocks, emits a unique marker token at each table's position and captures its structure separately.

This is a distinct code path from get_document_text/1 — that function's existing behaviour (silently skipping tables) is relied on by its other callers (Documents.detect_variables/1, Documents.image_slots_for_template/1) and is intentionally left untouched.

Returns {text, tables}. tables is a list of %{marker_index: pos_integer(), rows: pos_integer(), columns: pos_integer(), cell_texts: [String.t()]}, one entry per table, in document order. cell_texts is row-major (row 0's cells left-to-right, then row 1's, ...), one entry per table cell — the same order extract_table_cells/1 and the cell-fill phase (build_table_fill_requests/3, both private) enumerate cells in.

Nested tables (a table inside a table cell) are not supported: a cell's text is captured from its paragraph blocks only, same limitation the top-level flatten has.

This is a thin wrapper around flatten_template_with_table_markers_and_styles/1 that drops its 3rd/4th return values (body_runs/body_paragraphs) — kept at its original 2-tuple arity so existing callers are unaffected by the style-capture addition.

flatten_template_with_table_markers_and_styles(doc)

@spec flatten_template_with_table_markers_and_styles(map()) ::
  {String.t(), [map()], [map()], [map()]}

Same walk as flatten_template_with_table_markers/1, additionally capturing per-run character style (bold, italic, font size, foreground color) and per-paragraph style (alignment, line spacing, space above/below, named style type, indentation, list bullet) — see append_template/3's "Formatting fidelity" doc section.

Returns {text, tables, body_runs, body_paragraphs}:

  • text, tables — identical to flatten_template_with_table_markers/1, except each table map in tables gains three keys: column_properties (the source table's tableStyle.tableColumnProperties, normalized to %{width_type, magnitude, unit}, one per column, [] if the source table has none), cell_runs (one run-list per cell, parallel to cell_texts, same order — see text_style_requests/2's run shape) and cell_paragraphs (one paragraph-span-list per cell, parallel to cell_texts, same order — see paragraph_style_requests/2's span shape).
  • body_runs — the non-table body text's per-run style spans, same run shape as a cell_runs entry, with start_offset/length in UTF-16 units relative to the start of text (table markers consume offset but contribute no run — they're deleted before any style request referencing them would apply).
  • body_paragraphs — the non-table body text's per-paragraph style spans, same shape as a cell_paragraphs entry, offsets relative to the start of text.

folder_settings_key()

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

The Settings key used for folder configuration.

get_credentials()

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

Get stored OAuth credentials via PhoenixKit.Integrations.

get_document(doc_id)

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

Read a Google Doc's full content.

get_document_text(doc_id)

@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(doc_id)

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

Get the edit URL for a Google Doc.

get_folder_config()

@spec get_folder_config() :: map()

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

get_folder_ids()

@spec get_folder_ids() :: map()

Get cached folder IDs from Settings, or discover them.

get_folder_url(folder_id)

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

Get the Google Drive folder URL.

image_width_for_columns(content_width_pt, columns)

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

list_folder_files(folder_id)

@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(parent_id \\ "root")

@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(tables_asc, pre_existing_starts, new_slot_starts)

@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(root_folder_id)

@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(file_id, to_folder_id)

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

paragraph_bullet_requests(base_index, spans)

@spec paragraph_bullet_requests(integer(), [map()]) :: [map()]

Builds createParagraphBullets requests replaying captured list membership (see extract_bullet_info/2's doc for what "replaying" means here — glyph family, not exact glyph/format). Contiguous spans (no gap in offsets, same source listId) are merged into a single request spanning the whole run, so consecutive list items land in one target list (numbered items continuing count 1, 2, 3, ... instead of each restarting at 1) rather than length(spans) separate single-paragraph lists.

Spans with no bullet (not a list item) or zero length (see paragraph_style_requests/2) are excluded before grouping.

Requests are emitted in DESCENDING range order: createParagraphBullets strips leading tabs from paragraphs in its range, which shifts every later index — applying the highest range first means any shift lands only below ranges that are already done, the same reasoning as every other index-shifting pass in this module.

paragraph_style_requests(base_index, spans)

@spec paragraph_style_requests(integer(), [map()]) :: [map()]

Builds updateParagraphStyle requests replaying captured paragraph style (alignment, line spacing, space above/below, named style type, start/first-line indentation — see flatten_template_with_table_markers_and_styles/1's cell_paragraphs / body_paragraphs), anchored at base_index the same way text_style_requests/2 anchors character runs.

Every field is always included in the request's fields mask — the same anti-inheritance guarantee text_style_fields/1 applies to bold/italic. A property the template paragraph doesn't set (captured as nil) is left out of the payload, which the Docs API reads as "unset": it then resolves against the paragraph's named style in the target document (the same result as in the template wherever the two documents' named styles agree — see extract_paragraph_style/2), instead of being pinned to a concrete value the template never asked for. Without the complete mask, a newly split paragraph in the target document would inherit alignment/spacing/named style from whatever paragraph sat at the insertion point (e.g. an appended section's plain paragraph picking up CENTER alignment from a neighboring heading), not from the source template.

Unlike text_style_requests/2, spans are never merged — each paragraph gets its own request, since paragraphs are already discrete units (no benefit to coalescing, even when two adjacent ones share identical style). A span whose length is 0 (a paragraph with no textRun at all — doesn't happen for a real, non-empty paragraph, since even a blank line carries a "\n"-only run) is skipped: with zero characters, it contributes no offset and doesn't exist as distinct content in the inserted text either.

Ranges deliberately use the paragraph's own natural (un-stripped) length, even for a table cell's last paragraph whose text had its trailing newline stripped before insertion (see cell_fill_requests/4) — the cell's pre-existing bare paragraph supplies that newline structurally either way, so the natural length lands the range exactly on it. See cell_paragraph_spans/2's doc for the full argument.

Safe to batch with the insert/fill it styles: like character style changes, paragraph style changes never shift document indices. They DO reset the text style of the paragraphs they touch (the mask includes namedStyleType), so for the same range they must come BEFORE text_style_requests/2 — use paragraph_then_text_style_requests/3.

rename_file(file_id, new_name)

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

Rename a file in Google Drive.

replace_all_text(doc_id, variables)

@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(config)

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

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

section_margin_requests(section_index, template_doc)

@spec section_margin_requests(non_neg_integer(), map()) :: [map()]

Builds the updateSectionStyle request that gives an appended section its template's own page margins. section_index is any index inside the section — append_template/3 passes the section's content_start.

The margins are the ones the template's own first page renders with: its first section's sectionStyle.margin* where set, else documentStyle.margin* — the API's own resolution order (a section margin left unset defaults to the document's). A template that is itself a composed document carries per-section margins this way, and reading documentStyle alone would hand its first section the wrong ones.

Only the margins the template actually states are touched; a margin present without a magnitude is an explicit zero (the API omits a zero magnitude from its JSON — see dimension_or_nil/1). No margins in either place produces no request.

substitute_all_sections(doc_id, sections, ranges)

@spec substitute_all_sections(String.t(), [map()], %{
  required(non_neg_integer()) => {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. Section ranges are also recalculated (shift_ranges/2) by the net UTF-16 delta of every body text replacement, so the image phase matches markers against boundaries that reflect the edited document rather than the original one.

Headers and footers get their own pass. A composed document only ever inherits the headers/footers of its first section (copy_document/2 copies them; append_template/3 appends body content only), so a {{key}} found there is resolved against whichever section has the lowest position — never by range containment, since header/footer content has no body index at all. Each header/footer segment has its own Docs index space (a Docs segmentId), independent of the body's, so those replacements are excluded from shift_ranges/2 and sent as their own segmentId-scoped requests.

substitute_images(doc_id, fills, opts \\ [])

@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):

table_column_width_requests(table_start_index, column_properties)

@spec table_column_width_requests(integer(), [map()]) :: [map()]

Column-width companion used by build_table_fill_requests/3 (Phase 2, NOT the Phase 1b skeleton batch — see below). table_start_index must be a table's real, post-insert startIndex from a re-fetched document (the same value extract_table_cells/1's caller already has via match_new_tables/3's matched table element), never the location.index an insertTable request was given. column_properties is a table's captured column_properties list (see flatten_template_with_table_markers_and_styles/1), index-aligned to the table's columns.

Verified live against the real Docs API: insertTable at location.index = 8 produced a table whose actual startIndex was 9, one past the requested location — Google inserts an implicit paragraph break ahead of a table landing mid-paragraph, and updateTableColumnProperties rejects the un-adjusted index with INVALID_ARGUMENT: The provided table start location is invalid. This is exactly why finish_append_template/6 re-fetches after the skeleton batch before filling cells — this function rides along on that same re-fetch instead of trying to predict the offset analytically.

Only FIXED_WIDTH columns produce a request — EVENLY_DISTRIBUTED (or a column with no captured property) is already what a bare insertTable produces, so emitting a request for it would be a no-op round trip. One request per fixed column (the Docs API's columnIndices field lets one request retarget several columns sharing an identical width, but per- column source widths are rarely identical in practice, so this keeps the mapping simple and correct over minimizing request count).

table_image_inserts(map, media, opts)

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.

table_skeleton_requests(marker_ranges, tables_by_index)

@spec table_skeleton_requests([map()], %{required(integer()) => map()}) :: [map()]

Phase 1b of the append-with-tables pipeline (see append_template/3): for each located marker, delete the marker text and insert a bare table of its captured dimensions at that position. Sorted descending by start_index (same convention as collect_text_replacements/3 and build_image_batch_requests/3) so earlier replacements in the list don't shift the indices of markers still to be processed.

Column widths are deliberately NOT applied here even though the table's captured column_properties are available at this point — see table_column_width_requests/2's doc for why insertTable's location index cannot be trusted as the resulting table's real tableStartLocation.

text_style_requests(base_index, runs)

@spec text_style_requests(integer(), [map()]) :: [map()]

Builds updateTextStyle requests replaying captured per-run character style (see flatten_template_with_table_markers_and_styles/1's cell_runs / body_runs), anchored at base_index — the same index the corresponding insertText used. Adjacent runs sharing identical style are merged into one range first, but every non-empty run is still covered, including a plain run's explicit bold: false/italic: false — this is deliberate: a freshly inserted blob of text otherwise inherits its style from whatever character precedes it in the target document, so leaving a plain run unstyled would silently pick up bold/italic from neighboring content (seen live: an appended section's plain paragraph inheriting bold from an adjacent heading).

Safe to batch with the insert it styles: text style changes never shift document character indices, so nothing else in the same batch needs to account for these requests' presence. One ordering contract: for the same range these must come AFTER paragraph_style_requests/2, which resets text style — use paragraph_then_text_style_requests/3.

upload_image_for_embedding(data, mime_type, opts \\ [])

@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>=s4096 URL that Google's image fetcher can read without following a redirect.

The =s4096 suffix matters: a bare lh3…/d/<id> serves a copy scaled down to 1600px on the long side, so every larger image lost detail in the document and its PDF (a 4000×2884 upload reached the PDF as 1600×1154). =sN returns the original bytes when the long side is at most N and never enlarges; 4096 rather than =s0 because insertInlineImage rejects images over 25 megapixels, and a 4096px long side stays under that for any aspect ratio. Google's own PDF export stores images at up to 2500px on the long side, so 4096 loses nothing there.

  • 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(id)

@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}.