gmdoc 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gmdoc-0.1.0/.gitignore ADDED
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Linter cache
13
+ .ruff_cache/
gmdoc-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 boscoh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
gmdoc-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.5
2
+ Name: gmdoc
3
+ Version: 0.1.0
4
+ Summary: Pull/push Google Docs as Markdown via the Drive API
5
+ Project-URL: Homepage, https://github.com/boscoh/gmdoc
6
+ Project-URL: Repository, https://github.com/boscoh/gmdoc
7
+ Project-URL: Issues, https://github.com/boscoh/gmdoc/issues
8
+ Author-email: boscoh <apposite@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,docs,drive,gcloud,google-docs,markdown
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: cyclopts>=4.10.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # gmdoc
28
+
29
+ Pull and push Google Docs as Markdown, using the Drive API's built-in
30
+ `text/markdown` conversion.
31
+
32
+ `gmdoc` borrows your access token from `gcloud`, so you don't need a Google Cloud
33
+ project, OAuth client, or config file. It talks to the Drive REST API with the
34
+ standard library, so its only dependency is `cyclopts`.
35
+
36
+ ## Install
37
+
38
+ ```sh
39
+ brew install --cask gcloud-cli
40
+ gcloud auth login --enable-gdrive-access # browser login, click Allow
41
+
42
+ uv tool install gmdoc
43
+ # or: pipx install gmdoc
44
+ ```
45
+
46
+ Running the script directly also works — `gmdoc.py` declares its own dependencies
47
+ in a PEP 723 header, so `uv run gmdoc.py ...` (or `./gmdoc.py ...`) works anywhere,
48
+ even if you copy just that one file.
49
+
50
+ ## Usage
51
+
52
+ ```sh
53
+ gmdoc ls [name] # list recent docs
54
+ gmdoc pull <doc-url|id> [out.md] # download (default name: <title>.md)
55
+ gmdoc pull notes.md # re-pull an already-linked file
56
+ gmdoc push notes.md # upload edits (replaces the doc body)
57
+ gmdoc new draft.md -t "My Doc" # create a new doc from markdown
58
+ gmdoc status [files...] # in sync / local / remote / CONFLICT
59
+ gmdoc account [email] [--login] # list/check/switch Google accounts
60
+ ```
61
+
62
+ Each pulled file gets front matter linking it to the doc:
63
+
64
+ ```yaml
65
+ ---
66
+ gmdoc_id: 1AbC...
67
+ gmdoc_title: My Doc
68
+ gmdoc_modified: 2026-01-01T12:00:00.000Z
69
+ gmdoc_hash: 3f2a...
70
+ gmdoc_account: me@gmail.com
71
+ ---
72
+ ```
73
+
74
+ ## Multiple accounts
75
+
76
+ - `gmdoc account` lists your gcloud accounts, marks the active one with `*`, and
77
+ checks that each can reach Drive.
78
+ - `gmdoc account other@gmail.com` switches to that account, logging in first if
79
+ needed. Add `--login` to redo the login (fixes "no Drive access").
80
+ - Each file remembers the account it was synced with (`gmdoc_account`), and
81
+ `pull`, `push` and `status` use that account whichever one is active.
82
+ - New docs and `ls` use the active account. `--account/-a <email>` overrides it
83
+ on any command.
84
+
85
+ ## Safety checks
86
+
87
+ - `push` refuses if the doc was edited in Google after your last sync. It detects
88
+ this by exporting the doc and comparing it to `gmdoc_hash`, the hash of the text
89
+ at last sync.
90
+ - `pull` refuses if the local file has unpushed edits.
91
+ - `--force` skips both checks.
92
+
93
+ ## Troubleshooting
94
+
95
+ If Drive complains that the API isn't enabled for the project, set
96
+ `GMDOC_QUOTA_PROJECT=<your-project-id>` (a project with the Drive API enabled).
97
+
98
+ ## Caveats
99
+
100
+ - A push **replaces the whole doc body**. Comments become detached, and
101
+ suggestions and some formatting (colors, complex tables, images) may be lost.
102
+ - After a push the file is re-exported, so it matches Google's version of the
103
+ markdown.
104
+ - The `drive` scope is used so it can edit any doc you have access to.
105
+
106
+ ## License
107
+
108
+ MIT
gmdoc-0.1.0/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # gmdoc
2
+
3
+ Pull and push Google Docs as Markdown, using the Drive API's built-in
4
+ `text/markdown` conversion.
5
+
6
+ `gmdoc` borrows your access token from `gcloud`, so you don't need a Google Cloud
7
+ project, OAuth client, or config file. It talks to the Drive REST API with the
8
+ standard library, so its only dependency is `cyclopts`.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ brew install --cask gcloud-cli
14
+ gcloud auth login --enable-gdrive-access # browser login, click Allow
15
+
16
+ uv tool install gmdoc
17
+ # or: pipx install gmdoc
18
+ ```
19
+
20
+ Running the script directly also works — `gmdoc.py` declares its own dependencies
21
+ in a PEP 723 header, so `uv run gmdoc.py ...` (or `./gmdoc.py ...`) works anywhere,
22
+ even if you copy just that one file.
23
+
24
+ ## Usage
25
+
26
+ ```sh
27
+ gmdoc ls [name] # list recent docs
28
+ gmdoc pull <doc-url|id> [out.md] # download (default name: <title>.md)
29
+ gmdoc pull notes.md # re-pull an already-linked file
30
+ gmdoc push notes.md # upload edits (replaces the doc body)
31
+ gmdoc new draft.md -t "My Doc" # create a new doc from markdown
32
+ gmdoc status [files...] # in sync / local / remote / CONFLICT
33
+ gmdoc account [email] [--login] # list/check/switch Google accounts
34
+ ```
35
+
36
+ Each pulled file gets front matter linking it to the doc:
37
+
38
+ ```yaml
39
+ ---
40
+ gmdoc_id: 1AbC...
41
+ gmdoc_title: My Doc
42
+ gmdoc_modified: 2026-01-01T12:00:00.000Z
43
+ gmdoc_hash: 3f2a...
44
+ gmdoc_account: me@gmail.com
45
+ ---
46
+ ```
47
+
48
+ ## Multiple accounts
49
+
50
+ - `gmdoc account` lists your gcloud accounts, marks the active one with `*`, and
51
+ checks that each can reach Drive.
52
+ - `gmdoc account other@gmail.com` switches to that account, logging in first if
53
+ needed. Add `--login` to redo the login (fixes "no Drive access").
54
+ - Each file remembers the account it was synced with (`gmdoc_account`), and
55
+ `pull`, `push` and `status` use that account whichever one is active.
56
+ - New docs and `ls` use the active account. `--account/-a <email>` overrides it
57
+ on any command.
58
+
59
+ ## Safety checks
60
+
61
+ - `push` refuses if the doc was edited in Google after your last sync. It detects
62
+ this by exporting the doc and comparing it to `gmdoc_hash`, the hash of the text
63
+ at last sync.
64
+ - `pull` refuses if the local file has unpushed edits.
65
+ - `--force` skips both checks.
66
+
67
+ ## Troubleshooting
68
+
69
+ If Drive complains that the API isn't enabled for the project, set
70
+ `GMDOC_QUOTA_PROJECT=<your-project-id>` (a project with the Drive API enabled).
71
+
72
+ ## Caveats
73
+
74
+ - A push **replaces the whole doc body**. Comments become detached, and
75
+ suggestions and some formatting (colors, complex tables, images) may be lost.
76
+ - After a push the file is re-exported, so it matches Google's version of the
77
+ markdown.
78
+ - The `drive` scope is used so it can edit any doc you have access to.
79
+
80
+ ## License
81
+
82
+ MIT
gmdoc-0.1.0/gmdoc.py ADDED
@@ -0,0 +1,652 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.12"
4
+ # dependencies = [
5
+ # "cyclopts>=4.10.0",
6
+ # ]
7
+ # ///
8
+ """gmdoc: pull/push Google Docs as Markdown via the Drive API.
9
+
10
+ Pull and push Google Docs as Markdown, using the Drive API's built-in
11
+ `text/markdown` conversion.
12
+
13
+ ## Setup (once)
14
+
15
+ ```sh
16
+ brew install --cask gcloud-cli
17
+ gcloud auth login --enable-gdrive-access # browser login, click Allow
18
+ uv tool install gmdoc
19
+ ```
20
+
21
+ Or skip installing: `gmdoc.py` lists its own dependencies in the comment block at the
22
+ top, so `uv run gmdoc.py ...` or `./gmdoc.py ...` works anywhere, even if you copy just
23
+ this file.
24
+
25
+ `gmdoc` borrows gcloud's access token, so you don't need a Google Cloud project. It talks
26
+ to the Drive REST API with the standard library, so its only dependency is `cyclopts`.
27
+
28
+ If Drive complains that the API isn't enabled for the project, set
29
+ `GMDOC_QUOTA_PROJECT=<your-project-id>` (a project with the Drive API enabled).
30
+
31
+ ## Usage
32
+
33
+ ```sh
34
+ gmdoc ls [name] # list recent docs
35
+ gmdoc pull <doc-url|id> [out.md] # download (default name: <title>.md)
36
+ gmdoc pull notes.md # re-pull an already-linked file
37
+ gmdoc push notes.md # upload edits (replaces the doc body)
38
+ gmdoc new draft.md -t "My Doc" # create a new doc from markdown
39
+ gmdoc status [files...] # in sync / local / remote / CONFLICT
40
+ gmdoc account [email] [--login] # list/check/switch Google accounts
41
+ ```
42
+
43
+ Each pulled file gets front matter linking it to the doc:
44
+
45
+ ```yaml
46
+ ---
47
+ gmdoc_id: 1AbC...
48
+ gmdoc_title: My Doc
49
+ gmdoc_modified: 2026-01-01T12:00:00.000Z
50
+ gmdoc_hash: 3f2a...
51
+ gmdoc_account: me@gmail.com
52
+ ---
53
+ ```
54
+
55
+ ## Multiple accounts
56
+
57
+ - `gmdoc account` lists your gcloud accounts, marks the active one with `*`, and checks
58
+ that each can reach Drive.
59
+ - `gmdoc account other@gmail.com` switches to that account, logging in first if needed.
60
+ Add `--login` to redo the login (fixes "no Drive access").
61
+ - Each file remembers the account it was synced with (`gmdoc_account`), and `pull`,
62
+ `push` and `status` use that account whichever one is active.
63
+ - New docs and `ls` use the active account. `--account/-a <email>` overrides it on
64
+ any command.
65
+
66
+ ## Safety checks
67
+
68
+ - `push` refuses if the doc was edited in Google after your last sync. It detects this by
69
+ exporting the doc and comparing it to `gmdoc_hash`, the hash of the text at last sync.
70
+ - `pull` refuses if the local file has unpushed edits.
71
+ - `--force` skips both checks.
72
+
73
+ ## Caveats
74
+
75
+ - A push **replaces the whole doc body**. Comments become detached, and suggestions and
76
+ some formatting (colors, complex tables, images) may be lost.
77
+ - After a push the file is re-exported, so it matches Google's version of the markdown.
78
+ - The `drive` scope is used so it can edit any doc you have access to.
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import functools
84
+ import hashlib
85
+ import json
86
+ import os
87
+ import re
88
+ import secrets
89
+ import shutil
90
+ import subprocess
91
+ import sys
92
+ import urllib.error
93
+ import urllib.request
94
+ from dataclasses import dataclass
95
+ from pathlib import Path
96
+ from typing import Annotated, NoReturn
97
+ from urllib.parse import urlencode
98
+
99
+ from cyclopts import App, Parameter
100
+
101
+ API = "https://www.googleapis.com/drive/v3"
102
+ UPLOAD = "https://www.googleapis.com/upload/drive/v3"
103
+ MD = "text/markdown"
104
+ GDOC = "application/vnd.google-apps.document"
105
+ FIELDS = "id,name,mimeType,modifiedTime"
106
+
107
+ # Name to show in hints: "gmdoc", or e.g. "b gmdoc" when run as a bhtool subcommand.
108
+ _argv0 = Path(sys.argv[0]).name
109
+ PROG = f"{_argv0} gmdoc" if _argv0 in ("b", "bhtool") else "gmdoc"
110
+ LOGIN_HINT = PROG + " account {} --login"
111
+
112
+ FM_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.S)
113
+
114
+
115
+ # ---------------------------------------------------------------- auth / api
116
+
117
+
118
+ class HttpError(Exception):
119
+ def __init__(self, status: int, message: str):
120
+ super().__init__(f"Drive API error {status}: {message}")
121
+ self.status = status
122
+
123
+
124
+ @dataclass
125
+ class Drive:
126
+ token: str
127
+ account: str
128
+
129
+ def call(
130
+ self,
131
+ method: str,
132
+ url: str,
133
+ params: dict | None = None,
134
+ data: bytes | None = None,
135
+ content_type: str | None = None,
136
+ raise_errors: bool = False,
137
+ ) -> bytes:
138
+ """Make a Drive REST call. On HTTP errors, exit (or raise HttpError if asked)."""
139
+ headers = {"Authorization": f"Bearer {self.token}"}
140
+ if quota := os.environ.get("GMDOC_QUOTA_PROJECT"):
141
+ headers["x-goog-user-project"] = quota
142
+ if content_type:
143
+ headers["Content-Type"] = content_type
144
+ if params:
145
+ url += "?" + urlencode(params)
146
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
147
+ try:
148
+ with urllib.request.urlopen(req, timeout=60) as r:
149
+ return r.read()
150
+ except urllib.error.HTTPError as e:
151
+ raw = e.read().decode("utf-8", "replace")
152
+ try:
153
+ message = json.loads(raw)["error"]["message"]
154
+ except (ValueError, KeyError, TypeError):
155
+ message = raw.strip() or str(e.reason)
156
+ err = HttpError(e.code, message)
157
+ if raise_errors:
158
+ raise err from None
159
+ die(str(err))
160
+ except urllib.error.URLError as e:
161
+ die(f"can't reach Google: {e.reason}")
162
+
163
+ def call_json(self, method: str, url: str, **kw) -> dict:
164
+ return json.loads(self.call(method, url, **kw))
165
+
166
+ def meta(self, doc_id: str) -> dict:
167
+ try:
168
+ info = self.call_json(
169
+ "GET",
170
+ f"{API}/files/{doc_id}",
171
+ params={"fields": FIELDS, "supportsAllDrives": "true"},
172
+ raise_errors=True,
173
+ )
174
+ except HttpError as e:
175
+ if e.status == 404:
176
+ die(
177
+ f"doc {doc_id} not found, or not shared with {self.account}.",
178
+ "Try another account with --account <email>. To list your accounts:",
179
+ f" {PROG} account",
180
+ )
181
+ die(str(e))
182
+ if info["mimeType"] != GDOC:
183
+ die(f"{info['name']} is not a Google Doc ({info['mimeType']})")
184
+ return info
185
+
186
+ def export(self, doc_id: str) -> str:
187
+ data = self.call("GET", f"{API}/files/{doc_id}/export", params={"mimeType": MD})
188
+ return data.decode("utf-8")
189
+
190
+ def update(self, doc_id: str, text: str) -> dict:
191
+ """Replace the doc body with markdown (Drive converts it)."""
192
+ return self.call_json(
193
+ "PATCH",
194
+ f"{UPLOAD}/files/{doc_id}",
195
+ params={"uploadType": "media", "fields": FIELDS, "supportsAllDrives": "true"},
196
+ data=text.encode("utf-8"),
197
+ content_type=MD,
198
+ )
199
+
200
+ def create(self, meta: dict, text: str) -> dict:
201
+ """Create a file from metadata + markdown content (multipart upload)."""
202
+ boundary = "gmdoc-" + secrets.token_hex(16)
203
+ data = (
204
+ f"--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n"
205
+ f"{json.dumps(meta)}\r\n"
206
+ f"--{boundary}\r\nContent-Type: {MD}; charset=UTF-8\r\n\r\n"
207
+ f"{text}\r\n--{boundary}--\r\n"
208
+ ).encode("utf-8")
209
+ return self.call_json(
210
+ "POST",
211
+ f"{UPLOAD}/files",
212
+ params={"uploadType": "multipart", "fields": FIELDS, "supportsAllDrives": "true"},
213
+ data=data,
214
+ content_type=f"multipart/related; boundary={boundary}",
215
+ )
216
+
217
+ def files(self, **params) -> list[dict]:
218
+ return self.call_json("GET", f"{API}/files", params=params).get("files", [])
219
+
220
+
221
+ def connect(account: str | None = None) -> Drive:
222
+ """Drive client for `account` (default: gcloud's active account)."""
223
+ account = account or active_account()
224
+ return Drive(gcloud_token(account), account)
225
+
226
+
227
+ class Connections(dict):
228
+ """One Drive client per account, created on demand."""
229
+
230
+ def __missing__(self, account: str | None) -> Drive: # None = active account
231
+ self[account] = connect(account)
232
+ return self[account]
233
+
234
+
235
+ GCLOUD_LOCATIONS = [ # common installs that may not be on PATH
236
+ "/opt/homebrew/share/google-cloud-sdk/bin/gcloud",
237
+ "/usr/local/share/google-cloud-sdk/bin/gcloud",
238
+ "~/google-cloud-sdk/bin/gcloud",
239
+ "/usr/lib/google-cloud-sdk/bin/gcloud",
240
+ ]
241
+
242
+
243
+ @functools.cache
244
+ def gcloud_bin() -> str:
245
+ """Path to gcloud, or exit with install instructions."""
246
+ found = shutil.which("gcloud")
247
+ if found:
248
+ return found
249
+ for loc in GCLOUD_LOCATIONS:
250
+ p = Path(loc).expanduser()
251
+ if p.is_file() and os.access(p, os.X_OK):
252
+ return str(p)
253
+ die(
254
+ "gcloud is not installed. gmdoc uses it to log in to Google.",
255
+ "Install it:",
256
+ " brew install --cask gcloud-cli",
257
+ " (other systems: https://cloud.google.com/sdk/docs/install)",
258
+ "Then log in:",
259
+ " gcloud auth login --enable-gdrive-access",
260
+ )
261
+
262
+
263
+ def gcloud(*args: str, capture: bool = True) -> subprocess.CompletedProcess:
264
+ return subprocess.run([gcloud_bin(), *args], capture_output=capture, text=True)
265
+
266
+
267
+ def gcloud_accounts() -> list[dict]:
268
+ r = gcloud("auth", "list", "--format=json")
269
+ return json.loads(r.stdout or "[]") if r.returncode == 0 else []
270
+
271
+
272
+ def active_account() -> str:
273
+ for a in gcloud_accounts():
274
+ if a.get("status") == "ACTIVE":
275
+ return a["account"]
276
+ die("no active gcloud account.", "Log in / pick one:", f" {PROG} account <email>")
277
+
278
+
279
+ def gcloud_token(account: str) -> str:
280
+ """Borrow an access token from `gcloud auth login --enable-gdrive-access`."""
281
+ r = gcloud("auth", "print-access-token", account)
282
+ if r.returncode != 0:
283
+ die(
284
+ f"{account} is not logged in to gcloud.",
285
+ "Log in:",
286
+ f" {LOGIN_HINT.format(account)}",
287
+ )
288
+ return r.stdout.strip()
289
+
290
+
291
+ # ------------------------------------------------------------ front matter
292
+
293
+
294
+ def read(path: Path) -> tuple[dict, str]:
295
+ if not path.is_file():
296
+ die(f"no such file: {path}")
297
+ return parse(path.read_text(encoding="utf-8"))
298
+
299
+
300
+ def parse(text: str) -> tuple[dict, str]:
301
+ m = FM_RE.match(text)
302
+ if not m:
303
+ return {}, text
304
+ fm = {}
305
+ for line in m.group(1).splitlines():
306
+ if ":" in line:
307
+ k, v = line.split(":", 1)
308
+ fm[k.strip()] = v.strip()
309
+ if "gmdoc_id" not in fm: # someone else's front matter; leave it in the body
310
+ return {}, text
311
+ return fm, text[m.end():]
312
+
313
+
314
+ def render(fm: dict, body: str) -> str:
315
+ head = "\n".join(f"{k}: {v}" for k, v in fm.items())
316
+ return f"---\n{head}\n---\n\n{body.strip(chr(10))}\n"
317
+
318
+
319
+ def digest(body: str) -> str:
320
+ return hashlib.sha256(body.strip().encode("utf-8")).hexdigest()[:16]
321
+
322
+
323
+ def write_synced(path: Path, drive: Drive, info: dict, body: str) -> None:
324
+ fm = {
325
+ "gmdoc_id": info["id"],
326
+ "gmdoc_title": info["name"],
327
+ "gmdoc_modified": info["modifiedTime"],
328
+ "gmdoc_hash": digest(body),
329
+ }
330
+ if drive.account:
331
+ fm["gmdoc_account"] = drive.account
332
+ path.write_text(render(fm, body), encoding="utf-8")
333
+
334
+
335
+ # ------------------------------------------------------------------ helpers
336
+
337
+
338
+ def die(msg: str, *hints: str) -> NoReturn:
339
+ """Print an error and exit. Each hint goes on its own indented line after a blank line."""
340
+ text = f"{PROG}: {msg}"
341
+ if hints:
342
+ text += "\n\n" + "\n".join(f" {h}" if h else "" for h in hints)
343
+ print(text, file=sys.stderr)
344
+ sys.exit(1)
345
+
346
+
347
+ ID_RE = re.compile(r"[A-Za-z0-9_-]{20,}")
348
+
349
+
350
+ def doc_id_from(s: str) -> str:
351
+ """Extract a Drive file id from an id or any Docs/Drive URL.
352
+
353
+ Handles .../d/<id>/edit?tab=t.0#heading=..., /u/1/d/<id>, /view, /copy,
354
+ open?id=<id>, uc?id=<id>&export=..., and ids with stray ?/#/quotes.
355
+ """
356
+ from urllib.parse import parse_qs, urlsplit
357
+
358
+ s = s.strip().strip("'\"<>")
359
+ url = urlsplit(s)
360
+ parts = [p for p in url.path.split("/") if p]
361
+ if "d" in parts[:-1]: # .../d/<id>/...
362
+ cand = parts[parts.index("d") + 1]
363
+ else:
364
+ query = parse_qs(url.query) or parse_qs(url.fragment.lstrip("?"))
365
+ # ?id=<id>, else the last path segment (folders/<id>, or a bare id)
366
+ cand = (query.get("id") or [""])[0] or (parts[-1] if parts else "")
367
+ m = ID_RE.search(cand)
368
+ if not m:
369
+ die(f"can't find a doc id in {s!r}")
370
+ return m.group(0)
371
+
372
+
373
+ def slug(name: str) -> str:
374
+ return re.sub(r"[^A-Za-z0-9]+", "-", name).strip("-").lower() or "untitled"
375
+
376
+
377
+ def looks_like_file(s: str) -> bool:
378
+ return s.endswith(".md") or Path(s).exists()
379
+
380
+
381
+ def drive_ok(drive: Drive) -> str:
382
+ """Return '' if the account can use Drive, else a short reason."""
383
+ try:
384
+ drive.call("GET", f"{API}/files", params={"pageSize": 1, "fields": "files(id)"}, raise_errors=True)
385
+ return ""
386
+ except HttpError as e:
387
+ return "no Drive access" if e.status in (401, 403) else f"error {e.status}"
388
+
389
+
390
+ # ----------------------------------------------------------------- commands
391
+
392
+ Force = Annotated[bool, Parameter(name=["--force", "-f"], negative="")]
393
+ Account = Annotated[str | None, Parameter(name=["--account", "-a"])]
394
+
395
+ app = App(name="gmdoc", help="Pull/push Google Docs as Markdown.", version_flags=[])
396
+
397
+
398
+ @app.command
399
+ def pull(
400
+ target: str, out: Path | None = None, *, account: Account = None, force: Force = False
401
+ ) -> None:
402
+ """Download a doc as markdown.
403
+
404
+ Parameters
405
+ ----------
406
+ target
407
+ Doc id, doc URL, or an already-linked .md file.
408
+ out
409
+ Output file (default: the doc title, slugified, + .md).
410
+ account
411
+ Google account (default: the file's gmdoc_account, else gcloud's active account).
412
+ force
413
+ Overwrite local changes.
414
+ """
415
+ path = None
416
+ if looks_like_file(target):
417
+ fm, _ = read(Path(target))
418
+ if "gmdoc_id" not in fm:
419
+ die(f"{target} has no gmdoc_id front matter.", "Pass a doc id or URL instead.")
420
+ doc_id = fm["gmdoc_id"]
421
+ account = account or fm.get("gmdoc_account")
422
+ path = out or Path(target)
423
+ else:
424
+ doc_id = doc_id_from(target)
425
+
426
+ drive = connect(account)
427
+ info = drive.meta(doc_id)
428
+ path = path or out or Path(f"{slug(info['name'])}.md")
429
+
430
+ if path.exists() and not force:
431
+ fm, body = read(path)
432
+ if fm.get("gmdoc_id") and fm["gmdoc_id"] != doc_id:
433
+ die(
434
+ f"{path} is linked to a different doc ({fm['gmdoc_id']}).",
435
+ "Use --force to overwrite it.",
436
+ )
437
+ if not fm or fm.get("gmdoc_hash") != digest(body):
438
+ die(
439
+ f"{path} has local changes that would be overwritten.",
440
+ "Push them first, or use --force to discard them.",
441
+ )
442
+
443
+ write_synced(path, drive, info, drive.export(doc_id))
444
+ print(f"pulled '{info['name']}' -> {path}")
445
+
446
+
447
+ @app.command
448
+ def push(
449
+ file: Path, doc: str | None = None, *, account: Account = None, force: Force = False
450
+ ) -> None:
451
+ """Replace a doc's content with a markdown file.
452
+
453
+ Parameters
454
+ ----------
455
+ file
456
+ Linked .md file.
457
+ doc
458
+ Doc id/URL (default: from front matter).
459
+ account
460
+ Google account (default: the file's gmdoc_account, else gcloud's active account).
461
+ force
462
+ Overwrite remote changes.
463
+ """
464
+ fm, body = read(file)
465
+ doc_id = doc_id_from(doc) if doc else fm.get("gmdoc_id")
466
+ if not doc_id:
467
+ die(
468
+ f"{file} has no gmdoc_id front matter.",
469
+ "Pass a doc id or URL, or create a new doc:",
470
+ f" {PROG} new {file}",
471
+ )
472
+
473
+ drive = connect(account or fm.get("gmdoc_account"))
474
+ info = drive.meta(doc_id)
475
+ if not force:
476
+ if fm.get("gmdoc_id") != doc_id:
477
+ die(
478
+ f"{file} was not pulled from this doc.",
479
+ "Pushing replaces the doc's whole body. Use --force to do it anyway.",
480
+ )
481
+ if fm.get("gmdoc_hash") == digest(body):
482
+ print("no local changes; nothing to push")
483
+ return
484
+ if digest(drive.export(doc_id)) != fm.get("gmdoc_hash"):
485
+ die(
486
+ f"'{info['name']}' was edited in Google Docs since your last sync.",
487
+ "Save your edits elsewhere and pull, or use --force to overwrite.",
488
+ )
489
+
490
+ info = drive.update(doc_id, body)
491
+ # Re-export so the local file matches Google's normalized markdown.
492
+ write_synced(file, drive, info, drive.export(doc_id))
493
+ print(f"pushed {file} -> '{info['name']}'")
494
+
495
+
496
+ @app.command
497
+ def new(
498
+ file: Path,
499
+ *,
500
+ title: Annotated[str | None, Parameter(name=["--title", "-t"])] = None,
501
+ folder: str | None = None,
502
+ account: Account = None,
503
+ force: Force = False,
504
+ ) -> None:
505
+ """Create a new doc from a markdown file.
506
+
507
+ Parameters
508
+ ----------
509
+ file
510
+ Markdown file to upload.
511
+ title
512
+ Doc title (default: file name).
513
+ folder
514
+ Drive folder id/URL.
515
+ account
516
+ Google account that will own the doc (default: gcloud's active account).
517
+ force
518
+ Create even if the file is already linked to a doc.
519
+ """
520
+ fm, body = read(file)
521
+ if fm.get("gmdoc_id") and not force:
522
+ die(
523
+ f"{file} is already linked to {fm['gmdoc_id']}.",
524
+ f"Use `{PROG} push`, or --force to create another doc.",
525
+ )
526
+ body_meta: dict[str, object] = {"name": title or file.stem, "mimeType": GDOC}
527
+ if folder:
528
+ body_meta["parents"] = [doc_id_from(folder)]
529
+ drive = connect(account)
530
+ info = drive.create(body_meta, body)
531
+ write_synced(file, drive, info, drive.export(info["id"]))
532
+ print(f"created '{info['name']}' https://docs.google.com/document/d/{info['id']}/edit")
533
+
534
+
535
+ @app.command
536
+ def status(*files: Path) -> None:
537
+ """Show sync state (and account) of linked .md files.
538
+
539
+ Parameters
540
+ ----------
541
+ files
542
+ Files to check (default: *.md in the current directory).
543
+ """
544
+ drives = Connections()
545
+ for path in files or sorted(Path(".").glob("*.md")):
546
+ fm, body = read(path)
547
+ if "gmdoc_id" not in fm:
548
+ continue
549
+ drive = drives[fm.get("gmdoc_account")]
550
+ local = fm.get("gmdoc_hash") != digest(body)
551
+ remote = fm.get("gmdoc_hash") != digest(drive.export(fm["gmdoc_id"]))
552
+ state = {
553
+ (False, False): "in sync",
554
+ (True, False): "local changes (push)",
555
+ (False, True): "remote changes (pull)",
556
+ (True, True): "CONFLICT (both changed)",
557
+ }[(local, remote)]
558
+ who = f" [{drive.account}]" if drive.account else ""
559
+ print(f"{path}: {state}{who}")
560
+
561
+
562
+ @app.command
563
+ def ls(
564
+ query: str | None = None,
565
+ *,
566
+ limit: Annotated[int, Parameter(name=["--limit", "-n"])] = 20,
567
+ account: Account = None,
568
+ ) -> None:
569
+ """List recent Google Docs.
570
+
571
+ Parameters
572
+ ----------
573
+ query
574
+ Filter by name.
575
+ limit
576
+ Max docs to show.
577
+ account
578
+ Google account (default: gcloud's active account).
579
+ """
580
+ q = f"mimeType='{GDOC}' and trashed=false"
581
+ if query:
582
+ q += " and name contains '{}'".format(query.replace("'", "\\'"))
583
+ files = connect(account).files(
584
+ q=q,
585
+ orderBy="modifiedTime desc",
586
+ pageSize=limit,
587
+ fields="files(id,name,modifiedTime)",
588
+ includeItemsFromAllDrives="true",
589
+ supportsAllDrives="true",
590
+ )
591
+ for f in files:
592
+ print(f"{f['id']} {f['modifiedTime'][:10]} {f['name']}")
593
+
594
+
595
+ @app.command
596
+ def account(
597
+ email: str | None = None,
598
+ *,
599
+ login: Annotated[bool, Parameter(negative="")] = False,
600
+ ) -> None:
601
+ """List Google accounts and their Drive access, or switch the active one.
602
+
603
+ With EMAIL: switch gcloud's active account to it, logging in first
604
+ (with Drive access) if it isn't logged in yet.
605
+
606
+ Parameters
607
+ ----------
608
+ email
609
+ Account to switch to.
610
+ login
611
+ Force a fresh browser login with Drive access (fixes "no Drive access").
612
+ """
613
+ if email:
614
+ known = {a["account"] for a in gcloud_accounts()}
615
+ if login or email not in known:
616
+ # Interactive: opens a browser, so don't capture output.
617
+ r = gcloud("auth", "login", email, "--enable-gdrive-access", capture=False)
618
+ if r.returncode != 0:
619
+ die(f"login failed for {email}.")
620
+ r = gcloud("config", "set", "account", email)
621
+ if r.returncode != 0:
622
+ die(r.stderr.strip())
623
+ print(f"switched to {email}")
624
+ elif login:
625
+ die("--login needs an EMAIL")
626
+
627
+ accounts = gcloud_accounts()
628
+ if not accounts:
629
+ die("no gcloud accounts.", "Log in:", f" {PROG} account <email>")
630
+ width = max(len(a["account"]) for a in accounts)
631
+ broken = []
632
+ for a in accounts:
633
+ mark = "*" if a.get("status") == "ACTIVE" else " "
634
+ problem = drive_ok(connect(a["account"]))
635
+ if problem:
636
+ broken.append(a["account"])
637
+ print(f"{mark} {a['account']:<{width}} {problem or 'Drive ok'}")
638
+ if broken:
639
+ print("\nTo fix, log in again:")
640
+ for acct in broken:
641
+ print(f" {LOGIN_HINT.format(acct)}")
642
+
643
+
644
+ def main() -> None:
645
+ try:
646
+ app()
647
+ except KeyboardInterrupt:
648
+ sys.exit(130)
649
+
650
+
651
+ if __name__ == "__main__":
652
+ main()
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "gmdoc"
3
+ version = "0.1.0"
4
+ description = "Pull/push Google Docs as Markdown via the Drive API"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "boscoh", email = "apposite@gmail.com" }
10
+ ]
11
+ requires-python = ">=3.12"
12
+ keywords = ["google-docs", "markdown", "drive", "cli", "docs", "gcloud"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: End Users/Desktop",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: MacOS",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Text Processing :: Markup :: Markdown",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "cyclopts>=4.10.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/boscoh/gmdoc"
32
+ Repository = "https://github.com/boscoh/gmdoc"
33
+ Issues = "https://github.com/boscoh/gmdoc/issues"
34
+
35
+ [project.scripts]
36
+ gmdoc = "gmdoc:main"
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ only-include = ["gmdoc.py"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = [
47
+ "/gmdoc.py",
48
+ "/pyproject.toml",
49
+ "/README.md",
50
+ "/LICENSE",
51
+ ]