Compare commits
No commits in common. "main" and "v0.15.0" have entirely different histories.
1
.github/workflows/pages.yml
vendored
1
.github/workflows/pages.yml
vendored
@ -8,7 +8,6 @@ on:
|
||||
- "docs/**"
|
||||
- "scripts/gen-command-reference.sh"
|
||||
- "scripts/build-docs-site.mjs"
|
||||
- "scripts/docs-site-assets.mjs"
|
||||
- "Makefile"
|
||||
- ".github/workflows/pages.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
85
.github/workflows/release.yml
vendored
85
.github/workflows/release.yml
vendored
@ -48,31 +48,6 @@ jobs:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: git checkout "$RELEASE_TAG"
|
||||
|
||||
- name: Import macOS signing certificate
|
||||
env:
|
||||
MACOS_SIGNING_CERT_BASE64: ${{ secrets.MACOS_SIGNING_CERT_BASE64 }}
|
||||
MACOS_SIGNING_CERT_PASSWORD: ${{ secrets.MACOS_SIGNING_CERT_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$MACOS_SIGNING_CERT_BASE64" ]; then
|
||||
echo "No macOS signing certificate configured; skipping import."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
KEYCHAIN="build.keychain"
|
||||
KEYCHAIN_PASSWORD="$(uuidgen)"
|
||||
|
||||
echo "$MACOS_SIGNING_CERT_BASE64" | base64 --decode > /tmp/codesign.p12
|
||||
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
security default-keychain -s "$KEYCHAIN"
|
||||
security list-keychains -d user -s "$KEYCHAIN"
|
||||
|
||||
security import /tmp/codesign.p12 -k "$KEYCHAIN" -P "$MACOS_SIGNING_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
|
||||
- name: GoReleaser
|
||||
uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1
|
||||
with:
|
||||
@ -81,63 +56,3 @@ jobs:
|
||||
args: release --clean --config /tmp/.goreleaser.yaml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GOG_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
|
||||
|
||||
update-homebrew-tap:
|
||||
runs-on: ubuntu-latest
|
||||
needs: goreleaser
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "RELEASE_TAG=${{ inputs.tag }}" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "RELEASE_TAG=${{ github.ref_name }}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Dispatch tap formula update
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
request_id="gogcli-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
expected_title="Update gogcli for ${RELEASE_TAG} (${request_id})"
|
||||
|
||||
gh workflow run update-formula.yml \
|
||||
--repo steipete/homebrew-tap \
|
||||
--ref main \
|
||||
-f formula=gogcli \
|
||||
-f tag="$RELEASE_TAG" \
|
||||
-f repository=steipete/gogcli \
|
||||
-f artifact_template="{formula}_{version}_{target}.tar.gz" \
|
||||
-f request_id="$request_id"
|
||||
|
||||
run_id=""
|
||||
for _ in {1..30}; do
|
||||
run_id=$(gh run list \
|
||||
--repo steipete/homebrew-tap \
|
||||
--workflow update-formula.yml \
|
||||
--branch main \
|
||||
--event workflow_dispatch \
|
||||
--limit 20 \
|
||||
--json databaseId,displayTitle \
|
||||
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
|
||||
if [ -n "$run_id" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "::error::Could not find tap workflow run with title: $expected_title"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh run watch "$run_id" \
|
||||
--repo steipete/homebrew-tap \
|
||||
--exit-status \
|
||||
--interval 10
|
||||
|
||||
@ -34,21 +34,16 @@ builds:
|
||||
targets:
|
||||
- darwin_amd64
|
||||
- darwin_arm64
|
||||
hooks:
|
||||
post:
|
||||
- ./scripts/codesign-macos.sh "{{ .Path }}"
|
||||
|
||||
archives:
|
||||
- ids:
|
||||
- builds:
|
||||
- gog
|
||||
- gog_darwin
|
||||
formats:
|
||||
- tar.gz
|
||||
format: tar.gz
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
format: zip
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
1
Makefile
1
Makefile
@ -74,7 +74,6 @@ docs-site: docs-commands
|
||||
@node scripts/build-docs-site.mjs
|
||||
|
||||
docs-check: docs-site
|
||||
@node scripts/check-docs-coverage.mjs
|
||||
|
||||
tools:
|
||||
@mkdir -p $(TOOLS_DIR)
|
||||
|
||||
51
docs/README.md
Normal file
51
docs/README.md
Normal file
@ -0,0 +1,51 @@
|
||||
# gog Docs
|
||||
|
||||
`gog` is a single CLI for Google Workspace automation: Gmail, Calendar, Drive,
|
||||
Docs, Sheets, Slides, Contacts, Tasks, People, Forms, Apps Script, Groups, Admin,
|
||||
Keep, and related agent workflows.
|
||||
|
||||
## Start Here
|
||||
|
||||
- Install and authenticate from the repository
|
||||
[README](https://github.com/steipete/gogcli#readme).
|
||||
- Read [Auth Clients](auth-clients.md) when setting up OAuth clients, service
|
||||
accounts, or Workspace domain-wide delegation.
|
||||
- Read [Command Guards and Baked Safety Profiles](safety-profiles.md) when
|
||||
running `gog` from agents or automation.
|
||||
- Read the bundled [`gog` agent skill](../.agents/skills/gog/SKILL.md) when an
|
||||
agent needs safe auth preflight, JSON-first output, or guarded Workspace
|
||||
automation patterns.
|
||||
- Read [Sheets Tables](sheets-tables.md) when creating or inspecting Google
|
||||
Sheets structured tables.
|
||||
- Open the [Command Index](commands/README.md) for generated docs for every CLI
|
||||
command.
|
||||
|
||||
## Common Paths
|
||||
|
||||
```bash
|
||||
gog auth add you@gmail.com --services gmail,calendar,drive
|
||||
gog gmail search 'newer_than:7d' --max 10
|
||||
gog gmail get <messageId> --sanitize-content --json
|
||||
gog calendar events --today
|
||||
gog drive ls --max 20
|
||||
```
|
||||
|
||||
## Command Docs
|
||||
|
||||
Every command page under `docs/commands/` is generated from
|
||||
`gog schema --json`. Do not hand-edit generated command pages. After changing
|
||||
commands, flags, aliases, arguments, or help text, run:
|
||||
|
||||
```bash
|
||||
make docs-commands
|
||||
```
|
||||
|
||||
Then build the GitHub Pages site locally:
|
||||
|
||||
```bash
|
||||
make docs-site
|
||||
open dist/docs-site/index.html
|
||||
```
|
||||
|
||||
The site is intentionally static: no framework, no package install, and no
|
||||
client-side dependency beyond a small navigation script embedded by the builder.
|
||||
@ -24,10 +24,6 @@ Assumptions:
|
||||
- Go toolchain installed (Go version comes from `go.mod`).
|
||||
- `make` works locally.
|
||||
- Access to the tap repo (e.g. `steipete/homebrew-tap`).
|
||||
- For signed macOS release binaries (recommended): GitHub Actions secrets set:
|
||||
- `MACOS_SIGNING_CERT_BASE64` (base64-encoded `.p12`)
|
||||
- `MACOS_SIGNING_CERT_PASSWORD`
|
||||
- `MACOS_CODESIGN_IDENTITY` (e.g. `Developer ID Application: …`)
|
||||
|
||||
## 1) Verify build is green
|
||||
```sh
|
||||
@ -75,14 +71,7 @@ gh workflow run release.yml -f tag=vX.Y.Z
|
||||
## 5) Update (or add) the Homebrew formula
|
||||
In the tap repo (assumed sibling at `../homebrew-tap`), create/update `Formula/gogcli.rb`.
|
||||
|
||||
Recommended formula shape (download GitHub release assets; preserves macOS code signature):
|
||||
- `version "X.Y.Z"`
|
||||
- `url "https://github.com/steipete/gogcli/releases/download/vX.Y.Z/gogcli_X.Y.Z_darwin_arm64.tar.gz"` (or `darwin_amd64`)
|
||||
- `sha256 "<sha256>"`
|
||||
- Install:
|
||||
- `bin.install "gog"`
|
||||
|
||||
Alternative (build-from-source; macOS binary will be ad-hoc signed, which can trigger repeated Keychain prompts with `KeychainTrustApplication`):
|
||||
Recommended formula shape (build-from-source, no binary assets needed):
|
||||
- `version "X.Y.Z"`
|
||||
- `url "https://github.com/steipete/gogcli/archive/refs/tags/vX.Y.Z.tar.gz"`
|
||||
- `sha256 "<sha256>"`
|
||||
|
||||
489
docs/assets/site.css
Normal file
489
docs/assets/site.css
Normal file
@ -0,0 +1,489 @@
|
||||
:root {
|
||||
--bg0: #07070b;
|
||||
--bg1: #0b0b11;
|
||||
--bg2: #11111a;
|
||||
--card: rgba(17, 17, 26, 0.72);
|
||||
--card2: rgba(12, 12, 18, 0.64);
|
||||
--stroke: rgba(255, 255, 255, 0.08);
|
||||
--stroke2: rgba(255, 255, 255, 0.05);
|
||||
--text: #f3f4f6;
|
||||
--muted: rgba(243, 244, 246, 0.7);
|
||||
--dim: rgba(243, 244, 246, 0.46);
|
||||
|
||||
--b: #4285f4;
|
||||
--r: #ea4335;
|
||||
--y: #fbbc05;
|
||||
--g: #34a853;
|
||||
|
||||
--shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
--shadow2: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||
|
||||
--radius: 16px;
|
||||
--radius2: 22px;
|
||||
|
||||
--serif: "Fraunces", ui-serif, Georgia, serif;
|
||||
--sans: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: radial-gradient(1200px 800px at 50% -20%, rgba(66, 133, 244, 0.18), transparent 60%),
|
||||
radial-gradient(900px 700px at 80% 18%, rgba(234, 67, 53, 0.14), transparent 55%),
|
||||
radial-gradient(1000px 900px at 18% 62%, rgba(52, 168, 83, 0.14), transparent 55%),
|
||||
radial-gradient(900px 900px at 65% 88%, rgba(251, 188, 5, 0.12), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1) 40%, var(--bg0));
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
line-height: 1.55;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -999px;
|
||||
top: 10px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--stroke);
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
z-index: 20;
|
||||
}
|
||||
.skip:focus {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.bg__mesh {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(1200px 800px at 20% 20%, rgba(66, 133, 244, 0.18), transparent 60%),
|
||||
radial-gradient(1000px 800px at 82% 30%, rgba(234, 67, 53, 0.12), transparent 55%),
|
||||
radial-gradient(1000px 900px at 40% 85%, rgba(52, 168, 83, 0.12), transparent 55%);
|
||||
filter: blur(4px) saturate(1.12);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.bg__grid {
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
background-image: linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||||
background-size: 72px 72px;
|
||||
opacity: 0.055;
|
||||
transform: perspective(900px) rotateX(58deg) translateY(-18%);
|
||||
transform-origin: top;
|
||||
mask-image: radial-gradient(60% 60% at 50% 30%, rgba(0, 0, 0, 1), transparent 72%);
|
||||
}
|
||||
|
||||
.bg__grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.2;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(%23n)' opacity='.45'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: min(1100px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: rgba(7, 7, 11, 0.58);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.top__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.brand__mark {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 10px;
|
||||
background: conic-gradient(from 200deg, var(--b), var(--g), var(--y), var(--r), var(--b));
|
||||
box-shadow: 0 10px 24px rgba(66, 133, 244, 0.15), 0 10px 24px rgba(52, 168, 83, 0.12);
|
||||
}
|
||||
|
||||
.brand__mark--small {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.brand__name {
|
||||
font-family: var(--mono);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand__tag {
|
||||
font-size: 12px;
|
||||
color: var(--dim);
|
||||
border: 1px solid var(--stroke2);
|
||||
background: rgba(17, 17, 26, 0.55);
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav__cta {
|
||||
color: var(--text) !important;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 56px 0 26px;
|
||||
}
|
||||
|
||||
.hero__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 0.95fr;
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(243, 244, 246, 0.58);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.kicker::before {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(135deg, rgba(66, 133, 244, 0.9), rgba(52, 168, 83, 0.85));
|
||||
box-shadow: 0 0 0 4px rgba(66, 133, 244, 0.08);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 0.95;
|
||||
margin: 0 0 14px;
|
||||
font-size: clamp(44px, 5.5vw, 68px);
|
||||
}
|
||||
|
||||
.hero__word {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: rise 700ms cubic-bezier(0.2, 1, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
.hero__word:nth-child(1) {
|
||||
animation-delay: 80ms;
|
||||
}
|
||||
.hero__word:nth-child(2) {
|
||||
animation-delay: 160ms;
|
||||
}
|
||||
.hero__word:nth-child(3) {
|
||||
animation-delay: 260ms;
|
||||
}
|
||||
|
||||
.hero__word--mono {
|
||||
font-family: var(--mono);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.04em;
|
||||
color: rgba(243, 244, 246, 0.92);
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 0 0 18px;
|
||||
font-size: 16.5px;
|
||||
color: var(--muted);
|
||||
max-width: 52ch;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: rise 700ms cubic-bezier(0.2, 1, 0.2, 1) 320ms forwards;
|
||||
}
|
||||
|
||||
.lede strong {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0 0 20px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: rise 700ms cubic-bezier(0.2, 1, 0.2, 1) 380ms forwards;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 12px;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--stroke2);
|
||||
background: rgba(17, 17, 26, 0.55);
|
||||
color: rgba(243, 244, 246, 0.76);
|
||||
}
|
||||
|
||||
.pill--b {
|
||||
box-shadow: inset 0 0 0 1px rgba(66, 133, 244, 0.25);
|
||||
}
|
||||
.pill--r {
|
||||
box-shadow: inset 0 0 0 1px rgba(234, 67, 53, 0.22);
|
||||
}
|
||||
.pill--y {
|
||||
box-shadow: inset 0 0 0 1px rgba(251, 188, 5, 0.22);
|
||||
}
|
||||
.pill--g {
|
||||
box-shadow: inset 0 0 0 1px rgba(52, 168, 83, 0.22);
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 0 0 12px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: rise 700ms cubic-bezier(0.2, 1, 0.2, 1) 440ms forwards;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-decoration: none !important;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: linear-gradient(135deg, rgba(66, 133, 244, 0.92), rgba(52, 168, 83, 0.86));
|
||||
color: #091018;
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 18px 44px rgba(66, 133, 244, 0.18), 0 18px 44px rgba(52, 168, 83, 0.14);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: rgba(243, 244, 246, 0.56);
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: rise 700ms cubic-bezier(0.2, 1, 0.2, 1) 520ms forwards;
|
||||
}
|
||||
|
||||
.note code {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
color: rgba(243, 244, 246, 0.84);
|
||||
}
|
||||
|
||||
.hero__panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: rise 700ms cubic-bezier(0.2, 1, 0.2, 1) 260ms forwards;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--card);
|
||||
border-radius: var(--radius2);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.term__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(12, 12, 18, 0.58);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.dot--r {
|
||||
background: #ff5f57;
|
||||
}
|
||||
.dot--y {
|
||||
background: #febc2e;
|
||||
}
|
||||
.dot--g {
|
||||
background: #28c840;
|
||||
}
|
||||
|
||||
.term__title {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: rgba(243, 244, 246, 0.55);
|
||||
}
|
||||
|
||||
.term__body {
|
||||
padding: 14px 14px 16px;
|
||||
background: rgba(11, 11, 17, 0.6);
|
||||
}
|
||||
|
||||
.term__pre {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: rgba(243, 244, 246, 0.86);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.term__pre code {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.meta {
|
||||
padding: 14px 14px;
|
||||
background: var(--card2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.meta__item {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.meta__item + .meta__item {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.meta__k {
|
||||
color: rgba(243, 244, 246, 0.55);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.meta__v {
|
||||
color: rgba(243, 244, 246, 0.86);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta__v code {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
color: rgba(243, 244, 246, 0.9);
|
||||
}
|
||||
34
docs/assets/site.js
Normal file
34
docs/assets/site.js
Normal file
@ -0,0 +1,34 @@
|
||||
(() => {
|
||||
const el = document.getElementById("demo");
|
||||
if (!el) return;
|
||||
|
||||
const original = el.textContent || "";
|
||||
const prefersReduced = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
if (prefersReduced) return;
|
||||
|
||||
el.textContent = "";
|
||||
|
||||
const lines = original.split("\n");
|
||||
const chunkDelay = 16;
|
||||
const lineDelay = 140;
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
|
||||
const tick = () => {
|
||||
if (i >= lines.length) return;
|
||||
const line = lines[i];
|
||||
if (j <= line.length) {
|
||||
el.textContent += line.slice(j, j + 1);
|
||||
j += 1;
|
||||
window.setTimeout(tick, chunkDelay);
|
||||
return;
|
||||
}
|
||||
el.textContent += "\n";
|
||||
i += 1;
|
||||
j = 0;
|
||||
window.setTimeout(tick, lineDelay);
|
||||
};
|
||||
|
||||
window.setTimeout(tick, 260);
|
||||
})();
|
||||
|
||||
239
docs/assets/site.more.css
Normal file
239
docs/assets/site.more.css
Normal file
@ -0,0 +1,239 @@
|
||||
.section {
|
||||
padding: 52px 0;
|
||||
}
|
||||
|
||||
.section__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 0.36fr 0.64fr;
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 6px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.block {
|
||||
padding: 16px 16px;
|
||||
}
|
||||
|
||||
.code {
|
||||
margin: 0;
|
||||
padding: 12px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.code code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: rgba(243, 244, 246, 0.88);
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: grid;
|
||||
grid-template-columns: 46px 1fr;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(12, 12, 18, 0.46);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.step__n {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: var(--mono);
|
||||
font-weight: 500;
|
||||
color: rgba(243, 244, 246, 0.92);
|
||||
background: linear-gradient(135deg, rgba(66, 133, 244, 0.22), rgba(52, 168, 83, 0.18));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.step p {
|
||||
margin: 0 0 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.callout {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: linear-gradient(135deg, rgba(66, 133, 244, 0.14), rgba(234, 67, 53, 0.08));
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.callout__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 0 0 1px rgba(66, 133, 244, 0.18);
|
||||
}
|
||||
|
||||
.callout__body p {
|
||||
margin: 0 0 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.callout__body code {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
color: rgba(243, 244, 246, 0.92);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.feat {
|
||||
padding: 16px;
|
||||
background: rgba(12, 12, 18, 0.46);
|
||||
box-shadow: var(--shadow2);
|
||||
}
|
||||
|
||||
.feat p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.footerline {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.sitefoot {
|
||||
padding: 36px 0 26px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(7, 7, 11, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.sitefoot__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.sitefoot__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.sitefoot__small {
|
||||
margin-top: 8px;
|
||||
color: rgba(243, 244, 246, 0.6);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sitefoot__small a {
|
||||
color: rgba(243, 244, 246, 0.75);
|
||||
}
|
||||
|
||||
.sep {
|
||||
margin: 0 8px;
|
||||
color: rgba(243, 244, 246, 0.28);
|
||||
}
|
||||
|
||||
.sitefoot__right {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
color: rgba(243, 244, 246, 0.7);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sitefoot__fineprint {
|
||||
margin-top: 18px;
|
||||
color: rgba(243, 244, 246, 0.46);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.section__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wrap {
|
||||
width: min(1100px, calc(100% - 28px));
|
||||
}
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
.cols {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.footerline {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sitefoot__row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
# Contacts Dedupe Preview
|
||||
|
||||
read_when:
|
||||
- Finding duplicate Google Contacts.
|
||||
- Reviewing or changing `gog contacts dedupe`.
|
||||
|
||||
`gog contacts dedupe` finds likely duplicate personal contacts and prints a
|
||||
merge plan. It is preview-only: it does not merge, update, or delete contacts.
|
||||
|
||||
## Command Page
|
||||
|
||||
- [`gog contacts dedupe`](commands/gog-contacts-dedupe.md)
|
||||
|
||||
## Basic Use
|
||||
|
||||
```bash
|
||||
gog contacts dedupe
|
||||
gog contacts dedupe --json
|
||||
gog contacts dedupe --max 500 --json
|
||||
```
|
||||
|
||||
Default matching uses normalized email and phone values:
|
||||
|
||||
```bash
|
||||
gog contacts dedupe --match email,phone
|
||||
```
|
||||
|
||||
Name matching is opt-in because it can produce false positives:
|
||||
|
||||
```bash
|
||||
gog contacts dedupe --match email,phone,name
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
The command groups contacts that share a matching key. JSON output includes:
|
||||
|
||||
- `scanned`: number of contacts examined
|
||||
- `groups`: likely duplicate groups
|
||||
- `primary`: the contact gog would keep first in a hypothetical merge plan
|
||||
- `merged`: merged emails/phones for preview
|
||||
- `matched_on`: duplicate email/phone/name keys that caused the group
|
||||
- `members`: all contacts in the group
|
||||
|
||||
## Safety
|
||||
|
||||
`contacts dedupe` is read-only. There is no apply flag.
|
||||
|
||||
Use `--dry-run` in automation anyway when you want a uniform safety habit across
|
||||
commands:
|
||||
|
||||
```bash
|
||||
gog contacts dedupe --dry-run --json
|
||||
```
|
||||
|
||||
Use `--fail-empty` in scheduled checks when "no duplicates" should be reported
|
||||
as a distinct exit code:
|
||||
|
||||
```bash
|
||||
gog contacts dedupe --fail-empty
|
||||
```
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [Raw API Dumps](raw-api.md)
|
||||
- [Generated Contacts command pages](commands/gog-contacts.md)
|
||||
- [`gog contacts export`](commands/gog-contacts-export.md)
|
||||
- [`gog contacts raw`](commands/gog-contacts-raw.md)
|
||||
@ -1,94 +0,0 @@
|
||||
# Google Docs Editing
|
||||
|
||||
read_when:
|
||||
- Editing Google Docs content, tabs, formatting, comments, or raw Docs output.
|
||||
- Reviewing Docs write, format, find-replace, or tab commands.
|
||||
|
||||
Docs commands cover document creation, export, content writes, find/replace,
|
||||
comments, tabs, formatting, and raw API inspection.
|
||||
|
||||
## Write Markdown
|
||||
|
||||
Append Markdown and convert it to Google Docs formatting:
|
||||
|
||||
```bash
|
||||
gog docs write <docId> --append --markdown --text '## Status'
|
||||
```
|
||||
|
||||
Replace the document body with Markdown from a file:
|
||||
|
||||
```bash
|
||||
gog docs write <docId> --replace --markdown --content-file README.md
|
||||
```
|
||||
|
||||
Command pages:
|
||||
|
||||
- [`gog docs write`](commands/gog-docs-write.md)
|
||||
- [`gog docs export`](commands/gog-docs-export.md)
|
||||
- [`gog docs cat`](commands/gog-docs-cat.md)
|
||||
|
||||
## Format Text
|
||||
|
||||
Apply text or paragraph formatting:
|
||||
|
||||
```bash
|
||||
gog docs format <docId> --match Status --bold --font-size 18
|
||||
gog docs format <docId> --match "Action item" --text-color '#b00020'
|
||||
gog docs format <docId> --match Heading --alignment center --line-spacing 120
|
||||
```
|
||||
|
||||
Use `--match-all` when every occurrence should be formatted.
|
||||
|
||||
Command page:
|
||||
|
||||
- [`gog docs format`](commands/gog-docs-format.md)
|
||||
|
||||
## Tabs
|
||||
|
||||
Manage Google Docs tabs:
|
||||
|
||||
```bash
|
||||
gog docs list-tabs <docId>
|
||||
gog docs add-tab <docId> --title "Notes"
|
||||
gog docs rename-tab <docId> <tabId> "Archive"
|
||||
gog docs delete-tab <docId> <tabId> --force
|
||||
```
|
||||
|
||||
Tab-aware commands accept `--tab` by title or ID:
|
||||
|
||||
```bash
|
||||
gog docs write <docId> --append --tab "Notes" --text "Follow-up"
|
||||
gog docs find-replace <docId> old new --tab "Notes" --dry-run
|
||||
```
|
||||
|
||||
Command pages:
|
||||
|
||||
- [`gog docs list-tabs`](commands/gog-docs-list-tabs.md)
|
||||
- [`gog docs add-tab`](commands/gog-docs-add-tab.md)
|
||||
- [`gog docs rename-tab`](commands/gog-docs-rename-tab.md)
|
||||
- [`gog docs delete-tab`](commands/gog-docs-delete-tab.md)
|
||||
|
||||
## Find and Replace
|
||||
|
||||
```bash
|
||||
gog docs find-replace <docId> old new --dry-run
|
||||
gog docs find-replace <docId> old '' --first
|
||||
gog docs find-replace <docId> PLACEHOLDER --content-file replacement.md --format markdown
|
||||
```
|
||||
|
||||
`--dry-run` is read-only and reports match counts. Empty replacement strings are
|
||||
allowed and delete matches.
|
||||
|
||||
Command page:
|
||||
|
||||
- [`gog docs find-replace`](commands/gog-docs-find-replace.md)
|
||||
|
||||
## Raw Docs Output
|
||||
|
||||
Use raw output when a script needs the Google Docs API object:
|
||||
|
||||
```bash
|
||||
gog docs raw <docId> --pretty
|
||||
```
|
||||
|
||||
See [Raw API Dumps](raw-api.md) for lossless-output safety notes.
|
||||
@ -1,75 +0,0 @@
|
||||
# Drive Audits
|
||||
|
||||
read_when:
|
||||
- Auditing Drive folder contents, size, or inventory without changing files.
|
||||
- Reviewing `drive tree`, `drive du`, or `drive inventory`.
|
||||
|
||||
Drive audit commands are read-only reporting helpers. They are meant for cleanup
|
||||
planning, migration review, and automation that needs stable JSON without
|
||||
writing back to Drive.
|
||||
|
||||
## Commands
|
||||
|
||||
- [`gog drive tree`](commands/gog-drive-tree.md)
|
||||
- [`gog drive du`](commands/gog-drive-du.md)
|
||||
- [`gog drive inventory`](commands/gog-drive-inventory.md)
|
||||
- [`gog drive ls`](commands/gog-drive-ls.md)
|
||||
- [`gog drive get`](commands/gog-drive-get.md)
|
||||
- [`gog drive raw`](commands/gog-drive-raw.md)
|
||||
|
||||
## Folder Tree
|
||||
|
||||
Print a readable folder tree:
|
||||
|
||||
```bash
|
||||
gog drive tree --parent <folderId> --depth 2
|
||||
```
|
||||
|
||||
Use JSON when another tool should consume the result:
|
||||
|
||||
```bash
|
||||
gog drive tree --parent <folderId> --depth 3 --json
|
||||
```
|
||||
|
||||
## Size Summary
|
||||
|
||||
Summarize folder sizes:
|
||||
|
||||
```bash
|
||||
gog drive du --parent <folderId> --max 20
|
||||
gog drive du --parent <folderId> --depth 2 --sort size --json
|
||||
```
|
||||
|
||||
`drive du` counts files under folders and sorts by `size`, `path`, or `files`.
|
||||
|
||||
## Inventory Export
|
||||
|
||||
Export a read-only item inventory:
|
||||
|
||||
```bash
|
||||
gog drive inventory --parent <folderId> --json
|
||||
gog drive inventory --parent <folderId> --max 0 --depth 0 --json > drive-inventory.json
|
||||
```
|
||||
|
||||
Use inventory output when you need a machine-readable list of Drive objects for
|
||||
review, diffing, or downstream cleanup scripts.
|
||||
|
||||
## Shared Drives
|
||||
|
||||
The audit commands include shared drives by default where the underlying Drive
|
||||
API supports it. Pass `--no-all-drives` to restrict a scan to My Drive:
|
||||
|
||||
```bash
|
||||
gog drive inventory --parent root --no-all-drives --json
|
||||
```
|
||||
|
||||
## Custom Fields
|
||||
|
||||
For object-level inspection, use `drive get --fields`:
|
||||
|
||||
```bash
|
||||
gog drive get <fileId> --fields 'id,name,mimeType,size,owners,emailAddress' --json
|
||||
```
|
||||
|
||||
Use [`gog drive raw`](commands/gog-drive-raw.md) when you need the raw Drive API
|
||||
object, with the sensitive-field behavior described in [Raw API Dumps](raw-api.md).
|
||||
@ -1,88 +0,0 @@
|
||||
# Gmail Workflows
|
||||
|
||||
read_when:
|
||||
- Working with Gmail content, filters, watches, labels, or agent-safe reads.
|
||||
- Reviewing Gmail commands that cross from read-only into send or modify flows.
|
||||
|
||||
Gmail is one of gog's broadest surfaces. Use command-specific pages for exact
|
||||
flags, and use this page to choose the right workflow shape.
|
||||
|
||||
## Search and Read
|
||||
|
||||
```bash
|
||||
gog gmail search 'newer_than:7d' --max 10 --json
|
||||
gog gmail get <messageId> --json
|
||||
gog gmail thread get <threadId> --json
|
||||
```
|
||||
|
||||
For agents, logs, or issue reports, prefer sanitized content:
|
||||
|
||||
```bash
|
||||
gog gmail get <messageId> --sanitize-content --json
|
||||
gog gmail thread get <threadId> --sanitize-content --json
|
||||
```
|
||||
|
||||
`--sanitize-content` strips unsafe/raw payload details while keeping useful
|
||||
message text for automation.
|
||||
|
||||
## Filters
|
||||
|
||||
Export filters as Gmail WebUI-compatible XML:
|
||||
|
||||
```bash
|
||||
gog gmail settings filters export --out filters.xml
|
||||
```
|
||||
|
||||
Keep API JSON when a script needs the Gmail API shape:
|
||||
|
||||
```bash
|
||||
gog gmail settings filters export --format json --json
|
||||
```
|
||||
|
||||
Command pages:
|
||||
|
||||
- [`gog gmail settings filters export`](commands/gog-gmail-settings-filters-export.md)
|
||||
- [`gog gmail settings filters list`](commands/gog-gmail-settings-filters-list.md)
|
||||
- [`gog gmail settings filters create`](commands/gog-gmail-settings-filters-create.md)
|
||||
- [`gog gmail settings filters delete`](commands/gog-gmail-settings-filters-delete.md)
|
||||
|
||||
## Send Guardrails
|
||||
|
||||
Block send operations globally for one run:
|
||||
|
||||
```bash
|
||||
gog --gmail-no-send gmail send --to you@example.com --subject test --text body
|
||||
```
|
||||
|
||||
Or use the environment variable in agent shells:
|
||||
|
||||
```bash
|
||||
export GOG_GMAIL_NO_SEND=1
|
||||
```
|
||||
|
||||
For account-specific send blocking, use the no-send config commands:
|
||||
|
||||
- [`gog config no-send set`](commands/gog-config-no-send-set.md)
|
||||
- [`gog config no-send list`](commands/gog-config-no-send-list.md)
|
||||
- [`gog config no-send remove`](commands/gog-config-no-send-remove.md)
|
||||
|
||||
## Watches and Push
|
||||
|
||||
Gmail watch/PubSub workflows are documented in [Gmail watch](watch.md).
|
||||
|
||||
Key command pages:
|
||||
|
||||
- [`gog gmail watch start`](commands/gog-gmail-settings-watch-start.md)
|
||||
- [`gog gmail watch serve`](commands/gog-gmail-settings-watch-serve.md)
|
||||
- [`gog gmail watch renew`](commands/gog-gmail-settings-watch-renew.md)
|
||||
- [`gog gmail history`](commands/gog-gmail-history.md)
|
||||
|
||||
## Email Tracking
|
||||
|
||||
Open tracking is documented in [Email Tracking](email-tracking.md) and
|
||||
[Email Tracking Worker](email-tracking-worker.md).
|
||||
|
||||
## Raw Gmail
|
||||
|
||||
Use [`gog gmail raw`](commands/gog-gmail-raw.md) when you need the underlying
|
||||
Gmail API `Message` object. See [Raw API Dumps](raw-api.md) for safety notes.
|
||||
301
docs/index.html
Normal file
301
docs/index.html
Normal file
@ -0,0 +1,301 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>gog — Google in your terminal</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="gog: a single CLI for Gmail, Calendar, Drive, Contacts, Tasks, Sheets, Docs, Slides, and People."
|
||||
/>
|
||||
<meta name="theme-color" content="#0b0b11" />
|
||||
<meta property="og:title" content="gog — Google in your terminal" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="One CLI for Gmail, Calendar, Drive, Contacts, Tasks, Sheets, Docs, Slides, and People."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://gogcli.sh/" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="./assets/site.css" />
|
||||
<link rel="stylesheet" href="./assets/site.more.css" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
<div class="bg" aria-hidden="true">
|
||||
<div class="bg__mesh"></div>
|
||||
<div class="bg__grid"></div>
|
||||
<div class="bg__grain"></div>
|
||||
</div>
|
||||
|
||||
<header class="top">
|
||||
<div class="wrap top__row">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand__mark" aria-hidden="true"></span>
|
||||
<span class="brand__name">gog</span>
|
||||
<span class="brand__tag">gogcli</span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="#install">Install</a>
|
||||
<a href="#quickstart">Quickstart</a>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#examples">Examples</a>
|
||||
<a class="nav__cta" href="https://github.com/steipete/gogcli">GitHub</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="main">
|
||||
<section class="hero">
|
||||
<div class="wrap hero__grid">
|
||||
<div class="hero__copy">
|
||||
<p class="kicker">Google Workspace. One binary.</p>
|
||||
<h1>
|
||||
<span class="hero__word">Google</span>
|
||||
<span class="hero__word">in your</span>
|
||||
<span class="hero__word hero__word--mono">terminal</span>
|
||||
</h1>
|
||||
<p class="lede">
|
||||
<strong>gog</strong> unifies Gmail, Calendar, Drive, Contacts, Tasks, Sheets, Docs, Slides, and People
|
||||
under one CLI — with JSON output and sane defaults.
|
||||
</p>
|
||||
|
||||
<div class="pills" aria-label="Supported services">
|
||||
<span class="pill pill--b">Gmail</span>
|
||||
<span class="pill pill--g">Calendar</span>
|
||||
<span class="pill pill--r">Drive</span>
|
||||
<span class="pill pill--y">Contacts</span>
|
||||
<span class="pill pill--g">Tasks</span>
|
||||
<span class="pill pill--g">Sheets</span>
|
||||
<span class="pill pill--b">Docs</span>
|
||||
<span class="pill pill--r">Slides</span>
|
||||
<span class="pill pill--y">People</span>
|
||||
</div>
|
||||
|
||||
<div class="hero__actions">
|
||||
<a class="btn btn--primary" href="#install">Install</a>
|
||||
<a class="btn btn--ghost" href="https://github.com/steipete/gogcli#quick-start">Readme</a>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
Tip: set a default in <code>gog auth manage</code> or export <code>GOG_ACCOUNT=you@gmail.com</code> once, stop repeating <code>--account</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero__panel">
|
||||
<div class="card term" role="region" aria-label="Terminal demo">
|
||||
<div class="term__bar" aria-hidden="true">
|
||||
<span class="dots">
|
||||
<span class="dot dot--r"></span>
|
||||
<span class="dot dot--y"></span>
|
||||
<span class="dot dot--g"></span>
|
||||
</span>
|
||||
<span class="term__title">gogcli.sh</span>
|
||||
</div>
|
||||
<div class="term__body">
|
||||
<pre class="term__pre"><code id="demo">
|
||||
$ brew install gogcli
|
||||
$ gog auth credentials ~/Downloads/client_secret.json
|
||||
$ gog auth add you@gmail.com
|
||||
|
||||
$ export GOG_ACCOUNT=you@gmail.com
|
||||
$ gog gmail labels list
|
||||
$ gog calendar calendars --max 5 --json | jq '.calendars[].summary'
|
||||
$ gog drive ls --query "mimeType='application/pdf'" --max 3
|
||||
</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card meta">
|
||||
<div class="meta__item">
|
||||
<div class="meta__k">Output</div>
|
||||
<div class="meta__v">tables / <code>--plain</code> / <code>--json</code></div>
|
||||
</div>
|
||||
<div class="meta__item">
|
||||
<div class="meta__k">Accounts</div>
|
||||
<div class="meta__v">multi-account + <code>gog auth manage</code></div>
|
||||
</div>
|
||||
<div class="meta__item">
|
||||
<div class="meta__k">Secrets</div>
|
||||
<div class="meta__v">OS keyring (Keychain / Secret Service / CredMan)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="install" class="section">
|
||||
<div class="wrap section__grid">
|
||||
<div>
|
||||
<h2>Install</h2>
|
||||
<p class="muted">Homebrew, or build from source.</p>
|
||||
</div>
|
||||
|
||||
<div class="cols">
|
||||
<div class="card block">
|
||||
<h3>Homebrew</h3>
|
||||
<pre class="code"><code>brew install gogcli</code></pre>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h3>From source</h3>
|
||||
<pre class="code"><code>git clone https://github.com/steipete/gogcli.git
|
||||
cd gogcli
|
||||
make
|
||||
./bin/gog --help</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="quickstart" class="section">
|
||||
<div class="wrap section__grid">
|
||||
<div>
|
||||
<h2>Quickstart</h2>
|
||||
<p class="muted">
|
||||
You’ll need a Google Cloud “Desktop app” OAuth client JSON once. Then you can keep adding accounts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step__n">1</div>
|
||||
<div class="step__b">
|
||||
<h3>Store credentials</h3>
|
||||
<p>Save your downloaded client JSON into gog’s config.</p>
|
||||
<pre class="code"><code>gog auth credentials ~/Downloads/client_secret_....json</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step__n">2</div>
|
||||
<div class="step__b">
|
||||
<h3>Authorize an account</h3>
|
||||
<p>Browser flow by default. Use <code>--manual</code> for headless.</p>
|
||||
<pre class="code"><code>gog auth add you@gmail.com</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step__n">3</div>
|
||||
<div class="step__b">
|
||||
<h3>Run commands</h3>
|
||||
<p>Use <code>--json</code> for scripting.</p>
|
||||
<pre class="code"><code>export GOG_ACCOUNT=you@gmail.com
|
||||
gog gmail search 'newer_than:7d' --max 10 --json | jq</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout">
|
||||
<div class="callout__icon" aria-hidden="true"></div>
|
||||
<div class="callout__body">
|
||||
<h3>Re-auth a service (e.g. Sheets)</h3>
|
||||
<p>
|
||||
If you add scopes later and Google doesn’t return a refresh token, re-run with
|
||||
<code>--force-consent</code>.
|
||||
</p>
|
||||
<pre class="code"><code>gog auth add you@gmail.com --services sheets --force-consent</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="features" class="section">
|
||||
<div class="wrap section__grid">
|
||||
<div>
|
||||
<h2>Features</h2>
|
||||
<p class="muted">High leverage commands, consistent UX, and clean output.</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card feat">
|
||||
<h3>Gmail</h3>
|
||||
<p>Search threads, send mail, manage labels, drafts, filters, settings, and watch (Pub/Sub push).</p>
|
||||
</div>
|
||||
<div class="card feat">
|
||||
<h3>Calendar</h3>
|
||||
<p>List/create/update events, respond to invites, detect conflicts, and check free/busy.</p>
|
||||
</div>
|
||||
<div class="card feat">
|
||||
<h3>Drive</h3>
|
||||
<p>List/search/upload/download, export Docs formats, permissions, folders, URLs.</p>
|
||||
</div>
|
||||
<div class="card feat">
|
||||
<h3>Sheets / Docs / Slides</h3>
|
||||
<p>Read/write Sheets; export Docs/Slides/Sheets to PDF/DOCX/PPTX/XLSX/CSV via Drive.</p>
|
||||
</div>
|
||||
<div class="card feat">
|
||||
<h3>Contacts / People</h3>
|
||||
<p>Personal contacts, “other contacts”, Workspace directory, and your profile.</p>
|
||||
</div>
|
||||
<div class="card feat">
|
||||
<h3>Tasks</h3>
|
||||
<p>Tasklists + tasks: add/update/done/undo/delete/clear with paging and JSON output.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="examples" class="section">
|
||||
<div class="wrap section__grid">
|
||||
<div>
|
||||
<h2>Examples</h2>
|
||||
<p class="muted">A few commands you’ll actually use.</p>
|
||||
</div>
|
||||
<div class="cols">
|
||||
<div class="card block">
|
||||
<h3>Find unread mail</h3>
|
||||
<pre class="code"><code>gog gmail search 'is:unread newer_than:7d' --max 20</code></pre>
|
||||
<p class="muted">Pipe JSON to jq for scripts.</p>
|
||||
<pre class="code"><code>gog gmail search 'newer_than:7d' --max 50 --json | jq '.threads[] | .subject'</code></pre>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h3>Export a Sheet as PDF</h3>
|
||||
<pre class="code"><code>gog sheets export <spreadsheetId> --format pdf --out ./sheet.pdf</code></pre>
|
||||
<p class="muted">Docs and Slides are similar.</p>
|
||||
<pre class="code"><code>gog docs export <docId> --format docx --out ./doc.docx
|
||||
gog slides export <presentationId> --format pptx --out ./deck.pptx</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footerline">
|
||||
<a class="btn btn--primary" href="https://github.com/steipete/gogcli">Go to GitHub</a>
|
||||
<a class="btn btn--ghost" href="https://github.com/steipete/gogcli/blob/main/CHANGELOG.md">Changelog</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="sitefoot">
|
||||
<div class="wrap sitefoot__row">
|
||||
<div class="sitefoot__left">
|
||||
<div class="sitefoot__brand">
|
||||
<span class="brand__mark brand__mark--small" aria-hidden="true"></span>
|
||||
<span>gog</span>
|
||||
</div>
|
||||
<div class="sitefoot__small">
|
||||
<span>Built by <a href="https://steipete.me">Peter Steinberger</a>.</span>
|
||||
<span class="sep" aria-hidden="true">·</span>
|
||||
<a href="https://github.com/steipete/gogcli/blob/main/LICENSE">MIT</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sitefoot__right">
|
||||
<a href="https://github.com/steipete/gogcli">Source</a>
|
||||
<a href="https://github.com/steipete/gogcli#installation">Install</a>
|
||||
<a href="https://github.com/steipete/gogcli#commands">Commands</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap sitefoot__fineprint">
|
||||
<span>Not affiliated with Google. Google is a trademark of Google LLC.</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="./assets/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,50 +0,0 @@
|
||||
---
|
||||
title: Overview
|
||||
permalink: /
|
||||
description: "gog is a single Go CLI for Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Apps Script, Contacts, Tasks, and Workspace admin — built for terminals, scripts, CI, and coding agents."
|
||||
---
|
||||
|
||||
## Try it
|
||||
|
||||
After you store an OAuth client and authorize an account ([Quickstart](quickstart.md) walks through the five-minute version), everything is a one-liner.
|
||||
|
||||
```bash
|
||||
# Search this week's mail and read a sanitized message body for an agent.
|
||||
gog gmail search 'newer_than:7d' --max 10
|
||||
gog gmail get <messageId> --sanitize-content --json
|
||||
|
||||
# Today's calendar.
|
||||
gog calendar events --today
|
||||
|
||||
# Audit a Drive folder without changing anything.
|
||||
gog drive tree --parent <folderId> --depth 2
|
||||
gog drive du --parent <folderId> --max 20 --json
|
||||
|
||||
# Edit a Doc, append to a Sheet table, push slides from Markdown.
|
||||
gog docs format <docId> --match Status --bold --font-size 18
|
||||
gog sheets table append <spreadsheetId> Tasks 'Ship README|done'
|
||||
gog slides create-from-markdown "Weekly update" --content-file slides.md
|
||||
```
|
||||
|
||||
`--json` produces a stable JSON envelope on stdout, `--plain` produces TSV; human progress, prompts, and warnings always go to stderr so pipes stay parseable.
|
||||
|
||||
## What gog does
|
||||
|
||||
- **One binary, every API.** Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Apps Script, Contacts, People, Tasks, Classroom, Chat, Groups, Keep, and Workspace Admin.
|
||||
- **Stable output.** `--json` for scripts, `--plain` TSV for `awk`, human output on stderr.
|
||||
- **Multi-account, multi-client.** Many Google accounts and OAuth client projects in one config; OAuth, direct access tokens, ADC, and Workspace service accounts all supported.
|
||||
- **Built for agents.** Runtime allow/deny lists (`--enable-commands`, `--disable-commands`, `--gmail-no-send`) plus baked safety-profile binaries that cannot be reconfigured at runtime.
|
||||
- **Read-only audits.** Drive `tree`, `du`, `inventory`; Contacts `dedupe` preview; raw API JSON dumps without ever mutating remote state.
|
||||
- **Generated reference.** Every command has a docs page produced from `gog schema --json`.
|
||||
|
||||
## Pick your path
|
||||
|
||||
- **Trying it.** [Install](install.md) → [Quickstart](quickstart.md). Five minutes from `brew install` to your first authenticated query.
|
||||
- **Wiring up an agent.** [Safety Profiles](safety-profiles.md) and the bundled [`gog` agent skill](https://github.com/steipete/gogcli/blob/main/.agents/skills/gog/SKILL.md). Lock the binary down before handing it to a model.
|
||||
- **Running Workspace at scale.** [Auth Clients](auth-clients.md) for service accounts, named OAuth clients, and domain-wide delegation.
|
||||
- **Backing up an account.** [Backup](backup.md) before pointing `gog backup push` at a busy mailbox.
|
||||
- **Looking up a flag.** The [Command Index](commands/) has a generated page for every subcommand.
|
||||
|
||||
## Project
|
||||
|
||||
Active development; the [changelog](https://github.com/steipete/gogcli/blob/main/CHANGELOG.md) tracks what shipped recently. Goals and non-goals live in the [spec](spec.md). Released under the [MIT license](https://github.com/steipete/gogcli/blob/main/LICENSE). Not affiliated with Google.
|
||||
124
docs/install.md
124
docs/install.md
@ -1,124 +0,0 @@
|
||||
# Install
|
||||
|
||||
`gog` ships as a single binary. The visible version is injected at build time:
|
||||
release builds use the tag, while local builds use `git describe`.
|
||||
|
||||
## Homebrew (macOS, Linux)
|
||||
|
||||
```bash
|
||||
brew install gogcli
|
||||
gog --version
|
||||
```
|
||||
|
||||
The Homebrew formula lives in `steipete/homebrew-tap` and installs the `gog`
|
||||
binary. Release verification should run:
|
||||
|
||||
```bash
|
||||
brew test steipete/tap/gogcli
|
||||
gog --version
|
||||
```
|
||||
|
||||
## Docker / GHCR
|
||||
|
||||
Release tags publish a non-root GitHub Container Registry image:
|
||||
|
||||
```bash
|
||||
docker run --rm ghcr.io/steipete/gogcli:latest version
|
||||
docker run --rm ghcr.io/steipete/gogcli:v0.15.0 version
|
||||
```
|
||||
|
||||
Authenticated container runs should mount a persistent config directory and
|
||||
use the encrypted file keyring:
|
||||
|
||||
```bash
|
||||
docker volume create gogcli-config
|
||||
|
||||
docker run --rm -it \
|
||||
-e GOG_KEYRING_BACKEND=file \
|
||||
-e GOG_KEYRING_PASSWORD \
|
||||
-v gogcli-config:/home/gog/.config/gogcli \
|
||||
ghcr.io/steipete/gogcli:latest \
|
||||
auth add you@gmail.com --services gmail,calendar,drive
|
||||
```
|
||||
|
||||
Keep `GOG_KEYRING_PASSWORD` in the shell session or your CI secret store. Do
|
||||
not bake it into images, scripts, or checked-in profiles.
|
||||
|
||||
## Windows
|
||||
|
||||
Download the matching ZIP from the
|
||||
[latest release](https://github.com/steipete/gogcli/releases):
|
||||
|
||||
- `gogcli_<version>_windows_amd64.zip`
|
||||
- `gogcli_<version>_windows_arm64.zip`
|
||||
|
||||
Extract `gog.exe` and put its directory on `PATH`.
|
||||
|
||||
## GitHub releases (raw binaries)
|
||||
|
||||
Release assets are uploaded by GoReleaser:
|
||||
|
||||
- `gogcli_<version>_darwin_amd64.tar.gz`
|
||||
- `gogcli_<version>_darwin_arm64.tar.gz`
|
||||
- `gogcli_<version>_linux_amd64.tar.gz`
|
||||
- `gogcli_<version>_linux_arm64.tar.gz`
|
||||
- `gogcli_<version>_windows_amd64.zip`
|
||||
- `gogcli_<version>_windows_arm64.zip`
|
||||
- `checksums.txt`
|
||||
|
||||
Browse the [releases page](https://github.com/steipete/gogcli/releases) for
|
||||
the latest tag and the full asset list.
|
||||
|
||||
## Build from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/steipete/gogcli.git
|
||||
cd gogcli
|
||||
make
|
||||
./bin/gog --version
|
||||
```
|
||||
|
||||
Source builds require the Go version declared in `go.mod`.
|
||||
|
||||
## Safety-profile binaries
|
||||
|
||||
When `gog` is going to be invoked by an agent, sandbox, or other caller that
|
||||
should not be able to broaden its own permissions, build a safety-profile
|
||||
binary instead of the default one. See [Safety Profiles](safety-profiles.md).
|
||||
|
||||
```bash
|
||||
./build-safe.sh safety-profiles/agent-safe.yaml -o bin/gog-agent-safe
|
||||
./build-safe.sh safety-profiles/readonly.yaml -o bin/gog-readonly
|
||||
```
|
||||
|
||||
## Verify the install
|
||||
|
||||
```bash
|
||||
gog --version
|
||||
gog auth keyring # report current keyring backend
|
||||
gog --help # discover top-level commands
|
||||
```
|
||||
|
||||
After running [`gog auth credentials`](commands/gog-auth-credentials.md) and
|
||||
[`gog auth add`](commands/gog-auth-add.md), `gog auth doctor --check` reports
|
||||
keyring health, refresh-token validity, and Workspace-specific failure modes.
|
||||
|
||||
## Updating
|
||||
|
||||
- **Homebrew:** `brew upgrade gogcli`.
|
||||
- **Docker:** pull a new tag (`ghcr.io/steipete/gogcli:vX.Y.Z`).
|
||||
- **GitHub release archives:** download the new tarball/ZIP and replace the
|
||||
binary.
|
||||
- **Source builds:** `git pull && make` — the version string comes from
|
||||
`git describe`.
|
||||
|
||||
Refresh tokens and OAuth clients are forward-compatible across point releases;
|
||||
no migration step is required for normal upgrades.
|
||||
|
||||
## Related command pages
|
||||
|
||||
- [`gog version`](commands/gog-version.md)
|
||||
- [`gog auth keyring`](commands/gog-auth-keyring.md)
|
||||
- [`gog auth credentials`](commands/gog-auth-credentials.md)
|
||||
- [`gog auth add`](commands/gog-auth-add.md)
|
||||
- [`gog auth doctor`](commands/gog-auth-doctor.md)
|
||||
@ -1,131 +0,0 @@
|
||||
---
|
||||
title: Quickstart
|
||||
description: "Five minutes from a clean machine to a working gog setup with one Google account."
|
||||
---
|
||||
|
||||
# Quickstart
|
||||
|
||||
Five minutes from a clean machine to authenticated Gmail, Calendar, and Drive
|
||||
queries. For a deeper look at OAuth clients, service accounts, and named
|
||||
profiles, read [Auth Clients](auth-clients.md) after this.
|
||||
|
||||
## 1. Install
|
||||
|
||||
```bash
|
||||
brew install gogcli
|
||||
gog --version
|
||||
```
|
||||
|
||||
Other options (Docker, Windows ZIPs, source builds) are documented on
|
||||
[Install](install.md).
|
||||
|
||||
## 2. Get an OAuth client
|
||||
|
||||
`gog` talks to Google APIs as you, using your own Cloud project. The one-time
|
||||
setup is:
|
||||
|
||||
1. Open <https://console.cloud.google.com/projectcreate> and create a project.
|
||||
2. Enable the APIs you intend to use: Gmail, Calendar, Drive, Docs, Sheets,
|
||||
Slides, Forms, Apps Script, People (Contacts), Tasks, Classroom — whatever
|
||||
you actually need. The [API library](https://console.cloud.google.com/apis/library)
|
||||
is the fastest way to enable several at once.
|
||||
3. Configure the [OAuth consent screen](https://console.cloud.google.com/auth/branding)
|
||||
for "External" + your email; that is enough for personal use.
|
||||
4. Create a **Desktop app** OAuth client at
|
||||
<https://console.cloud.google.com/auth/clients> and download the JSON.
|
||||
|
||||
Personal `gmail.com` accounts work for normal user APIs (Gmail, Calendar,
|
||||
Drive, Docs, Sheets, Slides, Forms, Apps Script, Contacts/People, Tasks,
|
||||
Classroom). Workspace-only APIs (Admin Directory, Cloud Identity Groups, Chat,
|
||||
Keep with domain-wide delegation) require a managed domain — see
|
||||
[Auth Clients](auth-clients.md).
|
||||
|
||||
> External + Testing OAuth apps issue refresh tokens that expire after seven
|
||||
> days. Publish the OAuth app for long-lived tokens, or be ready to re-run
|
||||
> `gog auth add` weekly.
|
||||
|
||||
## 3. Store the OAuth client
|
||||
|
||||
```bash
|
||||
gog auth credentials ~/Downloads/client_secret_*.json
|
||||
```
|
||||
|
||||
The file is copied to your per-user config (`$XDG_CONFIG_HOME/gogcli/` or the
|
||||
OS-equivalent) with mode `0600`.
|
||||
|
||||
## 4. Authorize an account
|
||||
|
||||
```bash
|
||||
gog auth add you@gmail.com --services gmail,calendar,drive,docs,sheets,contacts
|
||||
```
|
||||
|
||||
A browser tab opens, you grant the requested scopes, and `gog` stores a
|
||||
refresh token in your OS keyring (Keychain on macOS, Secret Service on Linux,
|
||||
Credential Manager on Windows). Headless? Add `--manual` for a paste-the-URL
|
||||
flow, or `--remote --step 1`/`--step 2` for fully split server runs.
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
gog auth list --check
|
||||
gog auth doctor --check
|
||||
```
|
||||
|
||||
## 5. Set a default account
|
||||
|
||||
```bash
|
||||
export GOG_ACCOUNT=you@gmail.com
|
||||
# or persist a default with gog auth alias
|
||||
gog auth alias set default you@gmail.com
|
||||
```
|
||||
|
||||
Now you can drop `--account` from every command.
|
||||
|
||||
## 6. Run real commands
|
||||
|
||||
```bash
|
||||
# Gmail
|
||||
gog gmail search 'newer_than:7d' --max 10
|
||||
gog gmail get <messageId> --sanitize-content --json
|
||||
|
||||
# Calendar
|
||||
gog calendar events --today
|
||||
gog calendar create --summary "Review" \
|
||||
--from "2026-05-06T10:00:00+02:00" \
|
||||
--to "2026-05-06T10:30:00+02:00"
|
||||
|
||||
# Drive
|
||||
gog drive ls --max 20
|
||||
gog drive tree --parent <folderId> --depth 2
|
||||
gog drive du --parent <folderId> --max 20 --json
|
||||
|
||||
# Docs / Sheets / Slides
|
||||
gog docs cat <docId> --tab "Notes"
|
||||
gog sheets get <spreadsheetId> 'Sheet1!A1:D20' --json
|
||||
gog slides create-from-markdown "Weekly update" --content-file slides.md
|
||||
|
||||
# Profile
|
||||
gog me
|
||||
```
|
||||
|
||||
`--json` produces a stable JSON envelope on stdout; `--plain` produces TSV.
|
||||
Human-facing progress, hints, and warnings always go to stderr, so pipes stay
|
||||
parseable.
|
||||
|
||||
## 7. Shell completion (optional)
|
||||
|
||||
```bash
|
||||
gog completion bash >> ~/.bash_completion
|
||||
gog completion zsh > "${fpath[1]}/_gog"
|
||||
gog completion fish > ~/.config/fish/completions/gog.fish
|
||||
```
|
||||
|
||||
## Where next
|
||||
|
||||
- [Auth Clients](auth-clients.md) — named clients, service accounts, ADC,
|
||||
Workspace domain-wide delegation, OIDC subject migration.
|
||||
- [Safety Profiles](safety-profiles.md) — runtime allow/deny lists and baked
|
||||
agent-safe binaries.
|
||||
- [Gmail Workflows](gmail-workflows.md) and [Drive Audits](drive-audits.md) for
|
||||
the two surfaces most people start automating.
|
||||
- [Command Index](commands/) — generated reference for every subcommand.
|
||||
@ -1,64 +0,0 @@
|
||||
# Raw API Dumps
|
||||
|
||||
read_when:
|
||||
- Using `gog <service> raw` commands for lossless Google API JSON.
|
||||
- Passing Google API responses into scripts, debuggers, or LLM workflows.
|
||||
- Reviewing sensitive-field behavior for raw output.
|
||||
|
||||
Raw commands return the canonical Google API response shape instead of gog's
|
||||
normal curated table/JSON output. They are useful when a script needs a field
|
||||
that gog does not model yet, or when debugging an API object exactly as Google
|
||||
returns it.
|
||||
|
||||
## Commands
|
||||
|
||||
- [`gog calendar raw`](commands/gog-calendar-raw.md)
|
||||
- [`gog contacts raw`](commands/gog-contacts-raw.md)
|
||||
- [`gog docs raw`](commands/gog-docs-raw.md)
|
||||
- [`gog drive raw`](commands/gog-drive-raw.md)
|
||||
- [`gog forms raw`](commands/gog-forms-raw.md)
|
||||
- [`gog gmail raw`](commands/gog-gmail-raw.md)
|
||||
- [`gog people raw`](commands/gog-people-raw.md)
|
||||
- [`gog sheets raw`](commands/gog-sheets-raw.md)
|
||||
- [`gog slides raw`](commands/gog-slides-raw.md)
|
||||
- [`gog tasks raw`](commands/gog-tasks-raw.md)
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gog drive raw <fileId> --pretty
|
||||
gog docs raw <docId> --json > doc-api.json
|
||||
gog gmail raw <messageId> --format metadata --json
|
||||
gog sheets raw <spreadsheetId> --include-grid-data --json
|
||||
```
|
||||
|
||||
Use service-native field masks when available:
|
||||
|
||||
```bash
|
||||
gog drive raw <fileId> --fields 'id,name,mimeType,owners(emailAddress)' --json
|
||||
gog contacts raw people/c123 --person-fields names,emailAddresses,phoneNumbers --json
|
||||
```
|
||||
|
||||
## Safety Model
|
||||
|
||||
Raw output is intentionally less opinionated than normal gog output. It may
|
||||
include private document content, contact data, event attendees, Gmail payloads,
|
||||
or service-specific metadata.
|
||||
|
||||
Drive has the highest capability-URL risk. By default, `gog drive raw` redacts
|
||||
fields such as `thumbnailLink`, `webContentLink`, `exportLinks`, `resourceKey`,
|
||||
`properties`, `appProperties`, and embedded thumbnail bytes unless the user
|
||||
explicitly names fields via `--fields`.
|
||||
|
||||
Sheets warns when grid data or developer metadata could expose sensitive data,
|
||||
but keeps output lossless. Docs and Slides may include short-lived image URLs.
|
||||
|
||||
For the full sensitive-field review, read [Raw API Sensitive Field Audit](raw-audit.md).
|
||||
|
||||
## Automation Tips
|
||||
|
||||
- Prefer `--json` for scripts.
|
||||
- Prefer `--pretty` for humans.
|
||||
- Use narrow `--fields` or service-specific field masks whenever possible.
|
||||
- Do not pipe raw output into logs or LLMs unless you are comfortable with the
|
||||
object's full Google API payload.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB |
@ -1,114 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
|
||||
<title id="title">gog social card</title>
|
||||
<desc id="desc">gog: Google Workspace in your terminal. One Go CLI for Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Apps Script, Contacts, Tasks, and Workspace admin.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#fbfcff"/>
|
||||
<stop offset="0.55" stop-color="#f3f7fd"/>
|
||||
<stop offset="1" stop-color="#eef3fa"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0f172a"/>
|
||||
<stop offset="1" stop-color="#0a0f1f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="panelBar" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1b2438"/>
|
||||
<stop offset="1" stop-color="#141c2e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="googleSweep" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#4285f4"/>
|
||||
<stop offset="0.34" stop-color="#ea4335"/>
|
||||
<stop offset="0.67" stop-color="#fbbc04"/>
|
||||
<stop offset="1" stop-color="#34a853"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-10%" y="-10%" width="120%" height="130%">
|
||||
<feDropShadow dx="0" dy="24" stdDeviation="28" flood-color="#0b1430" flood-opacity="0.18"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
|
||||
<!-- Brand mark (top-left): big rounded 2x2 inside a tile -->
|
||||
<g transform="translate(76 76)">
|
||||
<rect x="0" y="0" width="118" height="118" rx="26" fill="#0f1115"/>
|
||||
<rect x="22" y="22" width="32" height="32" rx="6" fill="#4285f4"/>
|
||||
<rect x="64" y="22" width="32" height="32" rx="6" fill="#ea4335"/>
|
||||
<rect x="22" y="64" width="32" height="32" rx="6" fill="#fbbc04"/>
|
||||
<rect x="64" y="64" width="32" height="32" rx="6" fill="#34a853"/>
|
||||
</g>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="76" y="270" fill="#0f1115" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="92" font-weight="800" letter-spacing="0">gog</text>
|
||||
|
||||
<!-- Tagline (one strong line) -->
|
||||
<text x="80" y="332" fill="#0f1115" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="34" font-weight="700" letter-spacing="0">Google in your terminal.</text>
|
||||
|
||||
<!-- Description (two lines) -->
|
||||
<text x="80" y="382" fill="#475569" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="22" font-weight="500" letter-spacing="0">One Go CLI for Gmail, Calendar, Drive, Docs,</text>
|
||||
<text x="80" y="412" fill="#475569" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="22" font-weight="500" letter-spacing="0">Sheets, Slides, Contacts, Tasks, and admin.</text>
|
||||
|
||||
<!-- Multi-color accent bar -->
|
||||
<rect x="80" y="438" width="280" height="4" rx="2" fill="url(#googleSweep)"/>
|
||||
|
||||
<!-- Bottom row: install pill + URL pill -->
|
||||
<g transform="translate(80 478)">
|
||||
<!-- install pill -->
|
||||
<rect x="0" y="0" width="320" height="48" rx="11" fill="#0f172a"/>
|
||||
<text x="20" y="32" fill="#64748b" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="18" font-weight="500">$</text>
|
||||
<text x="42" y="32" fill="#e6edf3" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="18" font-weight="600">brew install gogcli</text>
|
||||
|
||||
<!-- URL pill -->
|
||||
<rect x="340" y="0" width="180" height="48" rx="11" fill="#ffffff" stroke="#cbd5e1"/>
|
||||
<text x="362" y="32" fill="#0f1115" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="18" font-weight="600">gogcli.sh</text>
|
||||
</g>
|
||||
|
||||
<!-- Right-side terminal mockup -->
|
||||
<g transform="translate(670 142)" filter="url(#shadow)">
|
||||
<rect x="0" y="0" width="464" height="346" rx="20" fill="url(#panel)"/>
|
||||
<rect x="0" y="0" width="464" height="42" rx="20" fill="url(#panelBar)"/>
|
||||
<rect x="0" y="22" width="464" height="20" fill="url(#panelBar)"/>
|
||||
<circle cx="24" cy="21" r="6" fill="#ea4335"/>
|
||||
<circle cx="46" cy="21" r="6" fill="#fbbc04"/>
|
||||
<circle cx="68" cy="21" r="6" fill="#34a853"/>
|
||||
<text x="232" y="27" fill="#94a3b8" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13" font-weight="500" text-anchor="middle">gog — you@gmail.com</text>
|
||||
|
||||
<!-- Terminal content -->
|
||||
<text x="22" y="80" fill="#93c5fd" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="15" font-weight="600">$ gog gmail search 'newer_than:7d' --max 3</text>
|
||||
<text x="22" y="110" fill="#cbd5e1" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">Mary Lou Re: review notes 2h</text>
|
||||
<text x="22" y="132" fill="#cbd5e1" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">GitHub PR #547 ready 5h</text>
|
||||
<text x="22" y="154" fill="#cbd5e1" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">Calendar Standup at 10:00 1d</text>
|
||||
|
||||
<text x="22" y="194" fill="#93c5fd" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="15" font-weight="600">$ gog calendar events --today --json</text>
|
||||
|
||||
<rect x="22" y="212" width="420" height="118" rx="10" fill="#0a1024" stroke="#1f2937"/>
|
||||
<text x="38" y="238" fill="#fbbf24" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">{</text>
|
||||
<text x="54" y="258" fill="#a5b4fc" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">"events": [</text>
|
||||
<text x="74" y="278" fill="#a7f3d0" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">{ "summary": "Standup", "start": "10:00" },</text>
|
||||
<text x="74" y="298" fill="#a7f3d0" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">{ "summary": "Review", "start": "14:30" }</text>
|
||||
<text x="54" y="318" fill="#a5b4fc" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">]</text>
|
||||
</g>
|
||||
|
||||
<!-- Service pills below terminal -->
|
||||
<g transform="translate(670 514)" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="14" font-weight="600">
|
||||
<g>
|
||||
<rect x="0" y="0" width="74" height="30" rx="15" fill="#ffffff" stroke="#dbe2eb"/>
|
||||
<text x="37" y="20" fill="#4285f4" text-anchor="middle">Gmail</text>
|
||||
</g>
|
||||
<g transform="translate(86 0)">
|
||||
<rect x="0" y="0" width="92" height="30" rx="15" fill="#ffffff" stroke="#dbe2eb"/>
|
||||
<text x="46" y="20" fill="#34a853" text-anchor="middle">Calendar</text>
|
||||
</g>
|
||||
<g transform="translate(190 0)">
|
||||
<rect x="0" y="0" width="64" height="30" rx="15" fill="#ffffff" stroke="#dbe2eb"/>
|
||||
<text x="32" y="20" fill="#ea4335" text-anchor="middle">Drive</text>
|
||||
</g>
|
||||
<g transform="translate(266 0)">
|
||||
<rect x="0" y="0" width="74" height="30" rx="15" fill="#ffffff" stroke="#dbe2eb"/>
|
||||
<text x="37" y="20" fill="#fbbc04" text-anchor="middle">Sheets</text>
|
||||
</g>
|
||||
<g transform="translate(352 0)">
|
||||
<rect x="0" y="0" width="60" height="30" rx="15" fill="#ffffff" stroke="#dbe2eb"/>
|
||||
<text x="30" y="20" fill="#4285f4" text-anchor="middle">Docs</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.1 KiB |
@ -58,9 +58,19 @@ func (c *AdminGroupsListCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
return resp.Groups, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
groups, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var groups []*admin.Group
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, collectErr := collectAllPages(c.Page, fetch)
|
||||
if collectErr != nil {
|
||||
return collectErr
|
||||
}
|
||||
groups = all
|
||||
} else {
|
||||
groups, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
@ -166,9 +176,19 @@ func (c *AdminGroupsMembersListCmd) Run(ctx context.Context, flags *RootFlags) e
|
||||
return resp.Members, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
members, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var members []*admin.Member
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, collectErr := collectAllPages(c.Page, fetch)
|
||||
if collectErr != nil {
|
||||
return collectErr
|
||||
}
|
||||
members = all
|
||||
} else {
|
||||
members, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -60,9 +60,19 @@ func (c *AdminUsersListCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
return resp.Users, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
users, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var users []*admin.User
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, collectErr := collectAllPages(c.Page, fetch)
|
||||
if collectErr != nil {
|
||||
return collectErr
|
||||
}
|
||||
users = all
|
||||
} else {
|
||||
users, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -51,9 +51,20 @@ func listCalendarEvents(ctx context.Context, svc *calendar.Service, calendarID,
|
||||
return resp.Items, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
items, nextPageToken, err := loadPagedItems(page, allPages, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var items []*calendar.Event
|
||||
nextPageToken := ""
|
||||
if allPages {
|
||||
all, err := collectAllPages(page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items = all
|
||||
} else {
|
||||
var err error
|
||||
items, nextPageToken, err = fetch(page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
@ -153,10 +164,21 @@ func listCalendarIDsEvents(ctx context.Context, svc *calendar.Service, calendarI
|
||||
return resp.Items, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
events, _, err := loadPagedItems(page, allPages, fetch)
|
||||
if err != nil {
|
||||
u.Err().Printf("calendar %s: %v", calID, err)
|
||||
continue
|
||||
var events []*calendar.Event
|
||||
var err error
|
||||
if allPages {
|
||||
allEvents, collectErr := collectAllPages(page, fetch)
|
||||
if collectErr != nil {
|
||||
u.Err().Printf("calendar %s: %v", calID, collectErr)
|
||||
continue
|
||||
}
|
||||
events = allEvents
|
||||
} else {
|
||||
events, _, err = fetch(page)
|
||||
if err != nil {
|
||||
u.Err().Printf("calendar %s: %v", calID, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range events {
|
||||
|
||||
@ -43,9 +43,19 @@ func (c *CalendarCalendarsCmd) Run(ctx context.Context, flags *RootFlags) error
|
||||
return r.Items, r.NextPageToken, nil
|
||||
}
|
||||
|
||||
items, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var items []*calendar.CalendarListEntry
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, collectErr := collectAllPages(c.Page, fetch)
|
||||
if collectErr != nil {
|
||||
return collectErr
|
||||
}
|
||||
items = all
|
||||
} else {
|
||||
items, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
@ -163,9 +173,19 @@ func (c *CalendarAclCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
return r.Items, r.NextPageToken, nil
|
||||
}
|
||||
|
||||
items, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var items []*calendar.AclRule
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, collectErr := collectAllPages(c.Page, fetch)
|
||||
if collectErr != nil {
|
||||
return collectErr
|
||||
}
|
||||
items = all
|
||||
} else {
|
||||
items, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
|
||||
@ -2,6 +2,10 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
)
|
||||
|
||||
// CalendarRawCmd dumps the full Events.Get response as JSON, using the
|
||||
@ -40,10 +44,9 @@ func (c *CalendarRawCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event, err = requireRawResponse(event, "event not found")
|
||||
if err != nil {
|
||||
return err
|
||||
if event == nil {
|
||||
return errors.New("event not found")
|
||||
}
|
||||
|
||||
return writeRawJSON(ctx, event, c.Pretty)
|
||||
return outfmt.WriteRaw(ctx, os.Stdout, event, outfmt.RawOptions{Pretty: c.Pretty})
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -8,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
func newCalendarRawTestServer(t *testing.T, status int, body map[string]any) *httptest.Server {
|
||||
@ -40,8 +42,18 @@ func newCalendarRawTestServer(t *testing.T, status int, body map[string]any) *ht
|
||||
|
||||
func installMockCalendarService(t *testing.T, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", calendar.NewService)
|
||||
stubGoogleTestService(t, &newCalendarService, svc)
|
||||
orig := newCalendarService
|
||||
t.Cleanup(func() { newCalendarService = orig })
|
||||
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
|
||||
}
|
||||
|
||||
func fullCalendarEventResponse(id string) map[string]any {
|
||||
|
||||
@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
@ -15,7 +17,17 @@ import (
|
||||
func newCalendarServiceForTest(t *testing.T, h http.Handler) (*calendar.Service, func()) {
|
||||
t.Helper()
|
||||
|
||||
return newGoogleTestService(t, h, calendar.NewService)
|
||||
srv := httptest.NewServer(h)
|
||||
svc, err := calendar.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
srv.Close()
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
return svc, srv.Close
|
||||
}
|
||||
|
||||
func newTestCalendarService(t *testing.T, h http.Handler) (*calendar.Service, func()) {
|
||||
@ -25,7 +37,9 @@ func newTestCalendarService(t *testing.T, h http.Handler) (*calendar.Service, fu
|
||||
|
||||
func stubCalendarServiceForTest(t *testing.T, svc *calendar.Service) {
|
||||
t.Helper()
|
||||
stubGoogleTestService(t, &newCalendarService, svc)
|
||||
origNew := newCalendarService
|
||||
t.Cleanup(func() { newCalendarService = origNew })
|
||||
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
|
||||
}
|
||||
|
||||
func newCalendarOutputContext(t *testing.T, stdout, stderr io.Writer) context.Context {
|
||||
|
||||
@ -50,20 +50,31 @@ func (c *CalendarUsersCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
if strings.Contains(callErr.Error(), "accessNotConfigured") ||
|
||||
strings.Contains(callErr.Error(), "People API has not been used") {
|
||||
return nil, "", fmt.Errorf("people API is not enabled; enable it at: https://console.developers.google.com/apis/api/people.googleapis.com/overview (%w)", callErr)
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "accessNotConfigured") ||
|
||||
strings.Contains(err.Error(), "People API has not been used") {
|
||||
return nil, "", fmt.Errorf("people API is not enabled; enable it at: https://console.developers.google.com/apis/api/people.googleapis.com/overview (%w)", err)
|
||||
}
|
||||
return nil, "", callErr
|
||||
return nil, "", err
|
||||
}
|
||||
return resp.People, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
peopleList, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var peopleList []*people.Person
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peopleList = all
|
||||
} else {
|
||||
var err error
|
||||
peopleList, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -83,16 +83,27 @@ func (c *ChatMessagesListCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if filter != "" {
|
||||
call = call.Filter(filter)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", callErr
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return resp.Messages, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
messages, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var messages []*chat.Message
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages = all
|
||||
} else {
|
||||
var err error
|
||||
messages, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -120,16 +120,27 @@ func (c *ChatMessagesReactionsListCmd) Run(ctx context.Context, flags *RootFlags
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", callErr
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return resp.Reactions, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
reactions, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var reactions []*chat.Reaction
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reactions = all
|
||||
} else {
|
||||
var err error
|
||||
reactions, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -52,9 +52,20 @@ func (c *ChatSpacesListCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
return resp.Spaces, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
spaces, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var spaces []*chat.Space
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaces = all
|
||||
} else {
|
||||
var err error
|
||||
spaces, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -52,16 +52,27 @@ func (c *ChatThreadsListCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", callErr
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return resp.Messages, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
messages, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var messages []*chat.Message
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages = all
|
||||
} else {
|
||||
var err error
|
||||
messages, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
threads := make([]*chatMessageThreadItem, 0, len(messages))
|
||||
|
||||
@ -65,16 +65,27 @@ func (c *ClassroomCoursesListCmd) Run(ctx context.Context, flags *RootFlags) err
|
||||
if v := strings.TrimSpace(c.StudentID); v != "" {
|
||||
call.StudentId(v)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", wrapClassroomError(callErr)
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", wrapClassroomError(err)
|
||||
}
|
||||
return resp.Courses, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
courses, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var courses []*classroom.Course
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
courses = all
|
||||
} else {
|
||||
var err error
|
||||
courses, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -71,7 +71,7 @@ func (c *ClassroomCourseworkListCmd) Run(ctx context.Context, flags *RootFlags)
|
||||
var coursework []*classroom.CourseWork
|
||||
var nextPageToken string
|
||||
if c.All {
|
||||
all, _, err := loadPagedItems(c.Page, true, fetch)
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return wrapClassroomError(err)
|
||||
}
|
||||
|
||||
@ -51,16 +51,27 @@ func (c *ClassroomGuardiansListCmd) Run(ctx context.Context, flags *RootFlags) e
|
||||
if v := strings.TrimSpace(c.Email); v != "" {
|
||||
call.InvitedEmailAddress(v)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", wrapClassroomError(callErr)
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", wrapClassroomError(err)
|
||||
}
|
||||
return resp.Guardians, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
guardians, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var guardians []*classroom.Guardian
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guardians = all
|
||||
} else {
|
||||
var err error
|
||||
guardians, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
@ -227,16 +238,27 @@ func (c *ClassroomGuardianInvitesListCmd) Run(ctx context.Context, flags *RootFl
|
||||
}
|
||||
call.States(upper...)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", wrapClassroomError(callErr)
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", wrapClassroomError(err)
|
||||
}
|
||||
return resp.GuardianInvitations, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
invitations, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var invitations []*classroom.GuardianInvitation
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
invitations = all
|
||||
} else {
|
||||
var err error
|
||||
invitations, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -53,16 +53,27 @@ func (c *ClassroomInvitationsListCmd) Run(ctx context.Context, flags *RootFlags)
|
||||
call.UserId(v)
|
||||
}
|
||||
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", wrapClassroomError(callErr)
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", wrapClassroomError(err)
|
||||
}
|
||||
return resp.Invitations, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
invitations, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var invitations []*classroom.Invitation
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
invitations = all
|
||||
} else {
|
||||
var err error
|
||||
invitations, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -10,7 +10,14 @@ import (
|
||||
)
|
||||
|
||||
func fetchClassroomPagedList[T any](all bool, page string, fetch func(string) ([]*T, string, error)) ([]*T, string, error) {
|
||||
return loadPagedItems(page, all, fetch)
|
||||
if all {
|
||||
items, err := collectAllPages(page, fetch)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return items, "", nil
|
||||
}
|
||||
return fetch(page)
|
||||
}
|
||||
|
||||
func writeClassroomPagedList[T any](
|
||||
|
||||
@ -70,7 +70,7 @@ func (c *ClassroomMaterialsListCmd) Run(ctx context.Context, flags *RootFlags) e
|
||||
var materials []*classroom.CourseWorkMaterial
|
||||
var nextPageToken string
|
||||
if c.All {
|
||||
all, _, err := loadPagedItems(c.Page, true, fetch)
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return wrapClassroomError(err)
|
||||
}
|
||||
|
||||
@ -48,16 +48,27 @@ func (c *ClassroomStudentsListCmd) Run(ctx context.Context, flags *RootFlags) er
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", wrapClassroomError(callErr)
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", wrapClassroomError(err)
|
||||
}
|
||||
return resp.Students, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
students, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var students []*classroom.Student
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
students = all
|
||||
} else {
|
||||
var err error
|
||||
students, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
@ -269,16 +280,27 @@ func (c *ClassroomTeachersListCmd) Run(ctx context.Context, flags *RootFlags) er
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", wrapClassroomError(callErr)
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", wrapClassroomError(err)
|
||||
}
|
||||
return resp.Teachers, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
teachers, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var teachers []*classroom.Teacher
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
teachers = all
|
||||
} else {
|
||||
var err error
|
||||
teachers, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
@ -455,6 +477,7 @@ type ClassroomRosterCmd struct {
|
||||
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
|
||||
}
|
||||
|
||||
//nolint:gocyclo,cyclop // command orchestration across two role paths
|
||||
func (c *ClassroomRosterCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
account, err := requireAccount(flags)
|
||||
@ -491,9 +514,17 @@ func (c *ClassroomRosterCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
}
|
||||
return resp.Students, resp.NextPageToken, nil
|
||||
}
|
||||
students, studentsNextPageToken, err = loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
if c.All {
|
||||
all, collectErr := collectAllPages(c.Page, fetch)
|
||||
if collectErr != nil {
|
||||
return collectErr
|
||||
}
|
||||
students = all
|
||||
} else {
|
||||
students, studentsNextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if includeTeachers {
|
||||
@ -508,9 +539,17 @@ func (c *ClassroomRosterCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
}
|
||||
return resp.Teachers, resp.NextPageToken, nil
|
||||
}
|
||||
teachers, teachersNextPageToken, err = loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
if c.All {
|
||||
all, collectErr := collectAllPages(c.Page, fetch)
|
||||
if collectErr != nil {
|
||||
return collectErr
|
||||
}
|
||||
teachers = all
|
||||
} else {
|
||||
teachers, teachersNextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -79,16 +79,27 @@ func (c *ClassroomSubmissionsListCmd) Run(ctx context.Context, flags *RootFlags)
|
||||
}
|
||||
}
|
||||
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", wrapClassroomError(callErr)
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", wrapClassroomError(err)
|
||||
}
|
||||
return resp.StudentSubmissions, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
submissions, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var submissions []*classroom.StudentSubmission
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
submissions = all
|
||||
} else {
|
||||
var err error
|
||||
submissions, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -55,16 +55,27 @@ func (c *ContactsDirectoryListCmd) Run(ctx context.Context, flags *RootFlags) er
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", callErr
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return resp.People, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
peopleList, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var peopleList []*people.Person
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peopleList = all
|
||||
} else {
|
||||
var err error
|
||||
peopleList, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
type item struct {
|
||||
@ -151,16 +162,27 @@ func (c *ContactsDirectorySearchCmd) Run(ctx context.Context, flags *RootFlags)
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", callErr
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return resp.People, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
peopleList, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var peopleList []*people.Person
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peopleList = all
|
||||
} else {
|
||||
var err error
|
||||
peopleList, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
type item struct {
|
||||
@ -246,16 +268,27 @@ func (c *ContactsOtherListCmd) Run(ctx context.Context, flags *RootFlags) error
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", callErr
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return resp.OtherContacts, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
contacts, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var contacts []*people.Person
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contacts = all
|
||||
} else {
|
||||
var err error
|
||||
contacts, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
type item struct {
|
||||
|
||||
@ -71,12 +71,11 @@ func (c *DocsRawCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
doc, err = requireRawResponse(doc, "doc not found")
|
||||
if err != nil {
|
||||
return err
|
||||
if doc == nil {
|
||||
return errors.New("doc not found")
|
||||
}
|
||||
|
||||
return writeRawJSON(ctx, doc, c.Pretty)
|
||||
return outfmt.WriteRaw(ctx, os.Stdout, doc, outfmt.RawOptions{Pretty: c.Pretty})
|
||||
}
|
||||
|
||||
type DocsExportCmd struct {
|
||||
|
||||
@ -2,8 +2,13 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@ -53,6 +58,15 @@ const (
|
||||
extMD = ".md"
|
||||
extHTML = ".html"
|
||||
formatAuto = literalAuto
|
||||
driveShareToAnyone = "anyone"
|
||||
driveShareToUser = "user"
|
||||
driveShareToDomain = "domain"
|
||||
|
||||
// Drive sharing permission roles matching the Google Drive API roles.
|
||||
// "commenter" allows view + comment access without edit rights.
|
||||
drivePermRoleReader = "reader"
|
||||
drivePermRoleWriter = "writer"
|
||||
drivePermRoleCommenter = "commenter"
|
||||
)
|
||||
|
||||
type DriveCmd struct {
|
||||
@ -78,6 +92,93 @@ type DriveCmd struct {
|
||||
Raw DriveRawCmd `cmd:"" name:"raw" help:"Dump raw Google Drive API response as JSON (Files.Get; lossless; for scripting and LLM consumption)"`
|
||||
}
|
||||
|
||||
// driveRawSensitiveFields is the set of top-level File fields redacted from
|
||||
// `gog drive raw` output when the user did not name them via --fields. See
|
||||
// docs/raw-audit.md for the rationale per field.
|
||||
var driveRawSensitiveFields = []string{
|
||||
"thumbnailLink",
|
||||
"webContentLink",
|
||||
"exportLinks",
|
||||
"resourceKey",
|
||||
"appProperties",
|
||||
"properties",
|
||||
}
|
||||
|
||||
// DriveRawCmd dumps the full Files.Get response as JSON. Uses fields=* by
|
||||
// default to expose the entire File resource. When --fields is absent the
|
||||
// command redacts a small set of capability/token-shaped fields (see
|
||||
// driveRawSensitiveFields); when --fields is explicitly set the response is
|
||||
// returned verbatim, honoring exactly what the user asked for. This means
|
||||
// passing `--fields "id,name,thumbnailLink"` returns thumbnailLink as
|
||||
// requested.
|
||||
//
|
||||
// REST reference: https://developers.google.com/drive/api/reference/rest/v3/files/get
|
||||
// Go type: https://pkg.go.dev/google.golang.org/api/drive/v3#File
|
||||
type DriveRawCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
Fields string `name:"fields" help:"Drive API field mask (default: * with sensitive fields redacted client-side). Set explicitly to disable redaction."`
|
||||
Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"`
|
||||
}
|
||||
|
||||
func (c *DriveRawCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
fileID := strings.TrimSpace(c.FileID)
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userSetFields := strings.TrimSpace(c.Fields) != ""
|
||||
mask := "*"
|
||||
if userSetFields {
|
||||
mask = c.Fields
|
||||
}
|
||||
|
||||
f, err := svc.Files.Get(fileID).
|
||||
SupportsAllDrives(true).
|
||||
Fields(gapi.Field(mask)).
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if f == nil {
|
||||
return errors.New("file not found")
|
||||
}
|
||||
|
||||
// Round-trip through JSON so we can redact by key when needed.
|
||||
raw, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal drive file: %w", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return fmt.Errorf("unmarshal drive file: %w", err)
|
||||
}
|
||||
|
||||
// Redact only when the user did not explicitly request fields.
|
||||
if !userSetFields {
|
||||
for _, key := range driveRawSensitiveFields {
|
||||
delete(m, key)
|
||||
}
|
||||
// contentHints.thumbnail.image is the one nested leak.
|
||||
if hints, ok := m["contentHints"].(map[string]any); ok {
|
||||
if thumb, ok := hints["thumbnail"].(map[string]any); ok {
|
||||
delete(thumb, "image")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outfmt.WriteRaw(ctx, os.Stdout, m, outfmt.RawOptions{Pretty: c.Pretty})
|
||||
}
|
||||
|
||||
type DriveLsCmd struct {
|
||||
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"20"`
|
||||
Page string `name:"page" aliases:"cursor" help:"Page token"`
|
||||
@ -152,6 +253,91 @@ func (c *DriveGetCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type DriveDownloadCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
Output OutputPathFlag `embed:""`
|
||||
Format string `name:"format" help:"Export format for Google Docs files: pdf|csv|xlsx|pptx|txt|png|docx|md (default: inferred)"`
|
||||
Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (Google Docs only; see 'gog docs list-tabs')"`
|
||||
}
|
||||
|
||||
func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileID := normalizeGoogleID(strings.TrimSpace(c.FileID))
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
|
||||
if tab := strings.TrimSpace(c.Tab); tab != "" {
|
||||
if f := c.Format; f != "" && f != formatAuto {
|
||||
if _, fmtErr := tabExportFormatParam(f); fmtErr != nil {
|
||||
return fmt.Errorf("--tab limits export formats (pdf|docx|txt|md|html); %q is not supported with --tab", f)
|
||||
}
|
||||
}
|
||||
return runDocsTabExport(ctx, flags, tabExportParams{
|
||||
DocID: fileID,
|
||||
OutFlag: c.Output.Path,
|
||||
Format: c.Format,
|
||||
TabQuery: tab,
|
||||
})
|
||||
}
|
||||
|
||||
u := ui.FromContext(ctx)
|
||||
if formatErr := validateDriveDownloadFormatFlag(c.Format); formatErr != nil {
|
||||
return formatErr
|
||||
}
|
||||
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
meta, err := svc.Files.Get(fileID).
|
||||
SupportsAllDrives(true).
|
||||
Fields("id, name, mimeType").
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.Name == "" {
|
||||
return errors.New("file has no name")
|
||||
}
|
||||
if fileFormatErr := validateDriveDownloadFormatForFile(meta, c.Format); fileFormatErr != nil {
|
||||
return fileFormatErr
|
||||
}
|
||||
|
||||
destPath, err := resolveDriveDownloadDestPath(meta, c.Output.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(ctx) && isStdoutPath(destPath) {
|
||||
return usage("can't combine --json with --out -")
|
||||
}
|
||||
|
||||
downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"path": downloadedPath,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
if isStdoutPath(downloadedPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
u.Out().Printf("path\t%s", downloadedPath)
|
||||
u.Out().Printf("size\t%s", formatDriveSize(size))
|
||||
return nil
|
||||
}
|
||||
|
||||
type DriveCopyCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
Name string `arg:"" name:"name" help:"New file name"`
|
||||
@ -164,6 +350,18 @@ func (c *DriveCopyCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
}, c.FileID, c.Name, c.Parent)
|
||||
}
|
||||
|
||||
type DriveUploadCmd struct {
|
||||
LocalPath string `arg:"" name:"localPath" help:"Path to local file"`
|
||||
Name string `name:"name" help:"Override filename (create) or rename target (replace)"`
|
||||
Parent string `name:"parent" help:"Destination folder ID (create only)"`
|
||||
ReplaceFileID string `name:"replace" help:"Replace the content of an existing Drive file ID (preserves shared link/permissions)"`
|
||||
MimeType string `name:"mime-type" help:"Override MIME type inference"`
|
||||
KeepRevisionForever bool `name:"keep-revision-forever" help:"Keep the new head revision forever (binary files only)"`
|
||||
Convert bool `name:"convert" help:"Auto-convert to native Google format based on file extension (create only)"`
|
||||
ConvertTo string `name:"convert-to" help:"Convert to a specific Google format: doc|sheet|slides (create only)"`
|
||||
KeepFrontmatter bool `name:"keep-frontmatter" help:"Keep YAML frontmatter (---) in Markdown when converting to a Google Doc (--convert or --convert-to doc; default: strip)"`
|
||||
}
|
||||
|
||||
type DriveMkdirCmd struct {
|
||||
Name string `arg:"" name:"name" help:"Folder name"`
|
||||
Parent string `name:"parent" help:"Parent folder ID"`
|
||||
@ -370,6 +568,312 @@ func (c *DriveRenameCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type DriveShareCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
To string `name:"to" help:"Share target: anyone|user|domain"`
|
||||
Anyone bool `name:"anyone" hidden:"" help:"(deprecated) Use --to=anyone"`
|
||||
Email string `name:"email" help:"User email (for --to=user)"`
|
||||
Domain string `name:"domain" help:"Domain (for --to=domain; e.g. example.com)"`
|
||||
Role string `name:"role" help:"Permission: reader|writer|commenter" default:"reader"`
|
||||
Discoverable bool `name:"discoverable" help:"Allow file discovery in search (anyone/domain only)"`
|
||||
}
|
||||
|
||||
type driveShareTarget struct {
|
||||
to string
|
||||
email string
|
||||
domain string
|
||||
}
|
||||
|
||||
func (c *DriveShareCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := strings.TrimSpace(c.FileID)
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
|
||||
target, err := c.normalizeTarget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role, err := normalizeDrivePermissionRole(c.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.to == driveShareToAnyone {
|
||||
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("share drive file %s with anyone (public)", fileID)); confirmErr != nil {
|
||||
return confirmErr
|
||||
}
|
||||
}
|
||||
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
perm := target.permission(role, c.Discoverable)
|
||||
|
||||
created, err := svc.Permissions.Create(fileID, perm).
|
||||
SupportsAllDrives(true).
|
||||
SendNotificationEmail(false).
|
||||
Fields("id, type, role, emailAddress, domain, allowFileDiscovery").
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
link, err := driveWebLink(ctx, svc, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"link": link,
|
||||
"permissionId": created.Id,
|
||||
"permission": created,
|
||||
})
|
||||
}
|
||||
|
||||
u.Out().Printf("link\t%s", link)
|
||||
u.Out().Printf("permission_id\t%s", created.Id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *DriveShareCmd) normalizeTarget() (driveShareTarget, error) {
|
||||
to := strings.TrimSpace(c.To)
|
||||
email := strings.TrimSpace(c.Email)
|
||||
domain := strings.TrimSpace(c.Domain)
|
||||
|
||||
// Back-compat: allow legacy target flags without --to, but keep it unambiguous.
|
||||
// New UX: prefer explicit --to + matching parameter.
|
||||
if to == "" {
|
||||
switch {
|
||||
case c.Anyone && email == "" && domain == "":
|
||||
to = driveShareToAnyone
|
||||
case !c.Anyone && email != "" && domain == "":
|
||||
to = driveShareToUser
|
||||
case !c.Anyone && email == "" && domain != "":
|
||||
to = driveShareToDomain
|
||||
case !c.Anyone && email == "" && domain == "":
|
||||
return driveShareTarget{}, usage("must specify --to (anyone|user|domain)")
|
||||
default:
|
||||
return driveShareTarget{}, usage("ambiguous share target (use --to=anyone|user|domain)")
|
||||
}
|
||||
}
|
||||
|
||||
switch to {
|
||||
case driveShareToAnyone:
|
||||
if email != "" || domain != "" {
|
||||
return driveShareTarget{}, usage("--to=anyone cannot be combined with --email or --domain")
|
||||
}
|
||||
case driveShareToUser:
|
||||
if email == "" {
|
||||
return driveShareTarget{}, usage("missing --email for --to=user")
|
||||
}
|
||||
if domain != "" || c.Anyone {
|
||||
return driveShareTarget{}, usage("--to=user cannot be combined with --anyone or --domain")
|
||||
}
|
||||
if c.Discoverable {
|
||||
return driveShareTarget{}, usage("--discoverable is only valid for --to=anyone or --to=domain")
|
||||
}
|
||||
case driveShareToDomain:
|
||||
if domain == "" {
|
||||
return driveShareTarget{}, usage("missing --domain for --to=domain")
|
||||
}
|
||||
if email != "" || c.Anyone {
|
||||
return driveShareTarget{}, usage("--to=domain cannot be combined with --anyone or --email")
|
||||
}
|
||||
default:
|
||||
// Should be guarded by enum, but keep a friendly message for future changes.
|
||||
return driveShareTarget{}, usage("invalid --to (expected anyone|user|domain)")
|
||||
}
|
||||
|
||||
return driveShareTarget{to: to, email: email, domain: domain}, nil
|
||||
}
|
||||
|
||||
func (target driveShareTarget) permission(role string, discoverable bool) *drive.Permission {
|
||||
perm := &drive.Permission{Role: role}
|
||||
switch target.to {
|
||||
case driveShareToAnyone:
|
||||
perm.Type = "anyone"
|
||||
perm.AllowFileDiscovery = discoverable
|
||||
case driveShareToDomain:
|
||||
perm.Type = "domain"
|
||||
perm.Domain = target.domain
|
||||
perm.AllowFileDiscovery = discoverable
|
||||
default:
|
||||
perm.Type = "user"
|
||||
perm.EmailAddress = target.email
|
||||
}
|
||||
return perm
|
||||
}
|
||||
|
||||
func normalizeDrivePermissionRole(role string) (string, error) {
|
||||
role = strings.TrimSpace(role)
|
||||
if role == "" {
|
||||
return drivePermRoleReader, nil
|
||||
}
|
||||
switch role {
|
||||
case drivePermRoleReader, drivePermRoleWriter, drivePermRoleCommenter:
|
||||
return role, nil
|
||||
default:
|
||||
return "", usage("invalid --role (expected reader|writer|commenter)")
|
||||
}
|
||||
}
|
||||
|
||||
type DriveUnshareCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
PermissionID string `arg:"" name:"permissionId" help:"Permission ID"`
|
||||
}
|
||||
|
||||
func (c *DriveUnshareCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := strings.TrimSpace(c.FileID)
|
||||
permissionID := strings.TrimSpace(c.PermissionID)
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
if permissionID == "" {
|
||||
return usage("empty permissionId")
|
||||
}
|
||||
|
||||
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("remove permission %s from drive file %s", permissionID, fileID)); confirmErr != nil {
|
||||
return confirmErr
|
||||
}
|
||||
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := svc.Permissions.Delete(fileID, permissionID).SupportsAllDrives(true).Context(ctx).Do(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeResult(ctx, u,
|
||||
kv("removed", true),
|
||||
kv("fileId", fileID),
|
||||
kv("permissionId", permissionID),
|
||||
)
|
||||
}
|
||||
|
||||
type DrivePermissionsCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
|
||||
Page string `name:"page" aliases:"cursor" help:"Page token"`
|
||||
}
|
||||
|
||||
func (c *DrivePermissionsCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := strings.TrimSpace(c.FileID)
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
call := svc.Permissions.List(fileID).
|
||||
SupportsAllDrives(true).
|
||||
Fields("nextPageToken, permissions(id, type, role, emailAddress, domain)").
|
||||
Context(ctx)
|
||||
if c.Max > 0 {
|
||||
call = call.PageSize(c.Max)
|
||||
}
|
||||
if strings.TrimSpace(c.Page) != "" {
|
||||
call = call.PageToken(c.Page)
|
||||
}
|
||||
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"fileId": fileID,
|
||||
"permissions": resp.Permissions,
|
||||
"permissionCount": len(resp.Permissions),
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
if len(resp.Permissions) == 0 {
|
||||
u.Err().Println("No permissions")
|
||||
return nil
|
||||
}
|
||||
|
||||
w, flush := tableWriter(ctx)
|
||||
defer flush()
|
||||
fmt.Fprintln(w, "ID\tTYPE\tROLE\tEMAIL")
|
||||
for _, p := range resp.Permissions {
|
||||
email := p.EmailAddress
|
||||
if email == "" && p.Domain != "" {
|
||||
email = p.Domain
|
||||
}
|
||||
if email == "" {
|
||||
email = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", p.Id, p.Type, p.Role, email)
|
||||
}
|
||||
printNextPageHint(u, resp.NextPageToken)
|
||||
return nil
|
||||
}
|
||||
|
||||
type DriveURLCmd struct {
|
||||
FileIDs []string `arg:"" name:"fileId" help:"File IDs"`
|
||||
}
|
||||
|
||||
func (c *DriveURLCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range c.FileIDs {
|
||||
link, err := driveWebLink(ctx, svc, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
// collected below
|
||||
} else {
|
||||
u.Out().Printf("%s\t%s", id, link)
|
||||
}
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
urls := make([]map[string]string, 0, len(c.FileIDs))
|
||||
for _, id := range c.FileIDs {
|
||||
link, err := driveWebLink(ctx, svc, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
urls = append(urls, map[string]string{"id": id, "url": link})
|
||||
}
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"urls": urls})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDriveListQuery(folderID string, userQuery string) string {
|
||||
q := strings.TrimSpace(userQuery)
|
||||
parent := fmt.Sprintf("'%s' in parents", folderID)
|
||||
@ -480,6 +984,119 @@ func formatDriveSize(bytes int64) string {
|
||||
return fmt.Sprintf("%.1f %s", b, units[i])
|
||||
}
|
||||
|
||||
func guessMimeType(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case extPDF:
|
||||
return mimePDF
|
||||
case ".doc":
|
||||
return "application/msword"
|
||||
case extDocx:
|
||||
return mimeDocx
|
||||
case ".xls":
|
||||
return "application/vnd.ms-excel"
|
||||
case extXlsx:
|
||||
return mimeXlsx
|
||||
case ".ppt":
|
||||
return "application/vnd.ms-powerpoint"
|
||||
case extPptx:
|
||||
return mimePptx
|
||||
case extPNG:
|
||||
return mimePNG
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case extTXT:
|
||||
return mimeTextPlain
|
||||
case ".html":
|
||||
return "text/html"
|
||||
case ".css":
|
||||
return "text/css"
|
||||
case ".js":
|
||||
return "application/javascript"
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".zip":
|
||||
return "application/zip"
|
||||
case ".csv":
|
||||
return "text/csv"
|
||||
case ".md":
|
||||
return "text/markdown"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File, destPath string, format string) (string, int64, error) {
|
||||
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
|
||||
normalizedFormat := strings.ToLower(strings.TrimSpace(format))
|
||||
if normalizedFormat == formatAuto {
|
||||
normalizedFormat = ""
|
||||
}
|
||||
|
||||
if !isGoogleDoc && normalizedFormat != "" {
|
||||
return "", 0, fmt.Errorf("--format %q not supported for non-Google Workspace files (mimeType=%q); file can only be downloaded as-is", format, meta.MimeType)
|
||||
}
|
||||
if fileFormatErr := validateDriveDownloadFormatForFile(meta, format); fileFormatErr != nil {
|
||||
return "", 0, fileFormatErr
|
||||
}
|
||||
|
||||
var (
|
||||
resp *http.Response
|
||||
outPath string
|
||||
err error
|
||||
)
|
||||
|
||||
if isGoogleDoc {
|
||||
var exportMimeType string
|
||||
if normalizedFormat == "" {
|
||||
exportMimeType = driveExportMimeType(meta.MimeType)
|
||||
} else {
|
||||
var mimeErr error
|
||||
exportMimeType, mimeErr = driveExportMimeTypeForFormat(meta.MimeType, normalizedFormat)
|
||||
if mimeErr != nil {
|
||||
return "", 0, mimeErr
|
||||
}
|
||||
}
|
||||
if isStdoutPath(destPath) {
|
||||
outPath = stdoutPath
|
||||
} else {
|
||||
outPath = replaceExt(destPath, driveExportExtension(exportMimeType))
|
||||
}
|
||||
resp, err = driveExportDownload(ctx, svc, meta.Id, exportMimeType)
|
||||
} else {
|
||||
outPath = destPath
|
||||
resp, err = driveDownload(ctx, svc, meta.Id)
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", 0, fmt.Errorf("download failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
if isStdoutPath(outPath) {
|
||||
n, copyErr := io.Copy(os.Stdout, resp.Body)
|
||||
return stdoutPath, n, copyErr
|
||||
}
|
||||
|
||||
f, outPath, err := createUserOutputFile(outPath)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, resp.Body)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return outPath, n, nil
|
||||
}
|
||||
|
||||
func driveFilesListCallWithDriveSupport(call *drive.FilesListCall, allDrives bool, driveID string) *drive.FilesListCall {
|
||||
// SupportsAllDrives must be set for shared drive file IDs to behave correctly.
|
||||
call = call.SupportsAllDrives(true).IncludeItemsFromAllDrives(allDrives)
|
||||
@ -493,3 +1110,216 @@ func driveFilesListCallWithDriveSupport(call *drive.FilesListCall, allDrives boo
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
func validateDriveDownloadFormatFlag(format string) error {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" {
|
||||
return nil
|
||||
}
|
||||
switch format {
|
||||
case "pdf", "csv", "xlsx", "pptx", "txt", "png", "docx", "md", "html":
|
||||
return nil
|
||||
default:
|
||||
return usagef("invalid --format %q (use pdf|csv|xlsx|pptx|txt|png|docx|md|html)", format)
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveDownloadFormatForFile(meta *drive.File, format string) error {
|
||||
if meta == nil {
|
||||
return errors.New("missing file metadata")
|
||||
}
|
||||
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
|
||||
if isGoogleDoc {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(format) == "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("--format %q not supported for non-Google Workspace files (mimeType=%q); file can only be downloaded as-is", format, meta.MimeType)
|
||||
}
|
||||
|
||||
var driveDownload = func(ctx context.Context, svc *drive.Service, fileID string) (*http.Response, error) {
|
||||
return svc.Files.Get(fileID).SupportsAllDrives(true).Context(ctx).Download()
|
||||
}
|
||||
|
||||
var driveExportDownload = func(ctx context.Context, svc *drive.Service, fileID string, mimeType string) (*http.Response, error) {
|
||||
return svc.Files.Export(fileID, mimeType).Context(ctx).Download()
|
||||
}
|
||||
|
||||
func replaceExt(path string, ext string) string {
|
||||
base := strings.TrimSuffix(path, filepath.Ext(path))
|
||||
return base + ext
|
||||
}
|
||||
|
||||
func driveExportMimeType(googleMimeType string) string {
|
||||
switch googleMimeType {
|
||||
case driveMimeGoogleDoc:
|
||||
return mimePDF
|
||||
case driveMimeGoogleSheet:
|
||||
return mimeCSV
|
||||
case driveMimeGoogleSlides:
|
||||
return mimePDF
|
||||
case driveMimeGoogleDrawing:
|
||||
return mimePNG
|
||||
default:
|
||||
return mimePDF
|
||||
}
|
||||
}
|
||||
|
||||
func driveExportMimeTypeForFormat(googleMimeType string, format string) (string, error) {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" || format == formatAuto {
|
||||
return driveExportMimeType(googleMimeType), nil
|
||||
}
|
||||
|
||||
switch googleMimeType {
|
||||
case driveMimeGoogleDoc:
|
||||
switch format {
|
||||
case defaultExportFormat:
|
||||
return mimePDF, nil
|
||||
case "docx":
|
||||
return mimeDocx, nil
|
||||
case "txt":
|
||||
return mimeTextPlain, nil
|
||||
case "md":
|
||||
return mimeTextMarkdown, nil
|
||||
case "html":
|
||||
return mimeHTML, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --format %q for Google Doc (use pdf|docx|txt|md|html)", format)
|
||||
}
|
||||
case driveMimeGoogleSheet:
|
||||
switch format {
|
||||
case defaultExportFormat:
|
||||
return mimePDF, nil
|
||||
case "csv":
|
||||
return mimeCSV, nil
|
||||
case "xlsx":
|
||||
return mimeXlsx, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --format %q for Google Sheet (use pdf|csv|xlsx)", format)
|
||||
}
|
||||
case driveMimeGoogleSlides:
|
||||
switch format {
|
||||
case defaultExportFormat:
|
||||
return mimePDF, nil
|
||||
case "pptx":
|
||||
return mimePptx, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --format %q for Google Slides (use pdf|pptx)", format)
|
||||
}
|
||||
case driveMimeGoogleDrawing:
|
||||
switch format {
|
||||
case "png":
|
||||
return mimePNG, nil
|
||||
case defaultExportFormat:
|
||||
return mimePDF, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --format %q for Google Drawing (use png|pdf)", format)
|
||||
}
|
||||
default:
|
||||
if format == defaultExportFormat {
|
||||
return mimePDF, nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid --format %q for file type %q (use pdf)", format, googleMimeType)
|
||||
}
|
||||
}
|
||||
|
||||
func driveExportExtension(mimeType string) string {
|
||||
switch mimeType {
|
||||
case mimePDF:
|
||||
return extPDF
|
||||
case mimeCSV:
|
||||
return extCSV
|
||||
case mimeXlsx:
|
||||
return extXlsx
|
||||
case mimeDocx:
|
||||
return extDocx
|
||||
case mimePptx:
|
||||
return extPptx
|
||||
case mimePNG:
|
||||
return extPNG
|
||||
case mimeTextPlain:
|
||||
return extTXT
|
||||
case mimeTextMarkdown:
|
||||
return extMD
|
||||
case mimeHTML:
|
||||
return extHTML
|
||||
default:
|
||||
return extPDF
|
||||
}
|
||||
}
|
||||
|
||||
// googleConvertMimeType returns the Google-native MIME type for convertible
|
||||
// Office/text formats. The boolean indicates whether the extension is supported.
|
||||
func googleConvertMimeType(path string) (string, bool) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case extDocx, ".doc":
|
||||
return driveMimeGoogleDoc, true
|
||||
case extXlsx, ".xls", extCSV:
|
||||
return driveMimeGoogleSheet, true
|
||||
case extPptx, ".ppt":
|
||||
return driveMimeGoogleSlides, true
|
||||
case extTXT, ".html", extMD:
|
||||
return driveMimeGoogleDoc, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func googleConvertTargetMimeType(target string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(target)) {
|
||||
case "doc":
|
||||
return driveMimeGoogleDoc, true
|
||||
case "sheet":
|
||||
return driveMimeGoogleSheet, true
|
||||
case "slides":
|
||||
return driveMimeGoogleSlides, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func driveUploadConvertMimeType(path string, auto bool, target string) (string, bool, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target != "" {
|
||||
mimeType, ok := googleConvertTargetMimeType(target)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("--convert-to: invalid value %q (use doc|sheet|slides)", target)
|
||||
}
|
||||
return mimeType, true, nil
|
||||
}
|
||||
if !auto {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
mimeType, ok := googleConvertMimeType(path)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("--convert: unsupported file type %q (supported: docx, xlsx, pptx, doc, xls, ppt, csv, txt, html, md)", filepath.Ext(path))
|
||||
}
|
||||
return mimeType, true, nil
|
||||
}
|
||||
|
||||
// stripOfficeExt removes common Office extensions from a filename so
|
||||
// the resulting Google Doc/Sheet/Slides has a clean name.
|
||||
func stripOfficeExt(name string) string {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
switch ext {
|
||||
case extDocx, ".doc", extXlsx, ".xls", extPptx, ".ppt", extMD:
|
||||
return strings.TrimSuffix(name, filepath.Ext(name))
|
||||
default:
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
func driveWebLink(ctx context.Context, svc *drive.Service, fileID string) (string, error) {
|
||||
f, err := svc.Files.Get(fileID).SupportsAllDrives(true).Fields("webViewLink").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if f.WebViewLink != "" {
|
||||
return f.WebViewLink, nil
|
||||
}
|
||||
return fmt.Sprintf("https://drive.google.com/file/d/%s/view", fileID), nil
|
||||
}
|
||||
|
||||
@ -1,310 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/drive/v3"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
type DriveDownloadCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
Output OutputPathFlag `embed:""`
|
||||
Format string `name:"format" help:"Export format for Google Docs files: pdf|csv|xlsx|pptx|txt|png|docx|md (default: inferred)"`
|
||||
Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (Google Docs only; see 'gog docs list-tabs')"`
|
||||
}
|
||||
|
||||
func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileID := normalizeGoogleID(strings.TrimSpace(c.FileID))
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
|
||||
if tab := strings.TrimSpace(c.Tab); tab != "" {
|
||||
if f := c.Format; f != "" && f != formatAuto {
|
||||
if _, fmtErr := tabExportFormatParam(f); fmtErr != nil {
|
||||
return fmt.Errorf("--tab limits export formats (pdf|docx|txt|md|html); %q is not supported with --tab", f)
|
||||
}
|
||||
}
|
||||
return runDocsTabExport(ctx, flags, tabExportParams{
|
||||
DocID: fileID,
|
||||
OutFlag: c.Output.Path,
|
||||
Format: c.Format,
|
||||
TabQuery: tab,
|
||||
})
|
||||
}
|
||||
|
||||
u := ui.FromContext(ctx)
|
||||
if formatErr := validateDriveDownloadFormatFlag(c.Format); formatErr != nil {
|
||||
return formatErr
|
||||
}
|
||||
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
meta, err := svc.Files.Get(fileID).
|
||||
SupportsAllDrives(true).
|
||||
Fields("id, name, mimeType").
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.Name == "" {
|
||||
return errors.New("file has no name")
|
||||
}
|
||||
if fileFormatErr := validateDriveDownloadFormatForFile(meta, c.Format); fileFormatErr != nil {
|
||||
return fileFormatErr
|
||||
}
|
||||
|
||||
destPath, err := resolveDriveDownloadDestPath(meta, c.Output.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(ctx) && isStdoutPath(destPath) {
|
||||
return usage("can't combine --json with --out -")
|
||||
}
|
||||
|
||||
downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"path": downloadedPath,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
if isStdoutPath(downloadedPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
u.Out().Printf("path\t%s", downloadedPath)
|
||||
u.Out().Printf("size\t%s", formatDriveSize(size))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File, destPath string, format string) (string, int64, error) {
|
||||
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
|
||||
normalizedFormat := strings.ToLower(strings.TrimSpace(format))
|
||||
if normalizedFormat == formatAuto {
|
||||
normalizedFormat = ""
|
||||
}
|
||||
|
||||
if !isGoogleDoc && normalizedFormat != "" {
|
||||
return "", 0, fmt.Errorf("--format %q not supported for non-Google Workspace files (mimeType=%q); file can only be downloaded as-is", format, meta.MimeType)
|
||||
}
|
||||
if fileFormatErr := validateDriveDownloadFormatForFile(meta, format); fileFormatErr != nil {
|
||||
return "", 0, fileFormatErr
|
||||
}
|
||||
|
||||
var (
|
||||
resp *http.Response
|
||||
outPath string
|
||||
err error
|
||||
)
|
||||
|
||||
if isGoogleDoc {
|
||||
var exportMimeType string
|
||||
if normalizedFormat == "" {
|
||||
exportMimeType = driveExportMimeType(meta.MimeType)
|
||||
} else {
|
||||
var mimeErr error
|
||||
exportMimeType, mimeErr = driveExportMimeTypeForFormat(meta.MimeType, normalizedFormat)
|
||||
if mimeErr != nil {
|
||||
return "", 0, mimeErr
|
||||
}
|
||||
}
|
||||
if isStdoutPath(destPath) {
|
||||
outPath = stdoutPath
|
||||
} else {
|
||||
outPath = replaceExt(destPath, driveExportExtension(exportMimeType))
|
||||
}
|
||||
resp, err = driveExportDownload(ctx, svc, meta.Id, exportMimeType)
|
||||
} else {
|
||||
outPath = destPath
|
||||
resp, err = driveDownload(ctx, svc, meta.Id)
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", 0, fmt.Errorf("download failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
if isStdoutPath(outPath) {
|
||||
n, copyErr := io.Copy(os.Stdout, resp.Body)
|
||||
return stdoutPath, n, copyErr
|
||||
}
|
||||
|
||||
f, outPath, err := createUserOutputFile(outPath)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, resp.Body)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return outPath, n, nil
|
||||
}
|
||||
|
||||
func validateDriveDownloadFormatFlag(format string) error {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" {
|
||||
return nil
|
||||
}
|
||||
switch format {
|
||||
case "pdf", "csv", "xlsx", "pptx", "txt", "png", "docx", "md", "html":
|
||||
return nil
|
||||
default:
|
||||
return usagef("invalid --format %q (use pdf|csv|xlsx|pptx|txt|png|docx|md|html)", format)
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveDownloadFormatForFile(meta *drive.File, format string) error {
|
||||
if meta == nil {
|
||||
return errors.New("missing file metadata")
|
||||
}
|
||||
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
|
||||
if isGoogleDoc {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(format) == "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("--format %q not supported for non-Google Workspace files (mimeType=%q); file can only be downloaded as-is", format, meta.MimeType)
|
||||
}
|
||||
|
||||
var driveDownload = func(ctx context.Context, svc *drive.Service, fileID string) (*http.Response, error) {
|
||||
return svc.Files.Get(fileID).SupportsAllDrives(true).Context(ctx).Download()
|
||||
}
|
||||
|
||||
var driveExportDownload = func(ctx context.Context, svc *drive.Service, fileID string, mimeType string) (*http.Response, error) {
|
||||
return svc.Files.Export(fileID, mimeType).Context(ctx).Download()
|
||||
}
|
||||
|
||||
func replaceExt(path string, ext string) string {
|
||||
base := strings.TrimSuffix(path, filepath.Ext(path))
|
||||
return base + ext
|
||||
}
|
||||
|
||||
func driveExportMimeType(googleMimeType string) string {
|
||||
switch googleMimeType {
|
||||
case driveMimeGoogleDoc:
|
||||
return mimePDF
|
||||
case driveMimeGoogleSheet:
|
||||
return mimeCSV
|
||||
case driveMimeGoogleSlides:
|
||||
return mimePDF
|
||||
case driveMimeGoogleDrawing:
|
||||
return mimePNG
|
||||
default:
|
||||
return mimePDF
|
||||
}
|
||||
}
|
||||
|
||||
func driveExportMimeTypeForFormat(googleMimeType string, format string) (string, error) {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" || format == formatAuto {
|
||||
return driveExportMimeType(googleMimeType), nil
|
||||
}
|
||||
|
||||
switch googleMimeType {
|
||||
case driveMimeGoogleDoc:
|
||||
switch format {
|
||||
case defaultExportFormat:
|
||||
return mimePDF, nil
|
||||
case "docx":
|
||||
return mimeDocx, nil
|
||||
case "txt":
|
||||
return mimeTextPlain, nil
|
||||
case "md":
|
||||
return mimeTextMarkdown, nil
|
||||
case "html":
|
||||
return mimeHTML, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --format %q for Google Doc (use pdf|docx|txt|md|html)", format)
|
||||
}
|
||||
case driveMimeGoogleSheet:
|
||||
switch format {
|
||||
case defaultExportFormat:
|
||||
return mimePDF, nil
|
||||
case "csv":
|
||||
return mimeCSV, nil
|
||||
case "xlsx":
|
||||
return mimeXlsx, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --format %q for Google Sheet (use pdf|csv|xlsx)", format)
|
||||
}
|
||||
case driveMimeGoogleSlides:
|
||||
switch format {
|
||||
case defaultExportFormat:
|
||||
return mimePDF, nil
|
||||
case "pptx":
|
||||
return mimePptx, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --format %q for Google Slides (use pdf|pptx)", format)
|
||||
}
|
||||
case driveMimeGoogleDrawing:
|
||||
switch format {
|
||||
case "png":
|
||||
return mimePNG, nil
|
||||
case defaultExportFormat:
|
||||
return mimePDF, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --format %q for Google Drawing (use png|pdf)", format)
|
||||
}
|
||||
default:
|
||||
if format == defaultExportFormat {
|
||||
return mimePDF, nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid --format %q for file type %q (use pdf)", format, googleMimeType)
|
||||
}
|
||||
}
|
||||
|
||||
func driveExportExtension(mimeType string) string {
|
||||
switch mimeType {
|
||||
case mimePDF:
|
||||
return extPDF
|
||||
case mimeCSV:
|
||||
return extCSV
|
||||
case mimeXlsx:
|
||||
return extXlsx
|
||||
case mimeDocx:
|
||||
return extDocx
|
||||
case mimePptx:
|
||||
return extPptx
|
||||
case mimePNG:
|
||||
return extPNG
|
||||
case mimeTextPlain:
|
||||
return extTXT
|
||||
case mimeTextMarkdown:
|
||||
return extMD
|
||||
case mimeHTML:
|
||||
return extHTML
|
||||
default:
|
||||
return extPDF
|
||||
}
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
gapi "google.golang.org/api/googleapi"
|
||||
)
|
||||
|
||||
// driveRawSensitiveFields is the set of top-level File fields redacted from
|
||||
// `gog drive raw` output when the user did not name them via --fields. See
|
||||
// docs/raw-audit.md for the rationale per field.
|
||||
var driveRawSensitiveFields = []string{
|
||||
"thumbnailLink",
|
||||
"webContentLink",
|
||||
"exportLinks",
|
||||
"resourceKey",
|
||||
"appProperties",
|
||||
"properties",
|
||||
}
|
||||
|
||||
// DriveRawCmd dumps the full Files.Get response as JSON. Uses fields=* by
|
||||
// default to expose the entire File resource. When --fields is absent the
|
||||
// command redacts a small set of capability/token-shaped fields (see
|
||||
// driveRawSensitiveFields); when --fields is explicitly set the response is
|
||||
// returned verbatim, honoring exactly what the user asked for. This means
|
||||
// passing `--fields "id,name,thumbnailLink"` returns thumbnailLink as
|
||||
// requested.
|
||||
//
|
||||
// REST reference: https://developers.google.com/drive/api/reference/rest/v3/files/get
|
||||
// Go type: https://pkg.go.dev/google.golang.org/api/drive/v3#File
|
||||
type DriveRawCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
Fields string `name:"fields" help:"Drive API field mask (default: * with sensitive fields redacted client-side). Set explicitly to disable redaction."`
|
||||
Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"`
|
||||
}
|
||||
|
||||
func (c *DriveRawCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
fileID := strings.TrimSpace(c.FileID)
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userSetFields := strings.TrimSpace(c.Fields) != ""
|
||||
mask := "*"
|
||||
if userSetFields {
|
||||
mask = c.Fields
|
||||
}
|
||||
|
||||
f, err := svc.Files.Get(fileID).
|
||||
SupportsAllDrives(true).
|
||||
Fields(gapi.Field(mask)).
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err = requireRawResponse(f, "file not found")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal drive file: %w", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return fmt.Errorf("unmarshal drive file: %w", err)
|
||||
}
|
||||
|
||||
if !userSetFields {
|
||||
for _, key := range driveRawSensitiveFields {
|
||||
delete(m, key)
|
||||
}
|
||||
if hints, ok := m["contentHints"].(map[string]any); ok {
|
||||
if thumb, ok := hints["thumbnail"].(map[string]any); ok {
|
||||
delete(thumb, "image")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return writeRawJSON(ctx, m, c.Pretty)
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -9,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/drive/v3"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
type driveRawHit struct {
|
||||
@ -40,8 +42,18 @@ func newDriveRawTestServer(t *testing.T, status int, body map[string]any, hit *d
|
||||
|
||||
func installMockDriveService(t *testing.T, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", drive.NewService)
|
||||
stubGoogleTestService(t, &newDriveService, svc)
|
||||
orig := newDriveService
|
||||
t.Cleanup(func() { newDriveService = orig })
|
||||
|
||||
svc, err := drive.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil }
|
||||
}
|
||||
|
||||
// sensitiveDriveFile returns a File response containing every sensitive
|
||||
|
||||
@ -1,341 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/drive/v3"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
const (
|
||||
driveShareToAnyone = "anyone"
|
||||
driveShareToUser = "user"
|
||||
driveShareToDomain = "domain"
|
||||
|
||||
// Drive sharing permission roles matching the Google Drive API roles.
|
||||
// "commenter" allows view + comment access without edit rights.
|
||||
drivePermRoleReader = "reader"
|
||||
drivePermRoleWriter = "writer"
|
||||
drivePermRoleCommenter = "commenter"
|
||||
)
|
||||
|
||||
type DriveShareCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
To string `name:"to" help:"Share target: anyone|user|domain"`
|
||||
Anyone bool `name:"anyone" hidden:"" help:"(deprecated) Use --to=anyone"`
|
||||
Email string `name:"email" help:"User email (for --to=user)"`
|
||||
Domain string `name:"domain" help:"Domain (for --to=domain; e.g. example.com)"`
|
||||
Role string `name:"role" help:"Permission: reader|writer|commenter" default:"reader"`
|
||||
Discoverable bool `name:"discoverable" help:"Allow file discovery in search (anyone/domain only)"`
|
||||
}
|
||||
|
||||
type driveShareTarget struct {
|
||||
to string
|
||||
email string
|
||||
domain string
|
||||
}
|
||||
|
||||
func (c *DriveShareCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := strings.TrimSpace(c.FileID)
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
|
||||
target, err := c.normalizeTarget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role, err := normalizeDrivePermissionRole(c.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.to == driveShareToAnyone {
|
||||
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("share drive file %s with anyone (public)", fileID)); confirmErr != nil {
|
||||
return confirmErr
|
||||
}
|
||||
}
|
||||
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
perm := target.permission(role, c.Discoverable)
|
||||
|
||||
created, err := svc.Permissions.Create(fileID, perm).
|
||||
SupportsAllDrives(true).
|
||||
SendNotificationEmail(false).
|
||||
Fields("id, type, role, emailAddress, domain, allowFileDiscovery").
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
link, err := driveWebLink(ctx, svc, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"link": link,
|
||||
"permissionId": created.Id,
|
||||
"permission": created,
|
||||
})
|
||||
}
|
||||
|
||||
u.Out().Printf("link\t%s", link)
|
||||
u.Out().Printf("permission_id\t%s", created.Id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *DriveShareCmd) normalizeTarget() (driveShareTarget, error) {
|
||||
to := strings.TrimSpace(c.To)
|
||||
email := strings.TrimSpace(c.Email)
|
||||
domain := strings.TrimSpace(c.Domain)
|
||||
|
||||
// Back-compat: allow legacy target flags without --to, but keep it unambiguous.
|
||||
// New UX: prefer explicit --to + matching parameter.
|
||||
if to == "" {
|
||||
switch {
|
||||
case c.Anyone && email == "" && domain == "":
|
||||
to = driveShareToAnyone
|
||||
case !c.Anyone && email != "" && domain == "":
|
||||
to = driveShareToUser
|
||||
case !c.Anyone && email == "" && domain != "":
|
||||
to = driveShareToDomain
|
||||
case !c.Anyone && email == "" && domain == "":
|
||||
return driveShareTarget{}, usage("must specify --to (anyone|user|domain)")
|
||||
default:
|
||||
return driveShareTarget{}, usage("ambiguous share target (use --to=anyone|user|domain)")
|
||||
}
|
||||
}
|
||||
|
||||
switch to {
|
||||
case driveShareToAnyone:
|
||||
if email != "" || domain != "" {
|
||||
return driveShareTarget{}, usage("--to=anyone cannot be combined with --email or --domain")
|
||||
}
|
||||
case driveShareToUser:
|
||||
if email == "" {
|
||||
return driveShareTarget{}, usage("missing --email for --to=user")
|
||||
}
|
||||
if domain != "" || c.Anyone {
|
||||
return driveShareTarget{}, usage("--to=user cannot be combined with --anyone or --domain")
|
||||
}
|
||||
if c.Discoverable {
|
||||
return driveShareTarget{}, usage("--discoverable is only valid for --to=anyone or --to=domain")
|
||||
}
|
||||
case driveShareToDomain:
|
||||
if domain == "" {
|
||||
return driveShareTarget{}, usage("missing --domain for --to=domain")
|
||||
}
|
||||
if email != "" || c.Anyone {
|
||||
return driveShareTarget{}, usage("--to=domain cannot be combined with --anyone or --email")
|
||||
}
|
||||
default:
|
||||
return driveShareTarget{}, usage("invalid --to (expected anyone|user|domain)")
|
||||
}
|
||||
|
||||
return driveShareTarget{to: to, email: email, domain: domain}, nil
|
||||
}
|
||||
|
||||
func (target driveShareTarget) permission(role string, discoverable bool) *drive.Permission {
|
||||
perm := &drive.Permission{Role: role}
|
||||
switch target.to {
|
||||
case driveShareToAnyone:
|
||||
perm.Type = "anyone"
|
||||
perm.AllowFileDiscovery = discoverable
|
||||
case driveShareToDomain:
|
||||
perm.Type = "domain"
|
||||
perm.Domain = target.domain
|
||||
perm.AllowFileDiscovery = discoverable
|
||||
default:
|
||||
perm.Type = "user"
|
||||
perm.EmailAddress = target.email
|
||||
}
|
||||
return perm
|
||||
}
|
||||
|
||||
func normalizeDrivePermissionRole(role string) (string, error) {
|
||||
role = strings.TrimSpace(role)
|
||||
if role == "" {
|
||||
return drivePermRoleReader, nil
|
||||
}
|
||||
switch role {
|
||||
case drivePermRoleReader, drivePermRoleWriter, drivePermRoleCommenter:
|
||||
return role, nil
|
||||
default:
|
||||
return "", usage("invalid --role (expected reader|writer|commenter)")
|
||||
}
|
||||
}
|
||||
|
||||
type DriveUnshareCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
PermissionID string `arg:"" name:"permissionId" help:"Permission ID"`
|
||||
}
|
||||
|
||||
func (c *DriveUnshareCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := strings.TrimSpace(c.FileID)
|
||||
permissionID := strings.TrimSpace(c.PermissionID)
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
if permissionID == "" {
|
||||
return usage("empty permissionId")
|
||||
}
|
||||
|
||||
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("remove permission %s from drive file %s", permissionID, fileID)); confirmErr != nil {
|
||||
return confirmErr
|
||||
}
|
||||
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := svc.Permissions.Delete(fileID, permissionID).SupportsAllDrives(true).Context(ctx).Do(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeResult(ctx, u,
|
||||
kv("removed", true),
|
||||
kv("fileId", fileID),
|
||||
kv("permissionId", permissionID),
|
||||
)
|
||||
}
|
||||
|
||||
type DrivePermissionsCmd struct {
|
||||
FileID string `arg:"" name:"fileId" help:"File ID"`
|
||||
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
|
||||
Page string `name:"page" aliases:"cursor" help:"Page token"`
|
||||
}
|
||||
|
||||
func (c *DrivePermissionsCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := strings.TrimSpace(c.FileID)
|
||||
if fileID == "" {
|
||||
return usage("empty fileId")
|
||||
}
|
||||
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
call := svc.Permissions.List(fileID).
|
||||
SupportsAllDrives(true).
|
||||
Fields("nextPageToken, permissions(id, type, role, emailAddress, domain)").
|
||||
Context(ctx)
|
||||
if c.Max > 0 {
|
||||
call = call.PageSize(c.Max)
|
||||
}
|
||||
if strings.TrimSpace(c.Page) != "" {
|
||||
call = call.PageToken(c.Page)
|
||||
}
|
||||
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"fileId": fileID,
|
||||
"permissions": resp.Permissions,
|
||||
"permissionCount": len(resp.Permissions),
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
if len(resp.Permissions) == 0 {
|
||||
u.Err().Println("No permissions")
|
||||
return nil
|
||||
}
|
||||
|
||||
w, flush := tableWriter(ctx)
|
||||
defer flush()
|
||||
fmt.Fprintln(w, "ID\tTYPE\tROLE\tEMAIL")
|
||||
for _, p := range resp.Permissions {
|
||||
email := p.EmailAddress
|
||||
if email == "" && p.Domain != "" {
|
||||
email = p.Domain
|
||||
}
|
||||
if email == "" {
|
||||
email = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", p.Id, p.Type, p.Role, email)
|
||||
}
|
||||
printNextPageHint(u, resp.NextPageToken)
|
||||
return nil
|
||||
}
|
||||
|
||||
type DriveURLCmd struct {
|
||||
FileIDs []string `arg:"" name:"fileId" help:"File IDs"`
|
||||
}
|
||||
|
||||
func (c *DriveURLCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := newDriveService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range c.FileIDs {
|
||||
link, err := driveWebLink(ctx, svc, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
// collected below
|
||||
} else {
|
||||
u.Out().Printf("%s\t%s", id, link)
|
||||
}
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
urls := make([]map[string]string, 0, len(c.FileIDs))
|
||||
for _, id := range c.FileIDs {
|
||||
link, err := driveWebLink(ctx, svc, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
urls = append(urls, map[string]string{"id": id, "url": link})
|
||||
}
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"urls": urls})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func driveWebLink(ctx context.Context, svc *drive.Service, fileID string) (string, error) {
|
||||
f, err := svc.Files.Get(fileID).SupportsAllDrives(true).Fields("webViewLink").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if f.WebViewLink != "" {
|
||||
return f.WebViewLink, nil
|
||||
}
|
||||
return fmt.Sprintf("https://drive.google.com/file/d/%s/view", fileID), nil
|
||||
}
|
||||
@ -5,16 +5,28 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/drive/v3"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
func newDriveTestService(t *testing.T, h http.Handler) (*drive.Service, func()) {
|
||||
t.Helper()
|
||||
|
||||
return newGoogleTestService(t, h, drive.NewService)
|
||||
srv := httptest.NewServer(h)
|
||||
|
||||
svc, err := drive.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
return svc, srv.Close
|
||||
}
|
||||
|
||||
func stubDriveService(svc *drive.Service) func(context.Context, string) (*drive.Service, error) {
|
||||
@ -23,7 +35,9 @@ func stubDriveService(svc *drive.Service) func(context.Context, string) (*drive.
|
||||
|
||||
func stubDriveServiceForTest(t *testing.T, svc *drive.Service) {
|
||||
t.Helper()
|
||||
stubGoogleTestService(t, &newDriveService, svc)
|
||||
origNew := newDriveService
|
||||
t.Cleanup(func() { newDriveService = origNew })
|
||||
newDriveService = stubDriveService(svc)
|
||||
}
|
||||
|
||||
func newDriveMetadataTestService(t *testing.T, mimeType string) (*drive.Service, func()) {
|
||||
|
||||
@ -17,18 +17,6 @@ import (
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
type DriveUploadCmd struct {
|
||||
LocalPath string `arg:"" name:"localPath" help:"Path to local file"`
|
||||
Name string `name:"name" help:"Override filename (create) or rename target (replace)"`
|
||||
Parent string `name:"parent" help:"Destination folder ID (create only)"`
|
||||
ReplaceFileID string `name:"replace" help:"Replace the content of an existing Drive file ID (preserves shared link/permissions)"`
|
||||
MimeType string `name:"mime-type" help:"Override MIME type inference"`
|
||||
KeepRevisionForever bool `name:"keep-revision-forever" help:"Keep the new head revision forever (binary files only)"`
|
||||
Convert bool `name:"convert" help:"Auto-convert to native Google format based on file extension (create only)"`
|
||||
ConvertTo string `name:"convert-to" help:"Convert to a specific Google format: doc|sheet|slides (create only)"`
|
||||
KeepFrontmatter bool `name:"keep-frontmatter" help:"Keep YAML frontmatter (---) in Markdown when converting to a Google Doc (--convert or --convert-to doc; default: strip)"`
|
||||
}
|
||||
|
||||
type driveUploadOptions struct {
|
||||
localPath string
|
||||
fileName string
|
||||
@ -42,113 +30,6 @@ type driveUploadOptions struct {
|
||||
size int64
|
||||
}
|
||||
|
||||
func guessMimeType(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case extPDF:
|
||||
return mimePDF
|
||||
case ".doc":
|
||||
return "application/msword"
|
||||
case extDocx:
|
||||
return mimeDocx
|
||||
case ".xls":
|
||||
return "application/vnd.ms-excel"
|
||||
case extXlsx:
|
||||
return mimeXlsx
|
||||
case ".ppt":
|
||||
return "application/vnd.ms-powerpoint"
|
||||
case extPptx:
|
||||
return mimePptx
|
||||
case extPNG:
|
||||
return mimePNG
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case extTXT:
|
||||
return mimeTextPlain
|
||||
case ".html":
|
||||
return "text/html"
|
||||
case ".css":
|
||||
return "text/css"
|
||||
case ".js":
|
||||
return "application/javascript"
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".zip":
|
||||
return "application/zip"
|
||||
case ".csv":
|
||||
return "text/csv"
|
||||
case ".md":
|
||||
return "text/markdown"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// googleConvertMimeType returns the Google-native MIME type for convertible
|
||||
// Office/text formats. The boolean indicates whether the extension is supported.
|
||||
func googleConvertMimeType(path string) (string, bool) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case extDocx, ".doc":
|
||||
return driveMimeGoogleDoc, true
|
||||
case extXlsx, ".xls", extCSV:
|
||||
return driveMimeGoogleSheet, true
|
||||
case extPptx, ".ppt":
|
||||
return driveMimeGoogleSlides, true
|
||||
case extTXT, ".html", extMD:
|
||||
return driveMimeGoogleDoc, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func googleConvertTargetMimeType(target string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(target)) {
|
||||
case "doc":
|
||||
return driveMimeGoogleDoc, true
|
||||
case "sheet":
|
||||
return driveMimeGoogleSheet, true
|
||||
case "slides":
|
||||
return driveMimeGoogleSlides, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func driveUploadConvertMimeType(path string, auto bool, target string) (string, bool, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target != "" {
|
||||
mimeType, ok := googleConvertTargetMimeType(target)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("--convert-to: invalid value %q (use doc|sheet|slides)", target)
|
||||
}
|
||||
return mimeType, true, nil
|
||||
}
|
||||
if !auto {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
mimeType, ok := googleConvertMimeType(path)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("--convert: unsupported file type %q (supported: docx, xlsx, pptx, doc, xls, ppt, csv, txt, html, md)", filepath.Ext(path))
|
||||
}
|
||||
return mimeType, true, nil
|
||||
}
|
||||
|
||||
// stripOfficeExt removes common Office extensions from a filename so
|
||||
// the resulting Google Doc/Sheet/Slides has a clean name.
|
||||
func stripOfficeExt(name string) string {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
switch ext {
|
||||
case extDocx, ".doc", extXlsx, ".xls", extPptx, ".ppt", extMD:
|
||||
return strings.TrimSuffix(name, filepath.Ext(name))
|
||||
default:
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
opts, err := prepareDriveUpload(c)
|
||||
if err != nil {
|
||||
|
||||
@ -2,7 +2,11 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
)
|
||||
|
||||
// FormsRawCmd dumps the full Forms.Get response as JSON.
|
||||
@ -33,10 +37,9 @@ func (c *FormsRawCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
form, err = requireRawResponse(form, "form not found")
|
||||
if err != nil {
|
||||
return err
|
||||
if form == nil {
|
||||
return errors.New("form not found")
|
||||
}
|
||||
|
||||
return writeRawJSON(ctx, form, c.Pretty)
|
||||
return outfmt.WriteRaw(ctx, os.Stdout, form, outfmt.RawOptions{Pretty: c.Pretty})
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -8,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
formsapi "google.golang.org/api/forms/v1"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
func newFormsRawTestServer(t *testing.T, status int, body map[string]any) *httptest.Server {
|
||||
@ -31,8 +33,18 @@ func newFormsRawTestServer(t *testing.T, status int, body map[string]any) *httpt
|
||||
|
||||
func installMockFormsService(t *testing.T, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", formsapi.NewService)
|
||||
stubGoogleTestService(t, &newFormsService, svc)
|
||||
orig := newFormsService
|
||||
t.Cleanup(func() { newFormsService = orig })
|
||||
|
||||
svc, err := formsapi.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newFormsService = func(context.Context, string) (*formsapi.Service, error) { return svc, nil }
|
||||
}
|
||||
|
||||
func fullFormResponse(id string) map[string]any {
|
||||
|
||||
@ -43,17 +43,28 @@ func (c *GmailHistoryCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
resp, callErr := call.Context(ctx).Do()
|
||||
if callErr != nil {
|
||||
return nil, "", callErr
|
||||
resp, err := call.Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
historyID = formatHistoryID(resp.HistoryId)
|
||||
historyIDs := collectHistoryMessageIDs(resp)
|
||||
return historyIDs.FetchIDs, resp.NextPageToken, nil
|
||||
}
|
||||
ids, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var ids []string
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids = all
|
||||
} else {
|
||||
var err error
|
||||
ids, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
|
||||
@ -2,8 +2,12 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
)
|
||||
|
||||
// GmailRawCmd dumps the full Users.Messages.Get response as JSON. Note the
|
||||
@ -52,10 +56,9 @@ func (c *GmailRawCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err = requireRawResponse(msg, "message not found")
|
||||
if err != nil {
|
||||
return err
|
||||
if msg == nil {
|
||||
return errors.New("message not found")
|
||||
}
|
||||
|
||||
return writeRawJSON(ctx, msg, c.Pretty)
|
||||
return outfmt.WriteRaw(ctx, os.Stdout, msg, outfmt.RawOptions{Pretty: c.Pretty})
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -9,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
type gmailRawHit struct {
|
||||
@ -39,8 +41,18 @@ func newGmailRawTestServer(t *testing.T, status int, body map[string]any, hit *g
|
||||
|
||||
func installMockGmailService(t *testing.T, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", gmail.NewService)
|
||||
stubGoogleTestService(t, &newGmailService, svc)
|
||||
orig := newGmailService
|
||||
t.Cleanup(func() { newGmailService = orig })
|
||||
|
||||
svc, err := gmail.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
|
||||
}
|
||||
|
||||
func fullGmailMessageResponse(id string) map[string]any {
|
||||
|
||||
@ -1,19 +1,34 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
func newGmailServiceForTest(t *testing.T, h http.HandlerFunc) (*gmail.Service, func()) {
|
||||
t.Helper()
|
||||
|
||||
return newGoogleTestService(t, h, gmail.NewService)
|
||||
srv := httptest.NewServer(h)
|
||||
svc, err := gmail.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
srv.Close()
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
return svc, srv.Close
|
||||
}
|
||||
|
||||
func stubGmailServiceForTest(t *testing.T, svc *gmail.Service) {
|
||||
t.Helper()
|
||||
stubGoogleTestService(t, &newGmailService, svc)
|
||||
origNew := newGmailService
|
||||
t.Cleanup(func() { newGmailService = origNew })
|
||||
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
|
||||
}
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
type googleTestServiceFactory[T any] func(context.Context, ...option.ClientOption) (*T, error)
|
||||
|
||||
func newGoogleTestService[T any](t *testing.T, h http.Handler, factory googleTestServiceFactory[T]) (*T, func()) {
|
||||
t.Helper()
|
||||
|
||||
srv := httptest.NewServer(h)
|
||||
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", factory)
|
||||
return svc, srv.Close
|
||||
}
|
||||
|
||||
func newGoogleTestServiceWithEndpoint[T any](
|
||||
t *testing.T,
|
||||
client *http.Client,
|
||||
endpoint string,
|
||||
factory googleTestServiceFactory[T],
|
||||
) *T {
|
||||
t.Helper()
|
||||
|
||||
svc, err := factory(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(client),
|
||||
option.WithEndpoint(endpoint),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func stubGoogleTestService[T any](t *testing.T, target *func(context.Context, string) (*T, error), svc *T) {
|
||||
t.Helper()
|
||||
|
||||
orig := *target
|
||||
t.Cleanup(func() { *target = orig })
|
||||
//nolint:unparam // test stub must match production service constructor signature.
|
||||
*target = func(context.Context, string) (*T, error) { return svc, nil }
|
||||
}
|
||||
@ -60,16 +60,27 @@ func (c *GroupsListCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", wrapCloudIdentityError(callErr, account)
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", wrapCloudIdentityError(err, account)
|
||||
}
|
||||
return resp.Memberships, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
memberships, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var memberships []*cloudidentity.GroupRelation
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
memberships = all
|
||||
} else {
|
||||
var err error
|
||||
memberships, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
@ -201,16 +212,27 @@ func (c *GroupsMembersCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", fmt.Errorf("failed to list members: %w", callErr)
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to list members: %w", err)
|
||||
}
|
||||
return resp.Memberships, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
memberships, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var memberships []*cloudidentity.Membership
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
memberships = all
|
||||
} else {
|
||||
var err error
|
||||
memberships, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -60,9 +60,20 @@ func (c *KeepListCmd) Run(ctx context.Context, flags *RootFlags, keep *KeepCmd)
|
||||
return resp.Notes, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
notes, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var notes []*keepapi.Note
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notes = all
|
||||
} else {
|
||||
var err error
|
||||
notes, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -102,16 +102,27 @@ func (c *PeopleSearchCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", wrapPeopleAPIError(callErr)
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", wrapPeopleAPIError(err)
|
||||
}
|
||||
return resp.People, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
peopleList, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var peopleList []*people.Person
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peopleList = all
|
||||
} else {
|
||||
var err error
|
||||
peopleList, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -2,7 +2,11 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
)
|
||||
|
||||
// defaultPeopleRawMask is the field mask used when the user does not
|
||||
@ -64,10 +68,9 @@ func runPeopleRaw(ctx context.Context, flags *RootFlags, id, fields string, pret
|
||||
if err != nil {
|
||||
return wrapPeopleAPIError(err)
|
||||
}
|
||||
person, err = requireRawResponse(person, "person not found")
|
||||
if err != nil {
|
||||
return err
|
||||
if person == nil {
|
||||
return errors.New("person not found")
|
||||
}
|
||||
|
||||
return writeRawJSON(ctx, person, pretty)
|
||||
return outfmt.WriteRaw(ctx, os.Stdout, person, outfmt.RawOptions{Pretty: pretty})
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/api/people/v1"
|
||||
)
|
||||
|
||||
@ -31,8 +33,18 @@ func newPeopleRawTestServer(t *testing.T, status int, body map[string]any) *http
|
||||
|
||||
func installMockPeopleContactsService(t *testing.T, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", people.NewService)
|
||||
stubGoogleTestService(t, &newPeopleContactsService, svc)
|
||||
orig := newPeopleContactsService
|
||||
t.Cleanup(func() { newPeopleContactsService = orig })
|
||||
|
||||
svc, err := people.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newPeopleContactsService = func(context.Context, string) (*people.Service, error) { return svc, nil }
|
||||
}
|
||||
|
||||
func fullPersonResponse(id string) map[string]any {
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
)
|
||||
|
||||
func requireRawResponse[T any](response *T, notFoundMessage string) (*T, error) {
|
||||
if response == nil {
|
||||
return nil, errors.New(notFoundMessage)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func writeRawJSON(ctx context.Context, value any, pretty bool) error {
|
||||
return outfmt.WriteRaw(ctx, os.Stdout, value, outfmt.RawOptions{Pretty: pretty})
|
||||
}
|
||||
@ -3,6 +3,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@ -457,16 +458,15 @@ func (c *SheetsRawCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err = requireRawResponse(resp, "spreadsheet not found")
|
||||
if err != nil {
|
||||
return err
|
||||
if resp == nil {
|
||||
return errors.New("spreadsheet not found")
|
||||
}
|
||||
|
||||
if len(resp.DeveloperMetadata) > 0 {
|
||||
u.Err().Println("warning: response contains developerMetadata which may hold third-party app secrets")
|
||||
}
|
||||
|
||||
return writeRawJSON(ctx, resp, c.Pretty)
|
||||
return outfmt.WriteRaw(ctx, os.Stdout, resp, outfmt.RawOptions{Pretty: c.Pretty})
|
||||
}
|
||||
|
||||
type SheetsMetadataCmd struct {
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/api/sheets/v4"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
@ -46,8 +47,18 @@ func newSheetsRawTestServer(t *testing.T, status int, body map[string]any, hit *
|
||||
|
||||
func installMockSheetsService(t *testing.T, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", sheets.NewService)
|
||||
stubGoogleTestService(t, &newSheetsService, svc)
|
||||
orig := newSheetsService
|
||||
t.Cleanup(func() { newSheetsService = orig })
|
||||
|
||||
svc, err := sheets.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil }
|
||||
}
|
||||
|
||||
func fullSheetResponse(id string) map[string]any {
|
||||
|
||||
@ -69,12 +69,11 @@ func (c *SlidesRawCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pres, err = requireRawResponse(pres, "presentation not found")
|
||||
if err != nil {
|
||||
return err
|
||||
if pres == nil {
|
||||
return errors.New("presentation not found")
|
||||
}
|
||||
|
||||
return writeRawJSON(ctx, pres, c.Pretty)
|
||||
return outfmt.WriteRaw(ctx, os.Stdout, pres, outfmt.RawOptions{Pretty: c.Pretty})
|
||||
}
|
||||
|
||||
type SlidesExportCmd struct {
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/api/slides/v1"
|
||||
)
|
||||
|
||||
@ -33,8 +35,18 @@ func newSlidesRawTestServer(t *testing.T, status int, body map[string]any) *http
|
||||
|
||||
func installMockSlidesService(t *testing.T, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", slides.NewService)
|
||||
stubGoogleTestService(t, &newSlidesService, svc)
|
||||
orig := newSlidesService
|
||||
t.Cleanup(func() { newSlidesService = orig })
|
||||
|
||||
svc, err := slides.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newSlidesService = func(context.Context, string) (*slides.Service, error) { return svc, nil }
|
||||
}
|
||||
|
||||
func fullPresentationResponse(id string) map[string]any {
|
||||
|
||||
@ -82,16 +82,27 @@ func (c *TasksListCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
call = call.UpdatedMin(strings.TrimSpace(c.UpdatedMin))
|
||||
}
|
||||
|
||||
resp, callErr := call.Context(ctx).Do()
|
||||
if callErr != nil {
|
||||
return nil, "", callErr
|
||||
resp, err := call.Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return resp.Items, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
items, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var items []*tasks.Task
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items = all
|
||||
} else {
|
||||
var err error
|
||||
items, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -41,16 +41,27 @@ func (c *TasksListsListCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
resp, callErr := call.Do()
|
||||
if callErr != nil {
|
||||
return nil, "", callErr
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return resp.Items, resp.NextPageToken, nil
|
||||
}
|
||||
|
||||
items, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
var items []*tasks.TaskList
|
||||
nextPageToken := ""
|
||||
if c.All {
|
||||
all, err := collectAllPages(c.Page, fetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items = all
|
||||
} else {
|
||||
var err error
|
||||
items, nextPageToken, err = fetch(c.Page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
|
||||
@ -2,7 +2,11 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
)
|
||||
|
||||
// TasksRawCmd dumps the full Tasks.Get response as JSON.
|
||||
@ -42,10 +46,9 @@ func (c *TasksRawCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, err = requireRawResponse(task, "task not found")
|
||||
if err != nil {
|
||||
return err
|
||||
if task == nil {
|
||||
return errors.New("task not found")
|
||||
}
|
||||
|
||||
return writeRawJSON(ctx, task, c.Pretty)
|
||||
return outfmt.WriteRaw(ctx, os.Stdout, task, outfmt.RawOptions{Pretty: c.Pretty})
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/api/tasks/v1"
|
||||
)
|
||||
|
||||
@ -37,8 +39,18 @@ func newTasksRawTestServer(t *testing.T, status int, body map[string]any) *httpt
|
||||
|
||||
func installMockTasksService(t *testing.T, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", tasks.NewService)
|
||||
stubGoogleTestService(t, &newTasksService, svc)
|
||||
orig := newTasksService
|
||||
t.Cleanup(func() { newTasksService = orig })
|
||||
|
||||
svc, err := tasks.NewService(context.Background(),
|
||||
option.WithoutAuthentication(),
|
||||
option.WithHTTPClient(srv.Client()),
|
||||
option.WithEndpoint(srv.URL+"/"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil }
|
||||
}
|
||||
|
||||
func fullTaskResponse(id string) map[string]any {
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
admin "google.golang.org/api/admin/directory/v1"
|
||||
|
||||
@ -11,5 +12,11 @@ import (
|
||||
// NewAdminDirectory creates an Admin SDK Directory service for user and group management.
|
||||
// This API requires domain-wide delegation with a service account to manage Workspace users.
|
||||
func NewAdminDirectory(ctx context.Context, email string) (*admin.Service, error) {
|
||||
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceAdmin, "admin directory", admin.NewService)
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceAdmin, email); err != nil {
|
||||
return nil, fmt.Errorf("admin directory options: %w", err)
|
||||
} else if svc, err := admin.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create admin directory service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/script/v1"
|
||||
|
||||
@ -9,5 +10,11 @@ import (
|
||||
)
|
||||
|
||||
func NewAppScript(ctx context.Context, email string) (*script.Service, error) {
|
||||
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceAppScript, "appscript", script.NewService)
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceAppScript, email); err != nil {
|
||||
return nil, fmt.Errorf("appscript options: %w", err)
|
||||
} else if svc, err := script.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create appscript service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
|
||||
@ -9,5 +10,11 @@ import (
|
||||
)
|
||||
|
||||
func NewCalendar(ctx context.Context, email string) (*calendar.Service, error) {
|
||||
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceCalendar, "calendar", calendar.NewService)
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceCalendar, email); err != nil {
|
||||
return nil, fmt.Errorf("calendar options: %w", err)
|
||||
} else if svc, err := calendar.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create calendar service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/chat/v1"
|
||||
)
|
||||
@ -16,5 +17,11 @@ const (
|
||||
)
|
||||
|
||||
func NewChat(ctx context.Context, email string) (*chat.Service, error) {
|
||||
return newGoogleServiceForScopes(ctx, email, "chat", "chat", []string{scopeChatSpaces, scopeChatMessages, scopeChatMemberships, scopeChatReadStateRO, scopeChatReactionsCreate, scopeChatReactionsRO}, chat.NewService)
|
||||
if opts, err := optionsForAccountScopes(ctx, "chat", email, []string{scopeChatSpaces, scopeChatMessages, scopeChatMemberships, scopeChatReadStateRO, scopeChatReactionsCreate, scopeChatReactionsRO}); err != nil {
|
||||
return nil, fmt.Errorf("chat options: %w", err)
|
||||
} else if svc, err := chat.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create chat service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/classroom/v1"
|
||||
|
||||
@ -9,5 +10,11 @@ import (
|
||||
)
|
||||
|
||||
func NewClassroom(ctx context.Context, email string) (*classroom.Service, error) {
|
||||
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceClassroom, "classroom", classroom.NewService)
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceClassroom, email); err != nil {
|
||||
return nil, fmt.Errorf("classroom options: %w", err)
|
||||
} else if svc, err := classroom.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create classroom service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,53 +41,6 @@ func optionsForAccount(ctx context.Context, service googleauth.Service, email st
|
||||
return optionsForAccountScopes(ctx, string(service), email, scopes)
|
||||
}
|
||||
|
||||
type googleServiceFactory[T any] func(context.Context, ...option.ClientOption) (*T, error)
|
||||
|
||||
func newGoogleServiceForAccount[T any](
|
||||
ctx context.Context,
|
||||
email string,
|
||||
service googleauth.Service,
|
||||
label string,
|
||||
factory googleServiceFactory[T],
|
||||
) (*T, error) {
|
||||
opts, err := optionsForAccount(ctx, service, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s options: %w", label, err)
|
||||
}
|
||||
|
||||
return newGoogleService(ctx, label, opts, factory)
|
||||
}
|
||||
|
||||
func newGoogleServiceForScopes[T any](
|
||||
ctx context.Context,
|
||||
email string,
|
||||
serviceLabel string,
|
||||
errorLabel string,
|
||||
scopes []string,
|
||||
factory googleServiceFactory[T],
|
||||
) (*T, error) {
|
||||
opts, err := optionsForAccountScopes(ctx, serviceLabel, email, scopes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s options: %w", errorLabel, err)
|
||||
}
|
||||
|
||||
return newGoogleService(ctx, errorLabel, opts, factory)
|
||||
}
|
||||
|
||||
func newGoogleService[T any](
|
||||
ctx context.Context,
|
||||
label string,
|
||||
opts []option.ClientOption,
|
||||
factory googleServiceFactory[T],
|
||||
) (*T, error) {
|
||||
svc, err := factory(ctx, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create %s service: %w", label, err)
|
||||
}
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// IsADCMode reports whether Application Default Credentials mode is active.
|
||||
// When GOG_AUTH_MODE=adc, the CLI authenticates using the ambient credentials
|
||||
// (e.g. GKE Workload Identity, GOOGLE_APPLICATION_CREDENTIALS, or gcloud ADC)
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/cloudidentity/v1"
|
||||
)
|
||||
@ -13,5 +14,11 @@ const (
|
||||
// NewCloudIdentityGroups creates a Cloud Identity service for reading groups.
|
||||
// This API allows non-admin users to list groups they belong to and view group members.
|
||||
func NewCloudIdentityGroups(ctx context.Context, email string) (*cloudidentity.Service, error) {
|
||||
return newGoogleServiceForScopes(ctx, email, "cloudidentity", "cloudidentity", []string{scopeCloudIdentityGroupsRO}, cloudidentity.NewService)
|
||||
if opts, err := optionsForAccountScopes(ctx, "cloudidentity", email, []string{scopeCloudIdentityGroupsRO}); err != nil {
|
||||
return nil, fmt.Errorf("cloudidentity options: %w", err)
|
||||
} else if svc, err := cloudidentity.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create cloudidentity service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/docs/v1"
|
||||
|
||||
@ -9,5 +10,11 @@ import (
|
||||
)
|
||||
|
||||
func NewDocs(ctx context.Context, email string) (*docs.Service, error) {
|
||||
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceDocs, "docs", docs.NewService)
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceDocs, email); err != nil {
|
||||
return nil, fmt.Errorf("docs options: %w", err)
|
||||
} else if svc, err := docs.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create docs service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/drive/v3"
|
||||
|
||||
@ -9,5 +10,11 @@ import (
|
||||
)
|
||||
|
||||
func NewDrive(ctx context.Context, email string) (*drive.Service, error) {
|
||||
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceDrive, "drive", drive.NewService)
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceDrive, email); err != nil {
|
||||
return nil, fmt.Errorf("drive options: %w", err)
|
||||
} else if svc, err := drive.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create drive service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/forms/v1"
|
||||
|
||||
@ -9,5 +10,11 @@ import (
|
||||
)
|
||||
|
||||
func NewForms(ctx context.Context, email string) (*forms.Service, error) {
|
||||
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceForms, "forms", forms.NewService)
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceForms, email); err != nil {
|
||||
return nil, fmt.Errorf("forms options: %w", err)
|
||||
} else if svc, err := forms.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create forms service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
|
||||
@ -9,5 +10,11 @@ import (
|
||||
)
|
||||
|
||||
func NewGmail(ctx context.Context, email string) (*gmail.Service, error) {
|
||||
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceGmail, "gmail", gmail.NewService)
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceGmail, email); err != nil {
|
||||
return nil, fmt.Errorf("gmail options: %w", err)
|
||||
} else if svc, err := gmail.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create gmail service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,13 @@ import (
|
||||
)
|
||||
|
||||
func NewKeep(ctx context.Context, email string) (*keep.Service, error) {
|
||||
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceKeep, "keep", keep.NewService)
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceKeep, email); err != nil {
|
||||
return nil, fmt.Errorf("keep options: %w", err)
|
||||
} else if svc, err := keep.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create keep service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewKeepWithServiceAccount(ctx context.Context, serviceAccountPath, impersonateEmail string) (*keep.Service, error) {
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/people/v1"
|
||||
)
|
||||
@ -13,13 +14,31 @@ const (
|
||||
)
|
||||
|
||||
func NewPeopleContacts(ctx context.Context, email string) (*people.Service, error) {
|
||||
return newGoogleServiceForScopes(ctx, email, "contacts", "contacts", []string{scopeContactsWrite}, people.NewService)
|
||||
if opts, err := optionsForAccountScopes(ctx, "contacts", email, []string{scopeContactsWrite}); err != nil {
|
||||
return nil, fmt.Errorf("contacts options: %w", err)
|
||||
} else if svc, err := people.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create contacts service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewPeopleOtherContacts(ctx context.Context, email string) (*people.Service, error) {
|
||||
return newGoogleServiceForScopes(ctx, email, "contacts", "contacts", []string{scopeContactsOtherRO}, people.NewService)
|
||||
if opts, err := optionsForAccountScopes(ctx, "contacts", email, []string{scopeContactsOtherRO}); err != nil {
|
||||
return nil, fmt.Errorf("contacts options: %w", err)
|
||||
} else if svc, err := people.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create contacts service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewPeopleDirectory(ctx context.Context, email string) (*people.Service, error) {
|
||||
return newGoogleServiceForScopes(ctx, email, "contacts", "contacts", []string{scopeDirectoryRO}, people.NewService)
|
||||
if opts, err := optionsForAccountScopes(ctx, "contacts", email, []string{scopeDirectoryRO}); err != nil {
|
||||
return nil, fmt.Errorf("contacts options: %w", err)
|
||||
} else if svc, err := people.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create contacts service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"google.golang.org/api/sheets/v4"
|
||||
@ -12,10 +13,15 @@ import (
|
||||
func NewSheets(ctx context.Context, email string) (*sheets.Service, error) {
|
||||
slog.Debug("creating sheets service", "email", email)
|
||||
|
||||
svc, err := newGoogleServiceForAccount(ctx, email, googleauth.ServiceSheets, "sheets", sheets.NewService)
|
||||
opts, err := optionsForAccount(ctx, googleauth.ServiceSheets, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sheets options: %w", err)
|
||||
}
|
||||
|
||||
svc, err := sheets.NewService(ctx, opts...)
|
||||
if err != nil {
|
||||
slog.Error("failed to create sheets service", "email", email, "error", err)
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("create sheets service: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("sheets service created successfully", "email", email)
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/slides/v1"
|
||||
|
||||
@ -9,5 +10,11 @@ import (
|
||||
)
|
||||
|
||||
func NewSlides(ctx context.Context, email string) (*slides.Service, error) {
|
||||
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceSlides, "slides", slides.NewService)
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceSlides, email); err != nil {
|
||||
return nil, fmt.Errorf("slides options: %w", err)
|
||||
} else if svc, err := slides.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create slides service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/api/tasks/v1"
|
||||
|
||||
@ -9,5 +10,11 @@ import (
|
||||
)
|
||||
|
||||
func NewTasks(ctx context.Context, email string) (*tasks.Service, error) {
|
||||
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceTasks, "tasks", tasks.NewService)
|
||||
if opts, err := optionsForAccount(ctx, googleauth.ServiceTasks, email); err != nil {
|
||||
return nil, fmt.Errorf("tasks options: %w", err)
|
||||
} else if svc, err := tasks.NewService(ctx, opts...); err != nil {
|
||||
return nil, fmt.Errorf("create tasks service: %w", err)
|
||||
} else {
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,139 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const bin = process.env.GOG_BIN || path.join(root, "bin", "gog");
|
||||
const docsDir = path.join(root, "docs");
|
||||
const commandsDir = path.join(docsDir, "commands");
|
||||
|
||||
const requiredFeatureDocs = [
|
||||
"install.md",
|
||||
"quickstart.md",
|
||||
"auth-clients.md",
|
||||
"safety-profiles.md",
|
||||
"raw-api.md",
|
||||
"raw-audit.md",
|
||||
"gmail-workflows.md",
|
||||
"watch.md",
|
||||
"email-tracking.md",
|
||||
"drive-audits.md",
|
||||
"contacts-dedupe.md",
|
||||
"contacts-json-update.md",
|
||||
"docs-editing.md",
|
||||
"sheets-tables.md",
|
||||
"sheets-formatting.md",
|
||||
"slides-markdown.md",
|
||||
"slides-template-replacement.md",
|
||||
"backup.md",
|
||||
"dates.md",
|
||||
];
|
||||
|
||||
const schema = JSON.parse(execFileSync(bin, ["schema", "--json"], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }));
|
||||
const commands = Array.from(walk(schema.command || {}));
|
||||
const seenSlugs = new Set();
|
||||
const missingCommandPages = [];
|
||||
|
||||
for (const command of commands) {
|
||||
const base = commandSlug(command);
|
||||
let slug = base;
|
||||
let suffix = 2;
|
||||
while (seenSlugs.has(slug)) {
|
||||
slug = `${base}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
seenSlugs.add(slug);
|
||||
|
||||
const page = path.join(commandsDir, `${slug}.md`);
|
||||
if (!fs.existsSync(page)) {
|
||||
missingCommandPages.push(path.relative(root, page));
|
||||
}
|
||||
}
|
||||
|
||||
const navSourcePath = path.join(root, "scripts", "build-docs-site.mjs");
|
||||
const navSource = fs.readFileSync(navSourcePath, "utf8");
|
||||
const missingFeaturePages = [];
|
||||
const unlinkedFeaturePages = [];
|
||||
const brokenLinks = checkMarkdownLinks(docsDir);
|
||||
|
||||
for (const rel of requiredFeatureDocs) {
|
||||
const page = path.join(docsDir, rel);
|
||||
if (!fs.existsSync(page)) {
|
||||
missingFeaturePages.push(`docs/${rel}`);
|
||||
continue;
|
||||
}
|
||||
if (!navSource.includes(`"${rel}"`)) {
|
||||
unlinkedFeaturePages.push(`docs/${rel}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingCommandPages.length || missingFeaturePages.length || unlinkedFeaturePages.length || brokenLinks.length) {
|
||||
for (const name of missingCommandPages) console.error(`missing command doc: ${name}`);
|
||||
for (const name of missingFeaturePages) console.error(`missing feature doc: ${name}`);
|
||||
for (const name of unlinkedFeaturePages) console.error(`feature doc not in scripts/build-docs-site.mjs sidebar: ${name}`);
|
||||
for (const item of brokenLinks) console.error(`broken docs link: ${item}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`docs coverage ok: ${commands.length} command pages, ${requiredFeatureDocs.length} feature pages`);
|
||||
|
||||
function* walk(command) {
|
||||
yield command;
|
||||
for (const child of command.subcommands || []) {
|
||||
yield* walk(child);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTokens(commandPath) {
|
||||
return (commandPath || "")
|
||||
.split(/\s+/)
|
||||
.filter((part) => part && !(part.startsWith("(") && part.endsWith(")")));
|
||||
}
|
||||
|
||||
function canonicalPath(command) {
|
||||
return canonicalTokens(command.path || command.name || "").join(" ");
|
||||
}
|
||||
|
||||
function commandSlug(command) {
|
||||
const slug = canonicalPath(command)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "gog";
|
||||
}
|
||||
|
||||
function checkMarkdownLinks(dir) {
|
||||
const broken = [];
|
||||
for (const file of allMarkdown(dir)) {
|
||||
const markdown = fs.readFileSync(file, "utf8");
|
||||
const linkPattern = /!?\[[^\]]*\]\(([^)]+)\)/g;
|
||||
let match;
|
||||
while ((match = linkPattern.exec(markdown)) !== null) {
|
||||
const rawTarget = match[1].trim().replace(/^<|>$/g, "");
|
||||
if (!rawTarget || rawTarget.startsWith("#")) continue;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(rawTarget)) continue;
|
||||
|
||||
const targetWithoutTitle = rawTarget.split(/\s+["'][^"']*["']\s*$/)[0];
|
||||
const targetPath = targetWithoutTitle.split("#")[0];
|
||||
if (!targetPath) continue;
|
||||
if (/^(url|path|file)$/i.test(targetPath)) continue;
|
||||
|
||||
const resolved = path.resolve(path.dirname(file), targetPath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
broken.push(`${path.relative(root, file)} -> ${targetPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return broken;
|
||||
}
|
||||
|
||||
function allMarkdown(dir) {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return allMarkdown(full);
|
||||
return entry.name.endsWith(".md") ? [full] : [];
|
||||
});
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BIN="${1:-}"
|
||||
if [[ -z "$BIN" ]]; then
|
||||
echo "usage: $0 <path-to-binary>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IDENTITY="${GOG_CODESIGN_IDENTITY:-${CODESIGN_IDENTITY:-}}"
|
||||
if [[ -z "$IDENTITY" ]]; then
|
||||
echo "codesign: skipped (set GOG_CODESIGN_IDENTITY or CODESIGN_IDENTITY)" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ID="com.steipete.gogcli.gog"
|
||||
|
||||
codesign --force --sign "$IDENTITY" --timestamp --options runtime --identifier "$ID" "$BIN"
|
||||
codesign --verify --deep --strict --verbose=2 "$BIN"
|
||||
@ -1,291 +0,0 @@
|
||||
export function css() {
|
||||
return `
|
||||
:root{
|
||||
--ink:#0f1115;
|
||||
--text:#1f2328;
|
||||
--muted:#6b7280;
|
||||
--subtle:#9aa1ab;
|
||||
--bg:#fafafa;
|
||||
--paper:#ffffff;
|
||||
--accent:#1a73e8;
|
||||
--accent-soft:rgba(26,115,232,.09);
|
||||
--accent-strong:#1558b9;
|
||||
--g-blue:#4285f4;--g-red:#ea4335;--g-yellow:#fbbc04;--g-green:#34a853;
|
||||
--line:#e5e7eb;
|
||||
--line-soft:#eef0f3;
|
||||
--code-bg:#0f172a;
|
||||
--code-fg:#e6edf3;
|
||||
--code-inline-fg:#1c2128;
|
||||
--code-border:#1f2937;
|
||||
--pill-border:#dbe2eb;
|
||||
--shadow-card:0 4px 14px rgba(15,17,21,.08);
|
||||
--scrollbar:#cbd5e1;
|
||||
--hl-keyword:#7aa2ff;
|
||||
--hl-string:#9ece6a;
|
||||
--hl-number:#e0a96d;
|
||||
--hl-comment:#7c8597;
|
||||
--hl-flag:#c4a4ff;
|
||||
--hl-meta:#f08aa0;
|
||||
--hl-prompt:#64748b;
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--ink:#f3f5f9;
|
||||
--text:#cbd2dc;
|
||||
--muted:#8d96a4;
|
||||
--subtle:#5d6371;
|
||||
--bg:#0c0e14;
|
||||
--paper:#171a23;
|
||||
--accent:#5294ff;
|
||||
--accent-soft:rgba(82,148,255,.16);
|
||||
--accent-strong:#7caeff;
|
||||
--line:#262a36;
|
||||
--line-soft:#1d2029;
|
||||
--code-bg:#06080d;
|
||||
--code-fg:#e6edf3;
|
||||
--code-inline-fg:#e6edf3;
|
||||
--code-border:#1c2030;
|
||||
--pill-border:#2a2f3c;
|
||||
--shadow-card:0 4px 18px rgba(0,0,0,.45);
|
||||
--scrollbar:#3a4154;
|
||||
--hl-keyword:#7aa2ff;
|
||||
--hl-string:#a6e3a1;
|
||||
--hl-number:#f0a868;
|
||||
--hl-comment:#6b7388;
|
||||
--hl-flag:#c4a4ff;
|
||||
--hl-meta:#ff8aa0;
|
||||
--hl-prompt:#7e8ba3;
|
||||
}
|
||||
:root{color-scheme:light}
|
||||
:root[data-theme="dark"]{color-scheme:dark}
|
||||
*{box-sizing:border-box}
|
||||
html{scroll-behavior:smooth;scroll-padding-top:24px}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:"Inter",ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;line-height:1.65;overflow-x:hidden;-webkit-font-smoothing:antialiased;font-feature-settings:"cv02","cv03","cv04","cv11";transition:background-color .18s,color .18s}
|
||||
::selection{background:var(--accent);color:#fff}
|
||||
a{color:var(--accent);text-decoration:none;transition:color .12s}
|
||||
a:hover{text-decoration:underline;text-underline-offset:.2em}
|
||||
.shell{display:grid;grid-template-columns:268px minmax(0,1fr);min-height:100vh}
|
||||
.sidebar{position:sticky;top:0;height:100vh;overflow:auto;padding:24px 22px;background:var(--paper);border-right:1px solid var(--line);scrollbar-width:thin;scrollbar-color:var(--line) transparent;transition:background-color .18s,border-color .18s}
|
||||
.sidebar::-webkit-scrollbar{width:6px}
|
||||
.sidebar::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
|
||||
.sidebar-head{display:flex;align-items:center;gap:10px;margin-bottom:24px}
|
||||
.brand{display:flex;align-items:center;gap:11px;color:var(--ink);text-decoration:none;flex:1;min-width:0}
|
||||
.brand:hover{text-decoration:none}
|
||||
.brand .mark{display:grid;grid-template-columns:repeat(2,12px);grid-template-rows:repeat(2,12px);gap:3px;flex:0 0 27px}
|
||||
.brand .mark i{display:block;border-radius:3px}
|
||||
.brand .mark i:nth-child(1){background:var(--g-blue)}
|
||||
.brand .mark i:nth-child(2){background:var(--g-red)}
|
||||
.brand .mark i:nth-child(3){background:var(--g-yellow)}
|
||||
.brand .mark i:nth-child(4){background:var(--g-green)}
|
||||
.brand strong{display:block;font-size:1.05rem;line-height:1.1;font-weight:600;letter-spacing:0;color:var(--ink)}
|
||||
.brand small{display:block;color:var(--muted);font-size:.74rem;margin-top:3px;font-weight:400}
|
||||
.theme-toggle{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:34px;height:34px;border-radius:8px;border:1px solid var(--line);background:var(--paper);color:var(--muted);cursor:pointer;padding:0;transition:border-color .15s,color .15s,background-color .15s,transform .12s}
|
||||
.theme-toggle:hover{border-color:var(--ink);color:var(--ink)}
|
||||
.theme-toggle:active{transform:scale(.94)}
|
||||
.theme-toggle svg{width:16px;height:16px;display:block}
|
||||
.theme-icon-sun{display:none}
|
||||
:root[data-theme="dark"] .theme-icon-sun{display:block}
|
||||
:root[data-theme="dark"] .theme-icon-moon{display:none}
|
||||
.search{display:block;margin:0 0 22px}
|
||||
.search span{display:block;color:var(--muted);font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0;margin-bottom:7px}
|
||||
.search input{width:100%;border:1px solid var(--line);background:var(--paper);border-radius:8px;padding:9px 12px;font:inherit;font-size:.9rem;color:var(--text);outline:none;transition:border-color .15s,box-shadow .15s,background-color .18s}
|
||||
.search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||
nav section{margin:0 0 18px}
|
||||
nav h2{font-size:.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:0;margin:0 0 6px;font-weight:600}
|
||||
.nav-link{display:block;color:var(--text);text-decoration:none;border-radius:6px;padding:5px 10px;margin:1px 0;font-size:.9rem;line-height:1.4;transition:background .12s,color .12s}
|
||||
.nav-link:hover{background:var(--line-soft);color:var(--ink);text-decoration:none}
|
||||
.nav-link.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
|
||||
main{min-width:0;padding:32px clamp(20px,4.5vw,56px) 80px;max-width:1180px;margin:0 auto;width:100%}
|
||||
.hero{display:flex;align-items:flex-end;justify-content:space-between;gap:22px;border-bottom:1px solid var(--line);padding:8px 0 22px;margin-bottom:8px;flex-wrap:wrap}
|
||||
.hero-text{min-width:0;flex:1 1 320px}
|
||||
.eyebrow{margin:0 0 8px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:0;font-size:.7rem}
|
||||
.hero h1{font-size:2.25rem;line-height:1.1;letter-spacing:0;margin:0;font-weight:700;color:var(--ink)}
|
||||
.hero-meta{display:flex;gap:8px;flex:0 0 auto;flex-wrap:wrap}
|
||||
.repo,.edit,.btn-ghost{border:1px solid var(--line);color:var(--text);text-decoration:none;border-radius:7px;padding:6px 11px;font-weight:500;font-size:.83rem;background:var(--paper);transition:border-color .15s,color .15s,background .15s}
|
||||
.repo:hover,.edit:hover,.btn-ghost:hover{border-color:var(--ink);color:var(--ink);text-decoration:none}
|
||||
.edit{color:var(--muted)}
|
||||
.home-hero{padding:14px 0 28px;margin-bottom:8px;border-bottom:1px solid var(--line)}
|
||||
.home-hero h1{font-size:3.25rem;line-height:1.04;letter-spacing:0;margin:0 0 .35em;font-weight:700;color:var(--ink)}
|
||||
.home-hero .lede{font-size:1.18rem;line-height:1.55;color:var(--text);margin:0 0 1.2em;max-width:60ch}
|
||||
.home-cta{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:0 0 18px}
|
||||
.home-cta .btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;padding:10px 16px;font-weight:600;font-size:.92rem;text-decoration:none;transition:background .15s,border-color .15s,color .15s,transform .12s}
|
||||
.home-cta .btn-primary{background:var(--accent);color:#fff;border:1px solid var(--accent)}
|
||||
.home-cta .btn-primary:hover{background:var(--accent-strong);border-color:var(--accent-strong);text-decoration:none}
|
||||
.home-cta .btn-ghost{padding:10px 16px}
|
||||
.home-install{display:flex;align-items:center;gap:12px;background:var(--code-bg);color:var(--code-fg);border-radius:8px;padding:10px 10px 10px 16px;font:500 .9rem/1.2 "JetBrains Mono","SF Mono",ui-monospace,monospace;max-width:32em;border:1px solid #1f2937}
|
||||
.home-install .prompt{color:#64748b;user-select:none;flex:0 0 auto}
|
||||
.home-install code{flex:1;background:transparent;border:0;color:var(--code-fg);font:inherit;padding:0;white-space:pre;overflow:hidden;text-overflow:ellipsis}
|
||||
.home-install .copy{flex:0 0 auto;background:rgba(255,255,255,.08);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:5px 11px;font:500 .72rem/1 "Inter",sans-serif;cursor:pointer;transition:background .15s,border-color .15s}
|
||||
.home-install .copy:hover{background:rgba(255,255,255,.16)}
|
||||
.home-install .copy.copied{background:var(--accent);border-color:var(--accent)}
|
||||
.home-services{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 18px}
|
||||
.home-services span{display:inline-block;padding:3px 9px;border:1px solid var(--line);border-radius:999px;font-size:.78rem;color:var(--muted);background:var(--paper)}
|
||||
.doc-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:48px;margin-top:24px}
|
||||
.doc-grid-home{margin-top:8px}
|
||||
@media(min-width:1180px){.doc-grid{grid-template-columns:minmax(0,72ch) 200px;justify-content:start}.doc-grid-home{grid-template-columns:minmax(0,76ch);justify-content:start}}
|
||||
.doc{min-width:0;max-width:72ch;overflow-wrap:break-word}
|
||||
.doc-home{max-width:76ch}
|
||||
.doc h1{font-size:2.6rem;line-height:1.08;letter-spacing:0;margin:0 0 .4em;font-weight:700;color:var(--ink)}
|
||||
body:not(.home) .doc>h1:first-child{display:none}
|
||||
.doc h2{font-size:1.45rem;line-height:1.2;margin:2em 0 .5em;font-weight:600;letter-spacing:0;color:var(--ink);position:relative}
|
||||
.doc h3{font-size:1.1rem;margin:1.7em 0 .35em;position:relative;font-weight:600;color:var(--ink);letter-spacing:0}
|
||||
.doc h4{font-size:.98rem;margin:1.4em 0 .25em;color:var(--ink);position:relative;font-weight:600}
|
||||
.doc h2:first-child,.doc h3:first-child,.doc h4:first-child{margin-top:.2em}
|
||||
.doc :is(h2,h3,h4) .anchor{position:absolute;left:-1.05em;top:0;color:var(--subtle);opacity:0;text-decoration:none;font-weight:400;padding-right:.3em;transition:opacity .12s,color .12s}
|
||||
.doc :is(h2,h3,h4):hover .anchor{opacity:.7}
|
||||
.doc :is(h2,h3,h4) .anchor:hover{opacity:1;color:var(--accent);text-decoration:none}
|
||||
.doc p{margin:0 0 1.05em}
|
||||
.doc ul,.doc ol{padding-left:1.3rem;margin:0 0 1.15em}
|
||||
.doc li{margin:.25em 0}
|
||||
.doc li>p{margin:0 0 .4em}
|
||||
.doc strong{font-weight:600;color:var(--ink)}
|
||||
.doc em{font-style:italic}
|
||||
.doc code{font-family:"JetBrains Mono","SF Mono",ui-monospace,monospace;font-size:.84em;background:var(--line-soft);border:1px solid var(--line);border-radius:5px;padding:.08em .35em;color:var(--code-inline-fg)}
|
||||
.doc pre{position:relative;overflow:auto;background:var(--code-bg);color:var(--code-fg);border-radius:8px;padding:14px 18px;margin:1.3em 0;font-size:.85em;line-height:1.6;scrollbar-width:thin;scrollbar-color:#334155 transparent;border:1px solid var(--code-border)}
|
||||
.doc pre::-webkit-scrollbar{height:8px;width:8px}
|
||||
.doc pre::-webkit-scrollbar-thumb{background:#334155;border-radius:8px}
|
||||
.doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre}
|
||||
.doc pre .copy{position:absolute;top:8px;right:8px;background:rgba(255,255,255,.06);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:3px 9px;font:500 .7rem/1 "Inter",sans-serif;cursor:pointer;opacity:0;transition:opacity .15s,background .15s,border-color .15s}
|
||||
.doc pre:hover .copy,.doc pre .copy:focus{opacity:1}
|
||||
.doc pre .copy:hover{background:rgba(255,255,255,.12)}
|
||||
.doc pre .copy.copied{background:var(--accent);border-color:var(--accent);opacity:1}
|
||||
.doc pre .hl-c{color:var(--hl-comment);font-style:italic}
|
||||
.doc pre .hl-s{color:var(--hl-string)}
|
||||
.doc pre .hl-n{color:var(--hl-number)}
|
||||
.doc pre .hl-k{color:var(--hl-keyword);font-weight:600}
|
||||
.doc pre .hl-f{color:var(--hl-flag)}
|
||||
.doc pre .hl-m{color:var(--hl-meta);font-weight:600}
|
||||
.doc pre .hl-p{color:var(--hl-prompt);user-select:none}
|
||||
.doc pre .hl-cmd{color:var(--hl-keyword);font-weight:600}
|
||||
.doc blockquote{margin:1.4em 0;padding:10px 16px;border-left:3px solid var(--accent);background:var(--accent-soft);border-radius:0 8px 8px 0;color:var(--text)}
|
||||
.doc blockquote p:last-child{margin-bottom:0}
|
||||
.doc table{width:100%;border-collapse:collapse;margin:1.2em 0;font-size:.92em}
|
||||
.doc th,.doc td{border-bottom:1px solid var(--line);padding:9px 10px;text-align:left;vertical-align:top}
|
||||
.doc th{font-weight:600;color:var(--ink);background:var(--line-soft);border-bottom:1px solid var(--line)}
|
||||
.doc hr{border:0;border-top:1px solid var(--line);margin:2.2em 0}
|
||||
.toc{position:sticky;top:24px;align-self:start;font-size:.84rem;padding-left:14px;border-left:1px solid var(--line);max-height:calc(100vh - 48px);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
|
||||
.toc::-webkit-scrollbar{width:5px}
|
||||
.toc::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
|
||||
.toc h2{font-size:.66rem;color:var(--muted);text-transform:uppercase;letter-spacing:0;margin:0 0 10px;font-weight:600}
|
||||
.toc a{display:block;color:var(--muted);text-decoration:none;padding:4px 0 4px 10px;line-height:1.35;border-left:2px solid transparent;margin-left:-12px;transition:color .12s,border-color .12s}
|
||||
.toc a:hover{color:var(--ink);text-decoration:none}
|
||||
.toc a.active{color:var(--accent);border-left-color:var(--accent);font-weight:500}
|
||||
.toc-l3{padding-left:22px!important;font-size:.94em}
|
||||
@media(max-width:1179px){.toc{display:none}}
|
||||
.page-nav{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:48px;border-top:1px solid var(--line);padding-top:20px}
|
||||
.page-nav>a{display:block;border:1px solid var(--line);background:var(--paper);border-radius:9px;padding:13px 16px;text-decoration:none;color:var(--text);transition:border-color .15s,transform .15s,box-shadow .15s,background-color .18s}
|
||||
.page-nav>a:hover{border-color:var(--accent);text-decoration:none;color:var(--ink)}
|
||||
.page-nav small{display:block;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:0;margin-bottom:5px;font-weight:600}
|
||||
.page-nav span{display:block;font-weight:600;line-height:1.3;color:var(--ink)}
|
||||
.page-nav-prev{text-align:left}
|
||||
.page-nav-next{text-align:right;grid-column:2}
|
||||
.page-nav-prev:only-child{grid-column:1}
|
||||
.nav-toggle{display:none;position:fixed;top:14px;right:14px;top:calc(14px + env(safe-area-inset-top, 0px));right:calc(14px + env(safe-area-inset-right, 0px));z-index:20;width:40px;height:40px;border-radius:9px;background:var(--paper);border:1px solid var(--line);color:var(--ink);cursor:pointer;padding:10px 9px;flex-direction:column;align-items:stretch;justify-content:space-between;box-shadow:var(--shadow-card)}
|
||||
.nav-toggle span{display:block;width:100%;height:2px;flex:0 0 2px;background:currentColor;border-radius:2px;transition:transform .2s,opacity .2s}
|
||||
.nav-toggle[aria-expanded="true"] span:nth-child(1){transform:translateY(8px) rotate(45deg)}
|
||||
.nav-toggle[aria-expanded="true"] span:nth-child(2){opacity:0}
|
||||
.nav-toggle[aria-expanded="true"] span:nth-child(3){transform:translateY(-8px) rotate(-45deg)}
|
||||
@media(max-width:900px){
|
||||
.shell{display:block}
|
||||
.sidebar{position:fixed;inset:0 30% 0 0;max-width:320px;height:100vh;z-index:15;transform:translateX(-100%);transition:transform .25s ease,background-color .18s,border-color .18s;box-shadow:0 18px 40px rgba(0,0,0,.18);background:var(--paper);pointer-events:none}
|
||||
.sidebar.open{transform:translateX(0);pointer-events:auto}
|
||||
.nav-toggle{display:flex}
|
||||
main{padding:64px 18px 56px}
|
||||
.hero{padding-top:6px}
|
||||
.hero h1{font-size:1.8rem}
|
||||
.home-hero h1{font-size:2.45rem}
|
||||
.doc h1{font-size:2.1rem}
|
||||
.hero-meta{width:100%;justify-content:flex-start}
|
||||
.home-hero{padding-top:8px}
|
||||
.doc{padding:0}
|
||||
.doc-grid{margin-top:18px;gap:24px}
|
||||
.doc :is(h2,h3,h4) .anchor{display:none}
|
||||
}
|
||||
@media(max-width:520px){
|
||||
main{padding:60px 14px 48px}
|
||||
.doc pre{margin-left:-14px;margin-right:-14px;border-radius:0;border-left:0;border-right:0}
|
||||
.home-install{flex-wrap:wrap}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function js() {
|
||||
return `
|
||||
const themeRoot=document.documentElement;
|
||||
function applyTheme(mode){themeRoot.dataset.theme=mode;document.querySelectorAll('[data-theme-toggle]').forEach(b=>b.setAttribute('aria-pressed',mode==='dark'?'true':'false'))}
|
||||
function storedTheme(){try{return localStorage.getItem('theme')}catch(e){return null}}
|
||||
function persistTheme(mode){try{localStorage.setItem('theme',mode)}catch(e){}}
|
||||
applyTheme(themeRoot.dataset.theme==='dark'?'dark':'light');
|
||||
document.querySelectorAll('[data-theme-toggle]').forEach(btn=>{btn.addEventListener('click',()=>{const next=themeRoot.dataset.theme==='dark'?'light':'dark';applyTheme(next);persistTheme(next)})});
|
||||
const systemDark=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)');
|
||||
function onSystemChange(e){if(storedTheme())return;applyTheme(e.matches?'dark':'light')}
|
||||
if(systemDark){if(systemDark.addEventListener)systemDark.addEventListener('change',onSystemChange);else if(systemDark.addListener)systemDark.addListener(onSystemChange)}
|
||||
const sidebar=document.querySelector('.sidebar');
|
||||
const toggle=document.querySelector('.nav-toggle');
|
||||
const mobileNav=window.matchMedia('(max-width: 900px)');
|
||||
const sidebarFocusable='a[href],button,input,select,textarea,[tabindex]';
|
||||
function setSidebarFocusable(enabled){
|
||||
sidebar?.querySelectorAll(sidebarFocusable).forEach((el)=>{
|
||||
if(enabled){
|
||||
if(el.dataset.sidebarTabindex!==undefined){
|
||||
if(el.dataset.sidebarTabindex)el.setAttribute('tabindex',el.dataset.sidebarTabindex);
|
||||
else el.removeAttribute('tabindex');
|
||||
delete el.dataset.sidebarTabindex;
|
||||
}
|
||||
}else if(el.dataset.sidebarTabindex===undefined){
|
||||
el.dataset.sidebarTabindex=el.getAttribute('tabindex')??'';
|
||||
el.setAttribute('tabindex','-1');
|
||||
}
|
||||
});
|
||||
}
|
||||
function setSidebarOpen(open){
|
||||
if(!sidebar||!toggle)return;
|
||||
sidebar.classList.toggle('open',open);
|
||||
toggle.setAttribute('aria-expanded',open?'true':'false');
|
||||
if(mobileNav.matches){
|
||||
sidebar.inert=!open;
|
||||
if(open)sidebar.removeAttribute('aria-hidden');
|
||||
else sidebar.setAttribute('aria-hidden','true');
|
||||
setSidebarFocusable(open);
|
||||
}else{
|
||||
sidebar.inert=false;
|
||||
sidebar.removeAttribute('aria-hidden');
|
||||
setSidebarFocusable(true);
|
||||
}
|
||||
}
|
||||
setSidebarOpen(false);
|
||||
toggle?.addEventListener('click',()=>setSidebarOpen(!sidebar?.classList.contains('open')));
|
||||
document.addEventListener('click',(e)=>{if(!sidebar?.classList.contains('open'))return;if(sidebar.contains(e.target)||toggle?.contains(e.target))return;setSidebarOpen(false)});
|
||||
document.addEventListener('keydown',(e)=>{if(e.key==='Escape')setSidebarOpen(false)});
|
||||
const syncSidebarForViewport=()=>setSidebarOpen(sidebar?.classList.contains('open')??false);
|
||||
if(mobileNav.addEventListener)mobileNav.addEventListener('change',syncSidebarForViewport);
|
||||
else mobileNav.addListener?.(syncSidebarForViewport);
|
||||
const input=document.getElementById('doc-search');
|
||||
input?.addEventListener('input',()=>{const q=input.value.trim().toLowerCase();document.querySelectorAll('nav section').forEach(sec=>{let any=false;sec.querySelectorAll('.nav-link').forEach(a=>{const m=!q||a.textContent.toLowerCase().includes(q);a.style.display=m?'block':'none';if(m)any=true});sec.style.display=any?'block':'none'})});
|
||||
function attachCopy(target,getText){const btn=document.createElement('button');btn.type='button';btn.className='copy';btn.textContent='Copy';btn.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(getText());btn.textContent='Copied';btn.classList.add('copied');setTimeout(()=>{btn.textContent='Copy';btn.classList.remove('copied')},1400)}catch{btn.textContent='Failed';setTimeout(()=>{btn.textContent='Copy'},1400)}});target.appendChild(btn)}
|
||||
document.querySelectorAll('.doc pre').forEach(pre=>attachCopy(pre,()=>pre.querySelector('code')?.textContent??''));
|
||||
document.querySelectorAll('.home-install').forEach(el=>attachCopy(el,()=>el.querySelector('code')?.textContent??''));
|
||||
const tocLinks=document.querySelectorAll('.toc a');
|
||||
if(tocLinks.length){const map=new Map();tocLinks.forEach(a=>{const id=a.getAttribute('href').slice(1);const el=document.getElementById(id);if(el)map.set(el,a)});const setActive=l=>{tocLinks.forEach(x=>x.classList.remove('active'));l.classList.add('active')};const obs=new IntersectionObserver(entries=>{const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.boundingClientRect.top-b.boundingClientRect.top);if(visible.length){const link=map.get(visible[0].target);if(link)setActive(link)}},{rootMargin:'-15% 0px -65% 0px',threshold:0});map.forEach((_,el)=>obs.observe(el))}
|
||||
`;
|
||||
}
|
||||
|
||||
export function preThemeScript() {
|
||||
return `(function(){var s;try{s=localStorage.getItem('theme')}catch(e){}var d=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.dataset.theme=s||(d?'dark':'light')})();`;
|
||||
}
|
||||
|
||||
export function themeToggleHtml() {
|
||||
return `<button class="theme-toggle" type="button" aria-label="Toggle dark mode" aria-pressed="false" data-theme-toggle>
|
||||
<svg class="theme-icon-moon" viewBox="0 0 20 20" aria-hidden="true"><path d="M14.6 12.1A6.5 6.5 0 0 1 7.4 2.7a6.5 6.5 0 1 0 7.2 9.4z" fill="currentColor"/></svg>
|
||||
<svg class="theme-icon-sun" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="3.4" fill="currentColor"/><g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="10" y1="2" x2="10" y2="4"/><line x1="10" y1="16" x2="10" y2="18"/><line x1="2" y1="10" x2="4" y2="10"/><line x1="16" y1="10" x2="18" y2="10"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="14.4" y1="14.4" x2="15.8" y2="15.8"/><line x1="4.2" y1="15.8" x2="5.6" y2="14.4"/><line x1="14.4" y1="5.6" x2="15.8" y2="4.2"/></g></svg>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
export function faviconSvg() {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="gog">
|
||||
<rect width="64" height="64" rx="12" fill="#0f1115"/>
|
||||
<rect x="14" y="14" width="14" height="14" rx="3" fill="#4285f4"/>
|
||||
<rect x="36" y="14" width="14" height="14" rx="3" fill="#ea4335"/>
|
||||
<rect x="14" y="36" width="14" height="14" rx="3" fill="#fbbc04"/>
|
||||
<rect x="36" y="36" width="14" height="14" rx="3" fill="#34a853"/>
|
||||
</svg>`;
|
||||
}
|
||||
@ -44,13 +44,13 @@ if [[ "$assets_count" -eq 0 ]]; then
|
||||
exit 2
|
||||
fi
|
||||
|
||||
release_run_id="$(gh api repos/steipete/gogcli/actions/runs --jq ".workflow_runs[] | select(.name==\"release\") | select(.head_branch==\"v$version\") | select(.conclusion==\"success\") | .id" | head -n1)"
|
||||
release_run_id="$(gh run list -L 20 --workflow release.yml --json databaseId,conclusion,headBranch -q ".[] | select(.headBranch==\"v$version\") | select(.conclusion==\"success\") | .databaseId" | head -n1)"
|
||||
if [[ -z "$release_run_id" ]]; then
|
||||
echo "release workflow not green for v$version" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
ci_ok="$(gh api repos/steipete/gogcli/actions/runs --jq '.workflow_runs[] | select(.name=="ci") | select(.head_branch=="main") | .conclusion // ""' | head -n1)"
|
||||
ci_ok="$(gh run list -L 1 --workflow ci --branch main --json conclusion -q '.[0].conclusion')"
|
||||
if [[ "$ci_ok" != "success" ]]; then
|
||||
echo "CI not green for main" >&2
|
||||
exit 2
|
||||
|
||||
Loading…
Reference in New Issue
Block a user