bhtool 0.2.5__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bhtool
3
- Version: 0.2.5
3
+ Version: 0.3.0
4
4
  Summary: Assorted CLI utilities for Bosco’s workflows (dev, media, macOS, LLM helpers).
5
5
  Author-email: Bosco Ho <apposite@gmail.com>
6
6
  Requires-Python: >=3.13
@@ -44,6 +44,7 @@ Assorted CLI utilities that are handy in Bosco’s day-to-day work—version bum
44
44
  | **text** | Convert between Markdown, HTML, DOCX, and Pug (needs`pandoc` and related tools on `PATH`) | |
45
45
  | **todict** | JSON or YAML file, or stdin, → Python`dict(...)`-style repr on stdout | |
46
46
  | **movies** | `uvx bhtool movies` (or `b movies`) — ask an LLM for normalized names; writes `movie_mapping.json` in the **current working directory**; `--execute` applies renames (default is dry-run table) | |
47
+ | **gdoc** | Pull/push Google Docs as Markdown (`b gdoc pull|push|new|status|ls|account`). Uses gcloud's login: `gcloud auth login --enable-gdrive-access`. See `bhtool/gdoc.py` docstring | |
47
48
 
48
49
  ### Movies + LLM
49
50
 
@@ -28,6 +28,7 @@ Assorted CLI utilities that are handy in Bosco’s day-to-day work—version bum
28
28
  | **text** | Convert between Markdown, HTML, DOCX, and Pug (needs`pandoc` and related tools on `PATH`) | |
29
29
  | **todict** | JSON or YAML file, or stdin, → Python`dict(...)`-style repr on stdout | |
30
30
  | **movies** | `uvx bhtool movies` (or `b movies`) — ask an LLM for normalized names; writes `movie_mapping.json` in the **current working directory**; `--execute` applies renames (default is dry-run table) | |
31
+ | **gdoc** | Pull/push Google Docs as Markdown (`b gdoc pull|push|new|status|ls|account`). Uses gcloud's login: `gcloud auth login --enable-gdrive-access`. See `bhtool/gdoc.py` docstring | |
31
32
 
32
33
  ### Movies + LLM
33
34
 
@@ -6,7 +6,10 @@ from typing import Literal
6
6
 
7
7
  from cyclopts import App
8
8
 
9
+ from bhtool.gdoc import app as gdoc_app
10
+
9
11
  app = App(name="bhtool", help="boscoh tools")
12
+ app.command(gdoc_app)
10
13
 
11
14
 
12
15
  @app.command(name="run")
@@ -24,7 +27,7 @@ def run_cmd(*params: str):
24
27
  def bumpver_cmd(
25
28
  part: Literal["major", "minor", "patch"] | None = None, *, publish: bool = True
26
29
  ):
27
- """Bump version in pyproject.toml, commit, push, and optionally publish to PyPI."""
30
+ """Bump version in uv, commit, push, and publish."""
28
31
  import bhtool.bumpver as bumpver_mod
29
32
 
30
33
  bumpver_mod.bumpver(part, publish=publish)
@@ -32,7 +35,7 @@ def bumpver_cmd(
32
35
 
33
36
  @app.command(name="clr_chmod")
34
37
  def clr_chmod_cmd():
35
- """Remove execute permissions from non-script files and write permissions from group/other."""
38
+ """Fix permissions for non-script files."""
36
39
  import bhtool.clr_chmod as clr_chmod_mod
37
40
 
38
41
  clr_chmod_mod.clr_chmod()
@@ -77,7 +80,7 @@ def port_cmd(*ports: int, kill: bool = False):
77
80
 
78
81
  @app.command(name="rm_npm")
79
82
  def rm_npm_cmd():
80
- """Recursively remove node_modules directories and package-lock.json files."""
83
+ """Recursively remove node_modules & package-lock.json."""
81
84
  import bhtool.rm_npm as rm_npm_mod
82
85
 
83
86
  rm_npm_mod.rm_npm()
@@ -104,7 +107,7 @@ def todict_cmd(json: str | None = None, yaml: str | None = None):
104
107
 
105
108
  @app.command(name="movies")
106
109
  def movies_cmd(root_dir: str | None = None, execute: bool = False):
107
- """Normalize movie directory and file names with an LLM (dry-run table by default).
110
+ """Normalize movie names with an LLM.
108
111
 
109
112
  :param root_dir: Root directory containing movies (positional); default is current working directory.
110
113
  :param execute: If true, perform renames; otherwise dry run (table output).
@@ -117,4 +120,7 @@ def movies_cmd(root_dir: str | None = None, execute: bool = False):
117
120
 
118
121
 
119
122
  def main() -> None:
120
- app()
123
+ try:
124
+ app()
125
+ except KeyboardInterrupt:
126
+ raise SystemExit(130)
@@ -0,0 +1,607 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """Pull/push Google Docs as Markdown (`b gdoc ...`).
4
+
5
+ Uses the Drive API's built-in `text/markdown` conversion, called directly with the
6
+ standard library. Login is borrowed from gcloud, so there's no Google Cloud project or
7
+ OAuth client to set up.
8
+
9
+ ## Setup (once)
10
+
11
+ ```sh
12
+ brew install --cask gcloud-cli
13
+ gcloud auth login --enable-gdrive-access # browser login, click Allow
14
+ ```
15
+
16
+ If Drive complains that the API isn't enabled for the project, set
17
+ `GDOC_QUOTA_PROJECT=<your-project-id>` (a project with the Drive API enabled).
18
+
19
+ ## Usage
20
+
21
+ ```sh
22
+ b gdoc ls [name] # list recent docs
23
+ b gdoc pull <doc-url|id> [out.md] # download (default name: slugified title + .md)
24
+ b gdoc pull notes.md # re-pull an already-linked file
25
+ b gdoc push notes.md # upload edits (replaces the doc body)
26
+ b gdoc new draft.md -t "My Doc" # create a new doc from markdown
27
+ b gdoc status [files...] # in sync / local / remote / CONFLICT
28
+ b gdoc account [email] [--login] # list/check/switch Google accounts
29
+ ```
30
+
31
+ Each pulled file gets front matter linking it to the doc:
32
+
33
+ ```yaml
34
+ ---
35
+ gdoc_id: 1AbC...
36
+ gdoc_title: My Doc
37
+ gdoc_modified: 2026-01-01T12:00:00.000Z
38
+ gdoc_hash: 3f2a...
39
+ gdoc_account: me@gmail.com
40
+ ---
41
+ ```
42
+
43
+ ## Multiple accounts
44
+
45
+ - `b gdoc account` lists your gcloud accounts, marks the active one with `*`, and checks
46
+ that each can reach Drive.
47
+ - `b gdoc account other@gmail.com` switches to that account, logging in first if needed.
48
+ Add `--login` to redo the login (fixes "no Drive access").
49
+ - Each file remembers the account it was synced with (`gdoc_account`), and `pull`,
50
+ `push` and `status` use that account whichever one is active.
51
+ - New docs and `ls` use the active account. `--account/-a <email>` overrides it on
52
+ any command.
53
+
54
+ ## Safety checks
55
+
56
+ - `push` refuses if the doc was edited in Google after your last sync. It detects this by
57
+ exporting the doc and comparing it to `gdoc_hash`, the hash of the text at last sync.
58
+ - `pull` refuses if the local file has unpushed edits.
59
+ - `--force` skips both checks.
60
+
61
+ ## Caveats
62
+
63
+ - A push **replaces the whole doc body**. Comments become detached, and suggestions and
64
+ some formatting (colors, complex tables, images) may be lost.
65
+ - After a push the file is re-exported, so it matches Google's version of the markdown.
66
+ - `gcloud auth login --enable-gdrive-access` grants full Drive access, so gdoc can edit
67
+ any doc your account can.
68
+ """
69
+
70
+ from __future__ import annotations
71
+
72
+ import functools
73
+ import hashlib
74
+ import json
75
+ import os
76
+ import re
77
+ import secrets
78
+ import shutil
79
+ import subprocess
80
+ import sys
81
+ import urllib.error
82
+ import urllib.request
83
+ from dataclasses import dataclass
84
+ from pathlib import Path
85
+ from typing import Annotated, NoReturn
86
+ from urllib.parse import urlencode
87
+
88
+ from cyclopts import App, Parameter
89
+
90
+ API = "https://www.googleapis.com/drive/v3"
91
+ UPLOAD = "https://www.googleapis.com/upload/drive/v3"
92
+ MD = "text/markdown"
93
+ GDOC = "application/vnd.google-apps.document"
94
+ FIELDS = "id,name,mimeType,modifiedTime"
95
+
96
+ # Name to show in hints: "b gdoc" / "bhtool gdoc", or "gdoc" if run some other way.
97
+ _argv0 = Path(sys.argv[0]).name
98
+ PROG = f"{_argv0} gdoc" if _argv0 in ("b", "bhtool") else "gdoc"
99
+ LOGIN_HINT = PROG + " account {} --login"
100
+
101
+ FM_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.S)
102
+
103
+
104
+ # ---------------------------------------------------------------- auth / api
105
+
106
+
107
+ class HttpError(Exception):
108
+ def __init__(self, status: int, message: str):
109
+ super().__init__(f"Drive API error {status}: {message}")
110
+ self.status = status
111
+
112
+
113
+ @dataclass
114
+ class Drive:
115
+ token: str
116
+ account: str
117
+
118
+ def call(
119
+ self,
120
+ method: str,
121
+ url: str,
122
+ params: dict | None = None,
123
+ data: bytes | None = None,
124
+ content_type: str | None = None,
125
+ raise_errors: bool = False,
126
+ ) -> bytes:
127
+ """Make a Drive REST call. On HTTP errors, exit (or raise HttpError if asked)."""
128
+ headers = {"Authorization": f"Bearer {self.token}"}
129
+ if quota := os.environ.get("GDOC_QUOTA_PROJECT"):
130
+ headers["x-goog-user-project"] = quota
131
+ if content_type:
132
+ headers["Content-Type"] = content_type
133
+ if params:
134
+ url += "?" + urlencode(params)
135
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
136
+ try:
137
+ with urllib.request.urlopen(req, timeout=60) as r:
138
+ return r.read()
139
+ except urllib.error.HTTPError as e:
140
+ raw = e.read().decode("utf-8", "replace")
141
+ try:
142
+ message = json.loads(raw)["error"]["message"]
143
+ except (ValueError, KeyError, TypeError):
144
+ message = raw.strip() or str(e.reason)
145
+ err = HttpError(e.code, message)
146
+ if raise_errors:
147
+ raise err from None
148
+ die(str(err))
149
+ except urllib.error.URLError as e:
150
+ die(f"can't reach Google: {e.reason}")
151
+
152
+ def call_json(self, method: str, url: str, **kw) -> dict:
153
+ return json.loads(self.call(method, url, **kw))
154
+
155
+ def meta(self, doc_id: str) -> dict:
156
+ try:
157
+ info = self.call_json(
158
+ "GET",
159
+ f"{API}/files/{doc_id}",
160
+ params={"fields": FIELDS, "supportsAllDrives": "true"},
161
+ raise_errors=True,
162
+ )
163
+ except HttpError as e:
164
+ if e.status == 404:
165
+ die(
166
+ f"doc {doc_id} not found, or not shared with {self.account}.\n"
167
+ f"Try --account <email>, or see `{PROG} account`."
168
+ )
169
+ die(str(e))
170
+ if info["mimeType"] != GDOC:
171
+ die(f"{info['name']} is not a Google Doc ({info['mimeType']})")
172
+ return info
173
+
174
+ def export(self, doc_id: str) -> str:
175
+ data = self.call("GET", f"{API}/files/{doc_id}/export", params={"mimeType": MD})
176
+ return data.decode("utf-8")
177
+
178
+ def update(self, doc_id: str, text: str) -> dict:
179
+ """Replace the doc body with markdown (Drive converts it)."""
180
+ return self.call_json(
181
+ "PATCH",
182
+ f"{UPLOAD}/files/{doc_id}",
183
+ params={"uploadType": "media", "fields": FIELDS, "supportsAllDrives": "true"},
184
+ data=text.encode("utf-8"),
185
+ content_type=MD,
186
+ )
187
+
188
+ def create(self, meta: dict, text: str) -> dict:
189
+ """Create a file from metadata + markdown content (multipart upload)."""
190
+ boundary = "gdoc-" + secrets.token_hex(16)
191
+ data = (
192
+ f"--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n"
193
+ f"{json.dumps(meta)}\r\n"
194
+ f"--{boundary}\r\nContent-Type: {MD}; charset=UTF-8\r\n\r\n"
195
+ f"{text}\r\n--{boundary}--\r\n"
196
+ ).encode("utf-8")
197
+ return self.call_json(
198
+ "POST",
199
+ f"{UPLOAD}/files",
200
+ params={"uploadType": "multipart", "fields": FIELDS, "supportsAllDrives": "true"},
201
+ data=data,
202
+ content_type=f"multipart/related; boundary={boundary}",
203
+ )
204
+
205
+ def files(self, **params) -> list[dict]:
206
+ return self.call_json("GET", f"{API}/files", params=params).get("files", [])
207
+
208
+
209
+ def connect(account: str | None = None) -> Drive:
210
+ """Drive client for `account` (default: gcloud's active account)."""
211
+ account = account or active_account()
212
+ return Drive(gcloud_token(account), account)
213
+
214
+
215
+ class Connections(dict):
216
+ """One Drive client per account, created on demand."""
217
+
218
+ def __missing__(self, account: str | None) -> Drive: # None = active account
219
+ self[account] = connect(account)
220
+ return self[account]
221
+
222
+
223
+ GCLOUD_LOCATIONS = [ # common installs that may not be on PATH
224
+ "/opt/homebrew/share/google-cloud-sdk/bin/gcloud",
225
+ "/usr/local/share/google-cloud-sdk/bin/gcloud",
226
+ "~/google-cloud-sdk/bin/gcloud",
227
+ "/usr/lib/google-cloud-sdk/bin/gcloud",
228
+ ]
229
+
230
+
231
+ @functools.cache
232
+ def gcloud_bin() -> str:
233
+ """Path to gcloud, or exit with install instructions."""
234
+ found = shutil.which("gcloud")
235
+ if found:
236
+ return found
237
+ for loc in GCLOUD_LOCATIONS:
238
+ p = Path(loc).expanduser()
239
+ if p.is_file() and os.access(p, os.X_OK):
240
+ return str(p)
241
+ die(
242
+ "gcloud is not installed. gdoc uses it to log in to Google.\n"
243
+ " install: brew install --cask gcloud-cli\n"
244
+ " (other systems: https://cloud.google.com/sdk/docs/install)\n"
245
+ " then: gcloud auth login --enable-gdrive-access"
246
+ )
247
+
248
+
249
+ def gcloud(*args: str, capture: bool = True) -> subprocess.CompletedProcess:
250
+ return subprocess.run([gcloud_bin(), *args], capture_output=capture, text=True)
251
+
252
+
253
+ def gcloud_accounts() -> list[dict]:
254
+ r = gcloud("auth", "list", "--format=json")
255
+ return json.loads(r.stdout or "[]") if r.returncode == 0 else []
256
+
257
+
258
+ def active_account() -> str:
259
+ for a in gcloud_accounts():
260
+ if a.get("status") == "ACTIVE":
261
+ return a["account"]
262
+ die(f"no active gcloud account; run: {PROG} account <email>")
263
+
264
+
265
+ def gcloud_token(account: str) -> str:
266
+ """Borrow an access token from `gcloud auth login --enable-gdrive-access`."""
267
+ r = gcloud("auth", "print-access-token", account)
268
+ if r.returncode != 0:
269
+ die(f"{account} is not logged in to gcloud; run: {LOGIN_HINT.format(account)}")
270
+ return r.stdout.strip()
271
+
272
+
273
+ # ------------------------------------------------------------ front matter
274
+
275
+
276
+ def read(path: Path) -> tuple[dict, str]:
277
+ if not path.is_file():
278
+ die(f"no such file: {path}")
279
+ return parse(path.read_text(encoding="utf-8"))
280
+
281
+
282
+ def parse(text: str) -> tuple[dict, str]:
283
+ m = FM_RE.match(text)
284
+ if not m:
285
+ return {}, text
286
+ fm = {}
287
+ for line in m.group(1).splitlines():
288
+ if ":" in line:
289
+ k, v = line.split(":", 1)
290
+ fm[k.strip()] = v.strip()
291
+ if "gdoc_id" not in fm: # someone else's front matter; leave it in the body
292
+ return {}, text
293
+ return fm, text[m.end():]
294
+
295
+
296
+ def render(fm: dict, body: str) -> str:
297
+ head = "\n".join(f"{k}: {v}" for k, v in fm.items())
298
+ return f"---\n{head}\n---\n\n{body.strip(chr(10))}\n"
299
+
300
+
301
+ def digest(body: str) -> str:
302
+ return hashlib.sha256(body.strip().encode("utf-8")).hexdigest()[:16]
303
+
304
+
305
+ def write_synced(path: Path, drive: Drive, info: dict, body: str) -> None:
306
+ fm = {
307
+ "gdoc_id": info["id"],
308
+ "gdoc_title": info["name"],
309
+ "gdoc_modified": info["modifiedTime"],
310
+ "gdoc_hash": digest(body),
311
+ }
312
+ if drive.account:
313
+ fm["gdoc_account"] = drive.account
314
+ path.write_text(render(fm, body), encoding="utf-8")
315
+
316
+
317
+ # ------------------------------------------------------------------ helpers
318
+
319
+
320
+ def die(msg: str) -> NoReturn:
321
+ print(f"gdoc: {msg}", file=sys.stderr)
322
+ sys.exit(1)
323
+
324
+
325
+ ID_RE = re.compile(r"[A-Za-z0-9_-]{20,}")
326
+
327
+
328
+ def doc_id_from(s: str) -> str:
329
+ """Extract a Drive file id from an id or any Docs/Drive URL.
330
+
331
+ Handles .../d/<id>/edit?tab=t.0#heading=..., /u/1/d/<id>, /view, /copy,
332
+ open?id=<id>, uc?id=<id>&export=..., and ids with stray ?/#/quotes.
333
+ """
334
+ from urllib.parse import parse_qs, urlsplit
335
+
336
+ s = s.strip().strip("'\"<>")
337
+ url = urlsplit(s)
338
+ parts = [p for p in url.path.split("/") if p]
339
+ if "d" in parts[:-1]: # .../d/<id>/...
340
+ cand = parts[parts.index("d") + 1]
341
+ else:
342
+ query = parse_qs(url.query) or parse_qs(url.fragment.lstrip("?"))
343
+ # ?id=<id>, else the last path segment (folders/<id>, or a bare id)
344
+ cand = (query.get("id") or [""])[0] or (parts[-1] if parts else "")
345
+ m = ID_RE.search(cand)
346
+ if not m:
347
+ die(f"can't find a doc id in {s!r}")
348
+ return m.group(0)
349
+
350
+
351
+ def slug(name: str) -> str:
352
+ return re.sub(r"[^A-Za-z0-9]+", "-", name).strip("-").lower() or "untitled"
353
+
354
+
355
+ def looks_like_file(s: str) -> bool:
356
+ return s.endswith(".md") or Path(s).exists()
357
+
358
+
359
+ def drive_ok(drive: Drive) -> str:
360
+ """Return '' if the account can use Drive, else a short reason."""
361
+ try:
362
+ drive.call("GET", f"{API}/files", params={"pageSize": 1, "fields": "files(id)"}, raise_errors=True)
363
+ return ""
364
+ except HttpError as e:
365
+ return "no Drive access" if e.status in (401, 403) else f"error {e.status}"
366
+
367
+
368
+ # ----------------------------------------------------------------- commands
369
+
370
+ Force = Annotated[bool, Parameter(name=["--force", "-f"], negative="")]
371
+ Account = Annotated[str | None, Parameter(name=["--account", "-a"])]
372
+
373
+ app = App(name="gdoc", help="Pull/push Google Docs as Markdown.", version_flags=[])
374
+
375
+
376
+ @app.command
377
+ def pull(
378
+ target: str, out: Path | None = None, *, account: Account = None, force: Force = False
379
+ ) -> None:
380
+ """Download a doc as markdown.
381
+
382
+ Parameters
383
+ ----------
384
+ target
385
+ Doc id, doc URL, or an already-linked .md file.
386
+ out
387
+ Output file (default: the doc title, slugified, + .md).
388
+ account
389
+ Google account (default: the file's gdoc_account, else gcloud's active account).
390
+ force
391
+ Overwrite local changes.
392
+ """
393
+ path = None
394
+ if looks_like_file(target):
395
+ fm, _ = read(Path(target))
396
+ if "gdoc_id" not in fm:
397
+ die(f"{target} has no gdoc_id front matter; pass a doc id or URL")
398
+ doc_id = fm["gdoc_id"]
399
+ account = account or fm.get("gdoc_account")
400
+ path = out or Path(target)
401
+ else:
402
+ doc_id = doc_id_from(target)
403
+
404
+ drive = connect(account)
405
+ info = drive.meta(doc_id)
406
+ path = path or out or Path(f"{slug(info['name'])}.md")
407
+
408
+ if path.exists() and not force:
409
+ fm, body = read(path)
410
+ if fm.get("gdoc_id") and fm["gdoc_id"] != doc_id:
411
+ die(f"{path} is linked to a different doc ({fm['gdoc_id']}); use --force")
412
+ if not fm or fm.get("gdoc_hash") != digest(body):
413
+ die(f"{path} has local changes that would be overwritten; push first or use --force")
414
+
415
+ write_synced(path, drive, info, drive.export(doc_id))
416
+ print(f"pulled '{info['name']}' -> {path}")
417
+
418
+
419
+ @app.command
420
+ def push(
421
+ file: Path, doc: str | None = None, *, account: Account = None, force: Force = False
422
+ ) -> None:
423
+ """Replace a doc's content with a markdown file.
424
+
425
+ Parameters
426
+ ----------
427
+ file
428
+ Linked .md file.
429
+ doc
430
+ Doc id/URL (default: from front matter).
431
+ account
432
+ Google account (default: the file's gdoc_account, else gcloud's active account).
433
+ force
434
+ Overwrite remote changes.
435
+ """
436
+ fm, body = read(file)
437
+ doc_id = doc_id_from(doc) if doc else fm.get("gdoc_id")
438
+ if not doc_id:
439
+ die(f"{file} has no gdoc_id; pass a doc id/URL or use '{PROG} new'")
440
+
441
+ drive = connect(account or fm.get("gdoc_account"))
442
+ info = drive.meta(doc_id)
443
+ if not force:
444
+ if fm.get("gdoc_id") != doc_id:
445
+ die(f"{file} was not pulled from this doc; pushing replaces its whole body. Use --force")
446
+ if fm.get("gdoc_hash") == digest(body):
447
+ print("no local changes; nothing to push")
448
+ return
449
+ if digest(drive.export(doc_id)) != fm.get("gdoc_hash"):
450
+ die(
451
+ f"'{info['name']}' was edited in Google Docs since your last sync.\n"
452
+ "Save your edits elsewhere and pull, or use --force to overwrite."
453
+ )
454
+
455
+ info = drive.update(doc_id, body)
456
+ # Re-export so the local file matches Google's normalized markdown.
457
+ write_synced(file, drive, info, drive.export(doc_id))
458
+ print(f"pushed {file} -> '{info['name']}'")
459
+
460
+
461
+ @app.command
462
+ def new(
463
+ file: Path,
464
+ *,
465
+ title: Annotated[str | None, Parameter(name=["--title", "-t"])] = None,
466
+ folder: str | None = None,
467
+ account: Account = None,
468
+ force: Force = False,
469
+ ) -> None:
470
+ """Create a new doc from a markdown file.
471
+
472
+ Parameters
473
+ ----------
474
+ file
475
+ Markdown file to upload.
476
+ title
477
+ Doc title (default: file name).
478
+ folder
479
+ Drive folder id/URL.
480
+ account
481
+ Google account that will own the doc (default: gcloud's active account).
482
+ force
483
+ Create even if the file is already linked to a doc.
484
+ """
485
+ fm, body = read(file)
486
+ if fm.get("gdoc_id") and not force:
487
+ die(f"{file} is already linked to {fm['gdoc_id']}; use push, or --force to create another")
488
+ body_meta: dict[str, object] = {"name": title or file.stem, "mimeType": GDOC}
489
+ if folder:
490
+ body_meta["parents"] = [doc_id_from(folder)]
491
+ drive = connect(account)
492
+ info = drive.create(body_meta, body)
493
+ write_synced(file, drive, info, drive.export(info["id"]))
494
+ print(f"created '{info['name']}' https://docs.google.com/document/d/{info['id']}/edit")
495
+
496
+
497
+ @app.command
498
+ def status(*files: Path) -> None:
499
+ """Show sync state (and account) of linked .md files.
500
+
501
+ Parameters
502
+ ----------
503
+ files
504
+ Files to check (default: *.md in the current directory).
505
+ """
506
+ drives = Connections()
507
+ for path in files or sorted(Path(".").glob("*.md")):
508
+ fm, body = read(path)
509
+ if "gdoc_id" not in fm:
510
+ continue
511
+ drive = drives[fm.get("gdoc_account")]
512
+ local = fm.get("gdoc_hash") != digest(body)
513
+ remote = fm.get("gdoc_hash") != digest(drive.export(fm["gdoc_id"]))
514
+ state = {
515
+ (False, False): "in sync",
516
+ (True, False): "local changes (push)",
517
+ (False, True): "remote changes (pull)",
518
+ (True, True): "CONFLICT (both changed)",
519
+ }[(local, remote)]
520
+ who = f" [{drive.account}]" if drive.account else ""
521
+ print(f"{path}: {state}{who}")
522
+
523
+
524
+ @app.command
525
+ def ls(
526
+ query: str | None = None,
527
+ *,
528
+ limit: Annotated[int, Parameter(name=["--limit", "-n"])] = 20,
529
+ account: Account = None,
530
+ ) -> None:
531
+ """List recent Google Docs.
532
+
533
+ Parameters
534
+ ----------
535
+ query
536
+ Filter by name.
537
+ limit
538
+ Max docs to show.
539
+ account
540
+ Google account (default: gcloud's active account).
541
+ """
542
+ q = f"mimeType='{GDOC}' and trashed=false"
543
+ if query:
544
+ q += " and name contains '{}'".format(query.replace("'", "\\'"))
545
+ files = connect(account).files(
546
+ q=q,
547
+ orderBy="modifiedTime desc",
548
+ pageSize=limit,
549
+ fields="files(id,name,modifiedTime)",
550
+ includeItemsFromAllDrives="true",
551
+ supportsAllDrives="true",
552
+ )
553
+ for f in files:
554
+ print(f"{f['id']} {f['modifiedTime'][:10]} {f['name']}")
555
+
556
+
557
+ @app.command
558
+ def account(
559
+ email: str | None = None,
560
+ *,
561
+ login: Annotated[bool, Parameter(negative="")] = False,
562
+ ) -> None:
563
+ """List Google accounts and their Drive access, or switch the active one.
564
+
565
+ With EMAIL: switch gcloud's active account to it, logging in first
566
+ (with Drive access) if it isn't logged in yet.
567
+
568
+ Parameters
569
+ ----------
570
+ email
571
+ Account to switch to.
572
+ login
573
+ Force a fresh browser login with Drive access (fixes "no Drive access").
574
+ """
575
+ if email:
576
+ known = {a["account"] for a in gcloud_accounts()}
577
+ if login or email not in known:
578
+ # Interactive: opens a browser, so don't capture output.
579
+ r = gcloud("auth", "login", email, "--enable-gdrive-access", capture=False)
580
+ if r.returncode != 0:
581
+ die("login failed")
582
+ r = gcloud("config", "set", "account", email)
583
+ if r.returncode != 0:
584
+ die(r.stderr.strip())
585
+ print(f"switched to {email}")
586
+ elif login:
587
+ die("--login needs an EMAIL")
588
+
589
+ accounts = gcloud_accounts()
590
+ if not accounts:
591
+ die(f"no gcloud accounts; run: {PROG} account <email>")
592
+ for a in accounts:
593
+ mark = "*" if a.get("status") == "ACTIVE" else " "
594
+ problem = drive_ok(connect(a["account"]))
595
+ note = f"{problem} (run: {LOGIN_HINT.format(a['account'])})" if problem else "Drive ok"
596
+ print(f"{mark} {a['account']} {note}")
597
+
598
+
599
+ def main() -> None: # for `python -m bhtool.gdoc`; bhtool mounts `app` directly
600
+ try:
601
+ app()
602
+ except KeyboardInterrupt:
603
+ sys.exit(130)
604
+
605
+
606
+ if __name__ == "__main__":
607
+ main()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bhtool"
3
- version = "0.2.5"
3
+ version = "0.3.0"
4
4
  description = "Assorted CLI utilities for Bosco’s workflows (dev, media, macOS, LLM helpers)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes