PhoenixKitDocumentCreator.GoogleDocsClient (PhoenixKitDocumentCreator v0.4.2)

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

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.

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.

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.

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

@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(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 | :pdf_export_failed | term()}

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

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

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.

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.

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.

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

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