Skip to content

Instantly share code, notes, and snippets.

@punit1108
Created March 24, 2026 14:51
Show Gist options
  • Select an option

  • Save punit1108/bd7f13d1477d6aa0bc5b8cc64120d9e1 to your computer and use it in GitHub Desktop.

Select an option

Save punit1108/bd7f13d1477d6aa0bc5b8cc64120d9e1 to your computer and use it in GitHub Desktop.
coda_cloner.sh
#!/usr/bin/env bash
# Copy a Coda page to a local folder by exporting it.
#
# Primary input: a Coda browser URL (doc or page). The script calls resolveBrowserLink,
# then exports the resolved page (or the only page if the URL opens the doc with one page).
#
# API flow:
# 0) GET /resolveBrowserLink?url=... (when using a browser URL)
# 1) GET /docs/{docId}/pages/{pageIdOrName}
# 2) POST /docs/{docId}/pages/{pageIdOrName}/export
# 3) GET /docs/{docId}/pages/{pageIdOrName}/export/{requestId} (poll)
# 4) Download downloadLink to disk
#
# Requires: curl, jq
#
# Usage:
# ./copy_coda_page.sh "https://coda.io/d/_dAbCDeFGH/Launch-Status_sumnO" \
# --token "<CODA_API_TOKEN>" \
# --output-dir ./exports \
# --format markdown
#
# ./copy_coda_page.sh --url "https://coda.io/d/_dDOCID/Page-slug" ...
#
# Legacy (IDs instead of URL):
# ./copy_coda_page.sh --doc-id "<DOC_ID>" --page-id "<PAGE_ID>" ...
#
# Or: export CODA_API_TOKEN / CODA_API_KEY, or put CODA_API_KEY=... in .env
# (script loads .env from its directory, then the current working directory)
set -euo pipefail
BASE_URL="https://coda.io/apis/v1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
die() {
echo "Error: $*" >&2
exit 1
}
# Load KEY=value lines from the first existing file (no command substitution / sourcing).
load_dotenv_file() {
local f line key val
f="$1"
[[ -f "$f" ]] || return 1
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line//[[:space:]]/}" ]] && continue
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || continue
key="${BASH_REMATCH[1]}"
val="${BASH_REMATCH[2]}"
val="${val%"${val##*[![:space:]]}"}"
val="${val#"${val%%[![:space:]]*}"}"
if [[ "$val" =~ ^\"(.*)\"$ ]]; then
val="${BASH_REMATCH[1]}"
elif [[ "$val" =~ ^\'(.*)\'$ ]]; then
val="${BASH_REMATCH[1]}"
fi
export "${key}=${val}"
done <"$f"
return 0
}
load_dotenv() {
load_dotenv_file "${SCRIPT_DIR}/.env" || load_dotenv_file "${PWD}/.env" || true
}
slugify() {
echo "$1" | tr '[:upper:]' '[:lower:]' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \
| sed 's/[^a-z0-9._-]/-/g' | sed 's/--*/-/g' | sed 's/^-*//;s/-*$//'
}
# Encode a path segment only when it contains characters that need encoding.
# Coda resource IDs (e.g. canvas-IjkLmnO) are already URL-safe and must NOT
# be percent-encoded — doing so produces a path Coda cannot resolve (404).
uri_encode_segment() {
local s="$1"
if [[ "$s" =~ ^[A-Za-z0-9_.~-]+$ ]]; then
printf '%s' "$s"
else
printf '%s' "$s" | jq -sRr @uri
fi
}
URL=""
DOC_ID=""
PAGE_ID=""
TOKEN=""
OUTPUT_DIR="."
FORMAT="markdown"
FILENAME=""
POLL_INTERVAL="2"
TIMEOUT="180"
while [[ $# -gt 0 ]]; do
case "$1" in
--url)
URL="${2:-}"
shift 2
;;
--doc-id)
DOC_ID="${2:-}"
shift 2
;;
--page-id)
PAGE_ID="${2:-}"
shift 2
;;
--token)
TOKEN="${2:-}"
shift 2
;;
--output-dir)
OUTPUT_DIR="${2:-}"
shift 2
;;
--format)
FORMAT="${2:-}"
shift 2
;;
--filename)
FILENAME="${2:-}"
shift 2
;;
--poll-interval)
POLL_INTERVAL="${2:-}"
shift 2
;;
--timeout)
TIMEOUT="${2:-}"
shift 2
;;
-h | --help)
sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
http://* | https://*)
[[ -z "$URL" ]] || die "Multiple URLs provided; use one Coda link"
URL="$1"
shift
;;
*)
die "Unknown option: $1"
;;
esac
done
# Token: --token > environment > .env (CODA_API_TOKEN or CODA_API_KEY)
if [[ -z "$TOKEN" ]]; then
TOKEN="${CODA_API_TOKEN:-${CODA_API_KEY:-}}"
fi
if [[ -z "$TOKEN" ]]; then
load_dotenv
TOKEN="${CODA_API_TOKEN:-${CODA_API_KEY:-}}"
fi
[[ -n "$TOKEN" ]] || die "provide --token, export CODA_API_TOKEN or CODA_API_KEY, or set one in .env (next to this script or cwd)"
case "$FORMAT" in
markdown | html) ;;
*) die "--format must be markdown or html" ;;
esac
command -v curl >/dev/null 2>&1 || die "curl is required"
command -v jq >/dev/null 2>&1 || die "jq is required"
AUTH_HEADER="Authorization: Bearer ${TOKEN}"
HTTP_LAST_CODE=""
HTTP_LAST_BODY=""
# Performs a GET and sets HTTP_LAST_CODE / HTTP_LAST_BODY.
# Returns 0 on 2xx/3xx, 1 on 4xx/5xx or curl failure.
http_get_json() {
local url="$1"
local raw code
HTTP_LAST_CODE=""
HTTP_LAST_BODY=""
raw="$(curl -sS -w "\n%{http_code}" -H "$AUTH_HEADER" "$url" 2>&1)" || {
HTTP_LAST_BODY="$raw"
return 1
}
code="$(printf '%s' "$raw" | tail -n1)"
HTTP_LAST_BODY="$(printf '%s' "$raw" | sed '$d')"
HTTP_LAST_CODE="$code"
if [[ "$code" -ge 400 ]]; then
echo "HTTP $code: $HTTP_LAST_BODY" >&2
return 1
fi
echo "$HTTP_LAST_BODY"
}
http_post_json() {
local url="$1"
local payload="$2"
local code body
body="$(curl -sS -w "\n%{http_code}" -X POST \
-H "$AUTH_HEADER" \
-H "Content-Type: application/json" \
-d "$payload" \
"$url")" || return 1
code="$(echo "$body" | tail -n1)"
body="$(echo "$body" | sed '$d')"
if [[ "$code" -ge 400 ]]; then
echo "HTTP $code: $body" >&2
return 1
fi
echo "$body"
}
# Encode value for query string (e.g. resolveBrowserLink?url=...)
uri_encode_query_value() {
printf '%s' "$1" | jq -sRr @uri
}
# From a Coda browser URL, set DOC_ID and PAGE_ID via resolveBrowserLink + optional pages list
resolve_url_to_doc_page() {
local browser_url="$1"
local q resolve_json api_href
q="$(uri_encode_query_value "$browser_url")"
resolve_json="$(http_get_json "${BASE_URL}/resolveBrowserLink?url=${q}")" || return 1
api_href="$(echo "$resolve_json" | jq -r '.resource.href // empty')"
local resource_type
resource_type="$(echo "$resolve_json" | jq -r '.resource.type // empty')"
[[ -n "$api_href" ]] || die "resolveBrowserLink returned no resource.href: $resolve_json"
echo " Resolved type=${resource_type} href=${api_href}" >&2
if [[ "$api_href" =~ /docs/([^/]+)/pages/([^/?]+) ]]; then
DOC_ID="${BASH_REMATCH[1]}"
PAGE_ID="${BASH_REMATCH[2]}"
# Strip query string if present on href
PAGE_ID="${PAGE_ID%%\?*}"
return 0
fi
if [[ "$api_href" =~ /docs/([^/]+)$ ]]; then
DOC_ID="${BASH_REMATCH[1]}"
local pages_json n
pages_json="$(http_get_json "${BASE_URL}/docs/${DOC_ID}/pages?limit=500")" || return 1
n="$(echo "$pages_json" | jq '.items | length')"
if [[ "$n" -eq 0 ]]; then
die "Doc has no pages to export."
fi
if [[ "$n" -gt 1 ]]; then
die "This URL opens the whole doc (${n} pages). Open the page you want, copy its link from the sidebar, and run again with that URL."
fi
PAGE_ID="$(echo "$pages_json" | jq -r '.items[0].id // empty')"
[[ -n "$PAGE_ID" ]] || die "Could not read page id from doc pages list."
return 0
fi
die "Could not parse doc/page from API href: $api_href"
}
if [[ -n "$URL" ]]; then
resolve_url_to_doc_page "$URL"
elif [[ -n "$DOC_ID" && -n "$PAGE_ID" ]]; then
:
else
die "Provide a Coda URL (https://coda.io/...) or both --doc-id and --page-id"
fi
PAGE_ENC="$(uri_encode_segment "$PAGE_ID")"
page_json="$(http_get_json "${BASE_URL}/docs/${DOC_ID}/pages/${PAGE_ENC}")" || exit 1
page_name="$(echo "$page_json" | jq -r '.name // empty')"
[[ -n "$page_name" ]] || page_name="$PAGE_ID"
echo "Exporting page \"${page_name}\" (doc=${DOC_ID}, page=${PAGE_ID}) as ${FORMAT}..." >&2
export_url="${BASE_URL}/docs/${DOC_ID}/pages/${PAGE_ENC}/export"
export_json="$(http_post_json "$export_url" \
"$(jq -nc --arg f "$FORMAT" '{outputFormat: $f}')")" || exit 1
request_id="$(echo "$export_json" | jq -r '.id // empty')"
[[ -n "$request_id" ]] || die "Missing export request id in response: $export_json"
# Use the href returned by the API to poll status. Building the URL ourselves
# risks encoding mismatches that produce 404 "No request was found".
status_url="$(echo "$export_json" | jq -r '.href // empty')"
# Make sure status_url is absolute
if [[ -n "$status_url" && ! "$status_url" =~ ^https?:// ]]; then
status_url="https://coda.io${status_url}"
fi
# Fallback: construct from raw IDs (no encoding on request_id)
if [[ -z "$status_url" ]]; then
status_url="${BASE_URL}/docs/${DOC_ID}/pages/${PAGE_ID}/export/${request_id}"
fi
echo " Export request: ${request_id}" >&2
echo " Status URL: ${status_url}" >&2
# The export is async — Coda needs time before the status endpoint is ready.
# We call curl directly here (not via a command-substitution wrapper) so that
# the HTTP status code lives in the parent shell and is never lost in a subshell.
_poll_body_file="$(mktemp)"
trap 'rm -f "$_poll_body_file"' EXIT
sleep "$POLL_INTERVAL"
deadline=$((SECONDS + TIMEOUT))
last_status="unknown"
consecutive_404=0
while (( SECONDS < deadline )); do
poll_code="$(curl -sS -o "$_poll_body_file" -w "%{http_code}" \
-H "$AUTH_HEADER" "$status_url")"
if [[ "$poll_code" == "404" ]]; then
consecutive_404=$((consecutive_404 + 1))
if (( consecutive_404 > 15 )); then
die "Export status returned 404 after ${consecutive_404} retries. The export request may have expired."
fi
echo " Waiting for export to become available (attempt ${consecutive_404})..." >&2
sleep "$POLL_INTERVAL"
continue
fi
if [[ "$poll_code" -ge 400 ]]; then
die "Failed to poll export status (HTTP ${poll_code}): $(cat "$_poll_body_file")"
fi
consecutive_404=0
status_json="$(cat "$_poll_body_file")"
last_status="$(printf '%s' "$status_json" | jq -r '.status // "unknown" | ascii_downcase')"
download_link="$(printf '%s' "$status_json" | jq -r '.downloadLink // empty')"
if [[ "$last_status" == "complete" || "$last_status" == "completed" ]] && [[ -n "$download_link" ]]; then
ext="md"
[[ "$FORMAT" == "html" ]] && ext="html"
if [[ -z "$FILENAME" ]]; then
base="$(slugify "$page_name")"
[[ -n "$base" ]] || base="page"
FILENAME="${base}.${ext}"
fi
mkdir -p "$OUTPUT_DIR"
outpath="${OUTPUT_DIR%/}/$FILENAME"
curl -sS -L --compressed -o "$outpath" "$download_link"
echo "Saved: $outpath"
exit 0
fi
if [[ "$last_status" == "error" || "$last_status" == "failed" ]]; then
err="$(printf '%s' "$status_json" | jq -r '.error // "Coda export failed."')"
die "$err"
fi
sleep "$POLL_INTERVAL"
done
die "Timed out waiting for export completion. Last status: $last_status"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment