01 / apply it
yardr pack apply google-docsyardr carries this pack in its binary; the name is enough. yardr pack check google-docs shows what it would change first. It goes into the workspace itself, and into no repo · depot.
yardr pack apply https://yardr.dev/packs/google-docs/From this site, as the files below.
02 / the files
Every file, as it ships.
# Docs and Slides as a pack of kind source: the integration, imported
# into the yard and into no depot. What they are here: the declared
# sources google-docs and google-slides, which read documents and decks,
# append text and slides to them, and replace document text in place.
# Each source's credential mints from a gws login on this machine, by
# google-mint, the mint command the pack brings beside its declaration:
# applying the pack sets it, with no command typed, and a mint_command
# the operator sets on a source wins over it. One command mints every
# account, choosing the login by the account it is given: the yard's
# login for the yard's calls, that account's own login beside it for a
# call --as a user. The login, the account, the mint and calling as a
# user are the four google packs' shared setup, said once in
# docs/google.md.
#
# yardr pack apply google-docs
#
# A person applies the packs they want, each by one card or typed. A
# pack of kind source asks no grants at import, so what a crew member
# may call is granted afterwards, by the setup notes: read for the
# reads, each update by its name, plain by the operator and as a user
# by that user alone.
name: google-docs
kind: source
setup: SETUP.md
sources:
google-docs:
declaration: google-docs.yaml
google-slides:
declaration: google-slides.yaml
# Suggested grants: named sets of the sources' calls, printed numbered by
# pack show. Suggesting grants nothing: the operator grants them, and
# terminal pack apply ignores them.
presets:
read:
about: Read documents and decks; change nothing
grants:
google-docs: [read]
google-slides: [read]
append:
about: Read documents and decks, and append text and slides; replace nothing
grants:
google-docs: [read, write]
google-slides: [read, slide]
everything:
about: Read and append to documents and decks, and replace document text in place
grants:
google-docs: [read, write, replace]
google-slides: [read, slide]
#!/bin/bash
# build-replace: the body of the google-docs replace call, built per call. One
# JSON object on stdin ({"params": {"docId", "find", "with", "all"}}), the
# batchUpdate body on stdout. GOOGLE_TOKEN and YARDR_SOURCE_BASE, the
# declaration's base, are in the environment. Needs curl and jq.
#
# The script reads the document for its text, counts the occurrences of
# find, and refuses more than one unless all is true: Docs replaces every
# match or none (replaceAllText), so an unguarded call rewrites the whole
# doc. The count runs over every text run of every tab, including body,
# headers, footers and footnotes. Matches are literal and case-sensitive,
# as the replaceAllText the script prints asks with matchCase. Zero matches
# still sends: the answer's occurrencesChanged of 0 says so. The write is
# pinned
# to the revision read, so a collaborator's intervening edit cannot shift
# the count. Anything the body cannot be built from is a refusal: words on
# stderr, exit 1, nothing sent.
set -euo pipefail
in=$(cat)
# Bash cannot hold NUL, so refuse it before extracting either string.
if jq -e '[.params.find, .params.with] | any(.[]; contains("\u0000"))' <<<"$in" >/dev/null; then
echo "find or with contains a NUL character" >&2; exit 1
fi
docId=$(jq -r '.params.docId // ""' <<<"$in")
find=$(jq -j '(.params.find // "") + "x"' <<<"$in")
find=${find%x}
with=$(jq -j '(.params.with // "") + "x"' <<<"$in")
with=${with%x}
all=$(jq -r '.params.all // false' <<<"$in")
[ -n "$docId" ] || { echo "replace was handed no docId" >&2; exit 1; }
[ -n "$find" ] || { echo "find names no text to replace" >&2; exit 1; }
: "${GOOGLE_TOKEN:?GOOGLE_TOKEN is not set}"
: "${YARDR_SOURCE_BASE:?YARDR_SOURCE_BASE is not set}"
# An argument can be read by every process on the machine while curl runs,
# so the token goes to curl as a config line on its stdin, written by the
# shell itself, as the other packs' scripts hand theirs over.
doc=$(printf 'header = "Authorization: Bearer %s"\n' "$GOOGLE_TOKEN" \
| curl -sS --fail-with-body --get -K - \
--data-urlencode "includeTabsContent=true" \
--data-urlencode "fields=revisionId,tabs" \
"$YARDR_SOURCE_BASE/v1/documents/$docId") || {
echo "document $docId could not be read (curl exit $?)" >&2; exit 1
}
revision=$(jq -r '.revisionId // empty' <<<"$doc")
[ -n "$revision" ] || { echo "document $docId answered no revision id" >&2; exit 1; }
tabs=$(jq -r '.tabs | if type == "array" then length else 0 end' <<<"$doc")
[ "$tabs" -gt 0 ] || { echo "document $docId answered no tabs" >&2; exit 1; }
text=$(jq -j '([.. | .textRun? // empty | .content // empty] | join("")) + "x"' <<<"$doc")
text=${text%x}
# The count is the shell's own substitution with the find quoted, which
# matches it literally, over line breaks too, as grep -o would not.
stripped=${text//"$find"/}
n=$(( (${#text} - ${#stripped}) / ${#find} ))
if [ "$n" -gt 1 ] && [ "$all" != "true" ]; then
echo "$n matches; pass all=true to replace them all" >&2; exit 1
fi
jq -c -n --arg find "$find" --arg with "$with" --arg revision "$revision" '
{requests: [{replaceAllText: {
containsText: {text: $find, matchCase: true}, replaceText: $with}}],
writeControl: {requiredRevisionId: $revision}}
'
#!/bin/bash
# build-write: the body of the google-docs write call, built per call. One
# JSON object on stdin ({"params": {"docId", "paragraphs"}}), the
# batchUpdate body on stdout. GOOGLE_TOKEN and YARDR_SOURCE_BASE, the
# declaration's base, are in the environment. Needs curl and jq.
#
# paragraphs is one JSON array of text/style pairs; the script reads the
# document for its body's end index, works out each paragraph's range
# itself, and prints the one batchUpdate that appends them all. The
# document is read for its end indices alone, not its text. Indices are
# UTF-16 code units, so an astral character counts double: jq's length
# counts codepoints, and the script adds one per codepoint past U+FFFF.
# The first appended character sits at E-1, before the body's last
# newline, and each paragraph starts past the newline before it; the
# first pair continues the doc's last paragraph. The write is pinned to
# the revision read, so a collaborator's intervening edit cannot shift the
# ranges. Anything the body cannot be built from is a refusal: words on
# stderr, exit 1, nothing sent.
set -euo pipefail
in=$(cat)
docId=$(jq -r '.params.docId // ""' <<<"$in")
paras=$(jq -c '.params.paragraphs | fromjson? // null' <<<"$in")
[ -n "$docId" ] || { echo "write was handed no docId" >&2; exit 1; }
[ "$paras" != "null" ] || { echo "paragraphs is no JSON array of text/style pairs" >&2; exit 1; }
: "${GOOGLE_TOKEN:?GOOGLE_TOKEN is not set}"
: "${YARDR_SOURCE_BASE:?YARDR_SOURCE_BASE is not set}"
# The pairs are checked before the document is read, so a refusal for
# bad paragraphs sends nothing. Docs drops some control and private-use
# characters on insertion; refusing them keeps later style ranges aligned.
checked=$(jq -c -n --argjson paras "$paras" '
def styles: ["NORMAL_TEXT", "TITLE", "HEADING_1", "HEADING_2"];
def check($i; $p):
if ($p | type) != "object" then "paragraph \($i + 1) is no text/style pair"
elif ($p.text | type) != "string" then "paragraph \($i + 1) names no text"
elif ($p.style | type) != "string" or ((styles | index($p.style)) | not) then
"paragraph \($i + 1) styles with \($p.style), which is no style: one of \(styles | join(", "))"
elif $p.text | contains("\n") then "paragraph \($i + 1) holds a line break: one pair is one paragraph"
elif $p.text | any(explode[]; . <= 8 or (. >= 12 and . <= 31) or (. >= 57344 and . <= 63743)) then
"paragraph \($i + 1) holds a control or private-use character Google Docs strips"
else null end;
if ($paras | type) != "array" then {"refusal": "paragraphs is no JSON array of text/style pairs"}
elif ($paras | length) == 0 then {"refusal": "paragraphs holds no paragraph to append"}
else ([range(0; $paras | length) | check(.; $paras[.]) | select(. != null)][0]) as $bad
| if $bad != null then {"refusal": $bad}
elif ([$paras[].text] | join("\n")) == "" then {"refusal": "paragraphs holds no text to append"}
else {"ok": true} end end
')
refusal=$(jq -r '.refusal // empty' <<<"$checked")
[ -z "$refusal" ] || { echo "$refusal" >&2; exit 1; }
# An argument can be read by every process on the machine while curl runs,
# so the token goes to curl as a config line on its stdin, written by the
# shell itself, as the other packs' scripts hand theirs over.
doc=$(printf 'header = "Authorization: Bearer %s"\n' "$GOOGLE_TOKEN" \
| curl -sS --fail-with-body --get -K - \
--data-urlencode "fields=revisionId,body(content(endIndex))" \
"$YARDR_SOURCE_BASE/v1/documents/$docId") || {
echo "document $docId could not be read (curl exit $?)" >&2; exit 1
}
e=$(jq -r '.body.content[-1].endIndex | select(type == "number") // empty' <<<"$doc")
[[ "$e" =~ ^[0-9]+$ ]] && [ "$e" -ge 2 ] || { echo "document $docId answered no end index" >&2; exit 1; }
revision=$(jq -r '.revisionId // empty' <<<"$doc")
[ -n "$revision" ] || { echo "document $docId answered no revision id" >&2; exit 1; }
jq -c -n --argjson paras "$paras" --argjson e "$e" --arg revision "$revision" '
def utf16: length + ([explode[] | select(. > 65535)] | length);
(reduce range(0; $paras | length) as $i ({start: ($e - 1), reqs: []};
($paras[$i]) as $p
| ($p.text | utf16) as $len
| .reqs += [{updateParagraphStyle: {
range: {startIndex: .start, endIndex: (.start + (if $len == 0 then 1 else $len end))},
paragraphStyle: {namedStyleType: $p.style}, fields: "namedStyleType"}}]
| .start += $len + 1)) as $r
| {requests: ([{insertText: {endOfSegmentLocation: {}, text: ([$paras[].text] | join("\n"))}}] + $r.reqs),
writeControl: {requiredRevisionId: $revision}}
'
# Google Docs as a declared source: a document read, appended to at the
# end with several styled paragraphs in one call, and replaced in place
# with a guarded find. One source per host, so Docs is its own; the empty
# file it fills is made by the drive source's doc call. No other
# batchUpdate request is declared.
#
# Checked against the discovery document
# (https://www.googleapis.com/discovery/v1/apis/docs/v1/rest) on 16
# October 2026: documents.get answers the document with title and body;
# documents.batchUpdate takes {requests} and answers {documentId,
# replies}. InsertText takes endOfSegmentLocation, which appends at the
# end of the body with no index; UpdateParagraphStyle takes a range, a
# paragraphStyle with namedStyleType (NORMAL_TEXT, TITLE, HEADING_1
# through HEADING_6) and fields, and styles the paragraphs the range
# overlaps. Indices are UTF-16 code units, so an astral character counts
# double; the write call's build script reads the document for its body's
# end index, works out each paragraph's range itself, and prints the
# batchUpdate that appends them. ReplaceAllText, checked on 17 October
# 2026, takes containsText with text and matchCase, and replaceText, and
# answers occurrencesChanged in its reply; the replace call's build
# script reads the document for its text, counts the matches itself, and
# refuses more than one unless all is true.
#
# The credential mints like google-mail's: GOOGLE_ACCOUNT names the
# account (an address, no secret) and google-mint, beside this file,
# mints from it, which the operator's own mint_command wins over. Docs
# takes no idempotency key, so the file names none: a write tried again
# after a lost answer is appended twice. A replace tried again counts
# again first, so it sends a second change only where the first left
# matches.
name: google-docs
about: Google Docs read and appended to, several styled paragraphs in one call, and replaced in place.
base: https://docs.googleapis.com
credential:
env: GOOGLE_TOKEN
header: "Authorization: Bearer {credential}"
mint:
- name: GOOGLE_ACCOUNT
secret: false # an address, asked in the clear
mint_command: [google-mint] # the file beside this one; the source's own mint_command wins
calls:
doc:
kind: read
about: Read one document, title and text
params:
docId: { type: string, pattern: "^[A-Za-z0-9_-]+$", max: 200, log: true }
request:
method: GET
path: /v1/documents/{docId}
answer: { select: [title, body], max: 262144 } # the text is in body's paragraphs
write:
kind: update
about: Append paragraphs at the end, each with its style
params:
docId: { type: string, pattern: "^[A-Za-z0-9_-]+$", max: 200, log: true }
# One JSON array of text/style pairs, the paragraphs to append:
# [{"text":"Lunch places","style":"HEADING_1"}]. Each style is one
# of NORMAL_TEXT, TITLE, HEADING_1, HEADING_2; NORMAL_TEXT writes
# plain. Free text: its value is in no event. The build script
# reads the document for its body's end index, works out each
# paragraph's range itself, and prints the batchUpdate; the first
# pair continues the doc's last paragraph, so on a doc with text
# it styles that paragraph whole.
paragraphs: { type: string, max: 60000 }
request:
method: POST
path: /v1/documents/{docId}:batchUpdate
build: [google-docs/build-write]
answer: { select: [documentId, replies] }
replace:
kind: update
about: Replace text in place, refusing more than one match unless told all
params:
docId: { type: string, pattern: "^[A-Za-z0-9_-]+$", max: 200, log: true }
# The text to find, literal and case-sensitive; empty is refused by
# the build script before the document is read. Free text: its value
# is in no event.
find: { type: string, max: 1000 }
# The text that takes its place; empty deletes. Free text: its value
# is in no event.
with: { type: string, max: 60000 }
# Left out, more than one match is refused in words that say the
# count; true replaces them all. The build script reads the
# document for its text, counts itself, and prints the batchUpdate,
# pinned to the revision read; zero matches sends, and the answer's
# occurrencesChanged of 0 says so.
all: { type: bool, optional: true }
request:
method: POST
path: /v1/documents/{docId}:batchUpdate
build: [google-docs/build-replace]
answer: { select: [documentId, replies] } # replies carry replaceAllText.occurrencesChanged
#!/bin/sh
# Mints a Google access token from a gws login: the mint command the
# google packs' declarations bring (credential.mint_command), whose
# credential names GOOGLE_ACCOUNT under credential.mint. Applying a
# pack copies it under <home>/sources/<source>/; the core runs it with
# {inputs, scope} on stdin and reads {token, expires_at} on stdout.
#
# GOOGLE_ACCOUNT is the account's address, no secret. The refresh
# credentials come from the local gws login for that account: the
# default login in ~/.config/gws where it is for that account, else
# that account's own login in ~/.config/gws-accounts/<account>,
# chosen by this script with
# GOOGLE_WORKSPACE_CLI_CONFIG_DIR for the gws it starts alone. gws auth
# status tells whose login one is, and gws auth export --unmasked prints
# the client id, client secret and refresh token, which are exchanged at
# the token endpoint for an access token. No new copy of a secret is
# stored: the three are held in this process's memory alone, sent in the
# exchange's body, and never printed, neither on stdout nor on stderr,
# nor written anywhere.
#
# Needs curl on PATH and gws: GWS_BIN names another gws, else the one on
# PATH, else the first gws in the common install directories of both
# platforms, the operator's own before the machine's.
# GOOGLE_TOKEN_ENDPOINT may name a loopback HTTP endpoint for a test's
# stand-in. The scope is the call's;
# Google's token narrows by the gws grant, not by call, so it is read
# and ignored.
set -eu
fail() { echo "google-mint: $*" >&2; exit 1; }
command -v curl >/dev/null 2>&1 || fail "needs curl on PATH"
if [ -n "${GWS_BIN:-}" ]; then
case $GWS_BIN in "~/"*) GWS_BIN=$HOME/${GWS_BIN#"~/"} ;; "~") GWS_BIN=$HOME ;; esac
[ -x "$GWS_BIN" ] || fail "GWS_BIN=$GWS_BIN is no program to run"
GWS=$GWS_BIN
elif command -v gws >/dev/null 2>&1; then
GWS=gws
else
# A process started with a short PATH (the daemon's own) still finds
# the gws its operator installed: the directories gws's install
# methods use on either platform, the operator's own before the
# machine's.
GWS=
for dir in "${HOME:-}/.local/bin" "${HOME:-}/.cargo/bin" /opt/homebrew/bin /usr/local/bin; do
if [ -x "$dir/gws" ]; then
GWS=$dir/gws
break
fi
done
[ -n "$GWS" ] || fail "needs gws on PATH or in a common install directory (or GWS_BIN=/path/to/gws): the operator's gws login is what the token is minted from"
fi
input=$(cat)
[ -n "$input" ] || fail "reads {inputs, scope} on stdin, and got nothing"
# first <text> <key> is the value of the first "key": "value" in text, ""
# when there is none. Values with an escaped quote in them are not read:
# an account, a scope, gws's answers and Google's have none. A line gws
# prints outside its answer (Using keyring backend: keyring, on stderr)
# has no quoted pair in it, so it is passed over either way.
first() {
printf '%s' "$1" | grep -o "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" |
head -n 1 | sed 's/^[^:]*:[[:space:]]*"//;s/"$//'
}
# num <text> <key> is the number of the first "key": <number> in text, ""
# when there is none.
num() {
printf '%s' "$1" | grep -o "\"$2\"[[:space:]]*:[[:space:]]*[0-9][0-9]*" |
head -n 1 | sed -n 's/[^0-9]*\([0-9][0-9]*\)/\1/p'
}
# shell_quote <text> is text as one shell word: as it is when nothing in
# it is special, single-quoted otherwise. For the login command a
# refusal names: the account that names its directory is data, so a
# ';', '$' or space in it must not become a command when it is copied.
shell_quote() {
case $1 in
*[!A-Za-z0-9/._+:,@%=-]*|'')
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
;;
*)
printf '%s' "$1"
;;
esac
}
account=$(first "$input" GOOGLE_ACCOUNT)
[ -n "$account" ] || fail "its inputs name no GOOGLE_ACCOUNT"
printf '%s' "$input" | grep -q '"scope"' || fail "reads {inputs, scope} on stdin, and got no scope"
endpoint=${GOOGLE_TOKEN_ENDPOINT:-https://oauth2.googleapis.com/token}
if [ "$endpoint" != "https://oauth2.googleapis.com/token" ]; then
# The override is for the test stand-in only. Keep refresh credentials
# from being sent to an endpoint supplied by the process environment.
case $endpoint in
http://127.0.0.1:*)
port=${endpoint#http://127.0.0.1:}
case $port in ''|*[!0-9]*) fail "GOOGLE_TOKEN_ENDPOINT is only allowed for a loopback test endpoint" ;; esac
;;
*) fail "GOOGLE_TOKEN_ENDPOINT is only allowed for a loopback test endpoint" ;;
esac
fi
# Whose login gws holds: the exchange below mints for whoever that is,
# so another account than the inputs name is refused, not minted. The
# default login is tried first, exactly as before; only where it answers
# another account is that account's own login tried, in the directory the
# pack declares beside gws's own.
status_out=$("$GWS" auth status 2>&1) || fail "gws auth status failed (exit $?): $(printf '%s' "$status_out" | head -c 500)"
user=$(first "$status_out" user)
[ -n "$user" ] || fail "gws auth status answered with no user"
if [ "$user" = "$account" ]; then
# The refresh credentials, decrypted by gws: masked without
# --unmasked, so that flag is the point. What fails is said in one
# line: the three values reach neither stdout nor stderr from here on.
export_out=$("$GWS" auth export --unmasked 2>&1) || fail "gws auth export failed (exit $?)"
else
# The account names a directory below, so one that is no single name
# is refused before it names one.
case $account in
*"/"*|"."|"..") fail "GOOGLE_ACCOUNT=$account names no per-account login (~/.config/gws-accounts/<account>)" ;;
esac
[ -n "${HOME:-}" ] || fail "HOME is not set: cannot find the login for GOOGLE_ACCOUNT=$account"
acct_dir=$HOME/.config/gws-accounts/$account
login_cmd="GOOGLE_WORKSPACE_CLI_CONFIG_DIR=$(shell_quote "$acct_dir") gws auth login -s gmail,drive,docs,slides,calendar"
# gws just answered above, so what fails here is the login in that
# directory, not gws: the refusal names the login that makes it, and
# not what gws said.
status_out=$(GOOGLE_WORKSPACE_CLI_CONFIG_DIR=$acct_dir "$GWS" auth status 2>&1) || fail "no gws login for GOOGLE_ACCOUNT=$account in $acct_dir ($login_cmd makes it)"
user=$(first "$status_out" user)
[ -n "$user" ] || fail "no gws login for GOOGLE_ACCOUNT=$account in $acct_dir ($login_cmd makes it)"
[ "$user" = "$account" ] || fail "the gws login in $acct_dir is for $user, not GOOGLE_ACCOUNT=$account ($login_cmd makes it)"
export_out=$(GOOGLE_WORKSPACE_CLI_CONFIG_DIR=$acct_dir "$GWS" auth export --unmasked 2>&1) || fail "gws auth export for GOOGLE_ACCOUNT=$account in $acct_dir failed (exit $?)"
fi
# (What it said is not repeated: on this line the refresh credentials
# themselves would be the leak.)
client_id=$(first "$export_out" client_id)
client_secret=$(first "$export_out" client_secret)
refresh_token=$(first "$export_out" refresh_token)
[ -n "$client_id" ] && [ -n "$client_secret" ] && [ -n "$refresh_token" ] ||
fail "gws auth export answered with no refresh credentials"
# The exchange is one form, sent as it is: what gws exports (letters,
# digits, dots, tildes, slashes and hyphens) needs no encoding, and
# anything else is refused rather than sent half-encoded. The body goes
# over the pipe, not in an argument and not in a file.
case $client_id$client_secret$refresh_token in
*[!A-Za-z0-9._~/-]*)
fail "what gws answered is no refresh credentials" ;;
esac
out=$(printf 'grant_type=refresh_token&client_id=%s&client_secret=%s&refresh_token=%s' \
"$client_id" "$client_secret" "$refresh_token" |
curl -s --max-time 10 -w '\n%{http_code}' -d @- "$endpoint") ||
fail "could not reach $endpoint (curl exit $?)"
code=$(printf '%s' "$out" | tail -n 1)
body=$(printf '%s' "$out" | sed '$d')
# The response body is untrusted and could echo the refresh credentials.
# Report only the status code on failure.
[ "$code" = "200" ] || fail "minting a token was answered $code"
token=$(first "$body" access_token)
expires_in=$(num "$body" expires_in)
[ -n "$token" ] && [ -n "$expires_in" ] || fail "minting a token came back with no token"
# The answer is JSON the core reads: a token outside what Google sends
# is refused rather than printed through it.
case $token in *[!A-Za-z0-9._-]*) fail "the token Google answered is no token" ;; esac
now=$(date +%s)
exp=$(date -u -r "$((now + expires_in))" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null ||
date -u -d "@$((now + expires_in))" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) ||
fail "cannot write the time (date reads neither -r nor -d here)"
printf '{"token":"%s","expires_at":"%s"}' "$token" "$exp"
# Google Slides as a declared source: a deck read, and a slide with
# title and body text appended to it. One source per host, so Slides is
# its own; the empty deck it fills is made by the drive source's slides
# call. No other batchUpdate request is declared.
#
# Checked against the discovery document
# (https://www.googleapis.com/discovery/v1/apis/slides/v1/rest) on 16
# October 2026: presentations.get answers the deck with title and
# slides; presentations.batchUpdate takes {requests} and answers
# {presentationId, replies}. CreateSlide takes an objectId, a
# slideLayoutReference with predefinedLayout TITLE_AND_BODY, and
# placeholderIdMappings naming the title and body placeholders' ids, so
# one batch creates the slide and writes both texts; with no
# insertionIndex the slide goes last. A caller-chosen id starts with a
# letter, digit or underscore, holds those with hyphen past it, and is 5
# to 50 characters; the title and body ids derive from the slide's with
# _title and _body, so the slide's is 5 to 44.
#
# The credential mints like google-mail's: GOOGLE_ACCOUNT names the
# account (an address, no secret) and google-mint, beside this file,
# mints from it, which the operator's own mint_command wins over. Slides
# takes no idempotency key, so the file names none: a slide tried again
# after a lost answer is added twice.
name: google-slides
about: Google Slides decks read, and slides with title and body appended.
base: https://slides.googleapis.com
credential:
env: GOOGLE_TOKEN
header: "Authorization: Bearer {credential}"
mint:
- name: GOOGLE_ACCOUNT
secret: false # an address, asked in the clear
mint_command: [google-mint] # the file beside this one; the source's own mint_command wins
calls:
deck:
kind: read
about: Read one deck, title and slides
params:
id: { type: string, pattern: "^[A-Za-z0-9_-]+$", max: 200, log: true }
request:
method: GET
path: /v1/presentations/{id}
answer: { select: [title, slides], max: 262144 }
slide:
kind: update
about: Append a slide with title and body text
params:
id: { type: string, pattern: "^[A-Za-z0-9_-]+$", max: 200, log: true }
# The slide's id, the caller's to choose and unique in the deck:
# letters, digits, _ and -, starting with one of the first three.
slide: { type: string, pattern: "^[A-Za-z0-9_][A-Za-z0-9_-]{4,43}$", log: true }
title: { type: string, max: 500 } # free text: its value is in no event
body: { type: string, max: 10000 }
request:
method: POST
path: /v1/presentations/{id}:batchUpdate
body:
requests:
- createSlide:
objectId: "{slide}"
slideLayoutReference: { predefinedLayout: TITLE_AND_BODY }
placeholderIdMappings:
- { layoutPlaceholder: { type: TITLE, index: 0 }, objectId: "{slide}_title" }
- { layoutPlaceholder: { type: BODY, index: 0 }, objectId: "{slide}_body" }
- insertText: { objectId: "{slide}_title", text: "{title}" }
- insertText: { objectId: "{slide}_body", text: "{body}" }
answer: { select: [presentationId, replies] }
# Setting up Docs and Slides
The pack wires Google Docs and Slides into the yard as the declared
sources `google-docs` (documents read, appended to and replaced in
place) and `google-slides` (decks read, slides with title and body
appended).
Each call goes out with a short-lived token minted from a gws login
on this machine, so no new copy of a secret is stored anywhere.
The login, the account, the mint and calling as a user are the four
google packs' shared setup, said once in `yardr docs google`.
1. **The gws login.** It needs the Docs and Slides scopes,
`https://www.googleapis.com/auth/documents` and
`https://www.googleapis.com/auth/presentations`, else the calls
come back 403. The shared login instructions in `yardr docs google`
start from a machine with no gws, and show how to check and add
the `docs,slides` services while keeping the other applied packs'
scopes.
2. **The setup card.** One card carries the pack's whole setup: the
pack, the account and the grants. Have a crew member ask for it,
and approve it, in footplate or in `yardr chat` (`ctrl+o`); a
person sets up the packs they want, one card each:
```sh
yardr bead ask <bead> "Set up Docs and Slides for the yardmaster?" --setup google-docs --crew yardmaster --recommend 1 --why "reading is the job; appending can follow" --context "The yardmaster reads docs and decks to work beads."
```
The card shows both declarations whole with their mint, the
account to type or take (step 3), and one option per preset
(step 5).
By hand it is the operator's to type, step by step, starting
with the pack:
```sh
yardr pack apply google-docs
# applied pack google-docs to the yard: 2 created, 0 changed, 0 unchanged
# source google-docs is declared: 3 calls under https://docs.googleapis.com (yardr source show google-docs)
# source google-slides is declared: 2 calls under https://slides.googleapis.com (yardr source show google-slides)
```
3. **The account.** The yard's Google account for `google-docs`
and `google-slides` is answered on the setup card, as described
in `yardr docs google` (step 3): typed unseen on the first pack,
then picked, one typed value serving both sources.
4. **The mint.** Nothing to type: the pack brings `google-mint`
beside its declaration, and applying it (step 2) copies it for
`google-docs` and `google-slides`, its sha256 on the card. What it needs and refuses,
and how a mint of the operator's own replaces it, are in
`yardr docs google`.
5. **The grants.** A pack of kind source asks none at import, so what
a crew member may call is picked on the setup card as one preset.
The pack's presets, as `yardr pack show google-docs` prints them
numbered, grant both sources together:
1. `read`: every read call of `google-docs` and `google-slides`,
and it covers any read call either source is later given;
changes nothing.
2. `append`: every read call of both, `write` on `google-docs`
and `slide` on `google-slides`: paragraphs appended at the end
of a doc, each with its style, and slides appended to a deck,
with title and body; replaces nothing.
3. `everything`: what `append` grants, and `replace` on
`google-docs`: text replaced in place, more than one match
refused unless the call passes all=true.
A grantee who comes later gets one preset grant card per source:
```sh
yardr bead ask <bead> "Let the reviewers read Docs" --run "source grant google-docs --preset read --group reviewers" --context "The reviewers read docs to check a bead's work; the card names each call it covers."
yardr bead ask <bead> "Let the reviewers read Slides" --run "source grant google-slides --preset read --group reviewers" --context "The reviewers read decks to check a bead's work; the card names each call it covers."
```
By hand they are the operator's to type:
```sh
for s in google-docs google-slides; do yardr source grant $s read --crew yardmaster; done
yardr source grant google-docs write --crew yardmaster
yardr source grant google-docs replace --crew yardmaster
yardr source grant google-slides slide --crew yardmaster
```
As ada, the same grants with `--as ada`, on cards addressed to
ada and approved by ada alone: the shared card is in
`yardr docs google` ("Calling as a user"), one card per grant, with
`read`, `write`, `replace` and `slide` from above. There is no typed way
beside these: a grant `as` ada is ada's to give.
`yardr source show google-docs` lists each call with its kind and
who may make it, plain and `as` each user.
6. **Check it.** Neither source names a check call, so each check
looks the credential over and sends nothing:
```sh
yardr source check google-docs
# google-docs: credential: GOOGLE_ACCOUNT is set in /Users/me/.yardr/sources/google.env, minted with /Users/me/.yardr/sources/google-docs/google-mint
# google-docs: its file names no check call (check: <a read call without parameters>), so nothing was sent
```
7. **Try it.** As the operator, who may make any call. A doc is an
empty file the drive source makes, whose id this pack's docs
source then fills, so the google-drive pack is applied first and
tried up to its own step 7. One write appends several paragraphs
in one call, each with its style: the call reads the doc for its
end index itself and works out each paragraph's range, and the
first paragraph continues the doc's last paragraph:
```sh
yardr call google-drive folder name="notes" parent=<a folder id>
yardr call google-drive doc name="lunch" parent=<the folder's id>
yardr call google-docs write docId=<the doc's id> paragraphs='[{"text":"Lunch places","style":"HEADING_1"},{"text":"Friday we ship.","style":"NORMAL_TEXT"}]'
```
Text is changed in place with replace, which counts its matches
first: more than one is refused unless the call passes all=true, and
the answer says how many changed.
```sh
yardr call google-docs replace docId=<the doc's id> find="Friday" with="Saturday"
```
A slide is the same shape: an empty deck the drive source makes,
then a slide with the caller's own id for it, unique in the deck:
```sh
yardr call google-drive slides name="friday" parent=<a folder id>
yardr call google-slides slide id=<the deck's id> slide=friday_1 title="Friday" body="We shipped."
```
Calling as a user — the yard reading another person's documents
with their own credential — is the shared setup too:
`yardr docs google` has it.
What is not there, on purpose: Sheets, which joins this pack when a
sheets source exists; Tasks, which is no source yet, so no pack
wires it in (Calendar is the google-calendar pack); paging past the
first page; and no call the pack does not list. The yard's source
format cannot express what is not listed, and the pack does not
change yardr.