Created
March 24, 2026 14:51
-
-
Save punit1108/bd7f13d1477d6aa0bc5b8cc64120d9e1 to your computer and use it in GitHub Desktop.
coda_cloner.sh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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