@champpaba/gslide 0.1.3 → 0.1.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@champpaba/gslide",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "CLI tool for automating Google Slides 'Help me visualize' feature via browser automation",
5
5
  "bin": {
6
6
  "gslide": "./bin/gslide.mjs"
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gslide"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "CLI tool for automating Google Slides 'Help me visualize' feature via browser automation"
5
5
  requires-python = ">=3.10"
6
6
  dependencies = [
@@ -1,3 +1,4 @@
1
- """gslide — CLI for automating Google Slides 'Help me visualize' feature."""
1
+ """gslide — CLI for automating Google Slides 'Help me visualise' feature."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.5"
4
+ __all__ = ["__version__"]
@@ -1,39 +1,32 @@
1
1
  """Authentication session management — file I/O and browser operations."""
2
2
 
3
- import json
4
3
  import sys
5
4
  from pathlib import Path
6
- from typing import Any
7
5
 
8
6
  import click
9
7
 
10
-
11
8
  SLIDES_URL = "https://docs.google.com/presentation/"
12
9
 
13
10
 
14
11
  def get_storage_path() -> Path:
15
- """Return the path to the storage state file."""
16
12
  return Path.home() / ".gslide" / "storage_state.json"
17
13
 
18
14
 
19
15
  def is_logged_in() -> bool:
20
- """Check if a storage state file exists."""
21
16
  return get_storage_path().exists()
22
17
 
23
18
 
24
- def save_storage_state(data: dict[str, Any]) -> None:
25
- """Save storage state data to disk, creating directories as needed."""
26
- path = get_storage_path()
27
- path.parent.mkdir(parents=True, exist_ok=True)
28
- path.write_text(json.dumps(data, indent=2))
19
+ def require_login() -> Path:
20
+ """Return storage path or exit if not logged in."""
21
+ storage_path = get_storage_path()
22
+ if not storage_path.exists():
23
+ click.echo("Not logged in. Run: gslide auth login", err=True)
24
+ sys.exit(1)
25
+ return storage_path
29
26
 
30
27
 
31
28
  def delete_storage_state() -> None:
32
- """Delete the storage state file. No error if missing."""
33
- try:
34
- get_storage_path().unlink()
35
- except FileNotFoundError:
36
- pass
29
+ get_storage_path().unlink(missing_ok=True)
37
30
 
38
31
 
39
32
  def login() -> None:
@@ -64,11 +57,7 @@ def login() -> None:
64
57
 
65
58
  def status() -> None:
66
59
  """Check if saved session is still valid."""
67
- path = get_storage_path()
68
-
69
- if not path.exists():
70
- click.echo("Not logged in. Run: gslide auth login")
71
- sys.exit(1)
60
+ path = require_login()
72
61
 
73
62
  from gslide.browser import BrowserSession
74
63
 
@@ -3,7 +3,7 @@
3
3
  from pathlib import Path
4
4
  from typing import Any
5
5
 
6
- from playwright.sync_api import sync_playwright, BrowserContext, Playwright
6
+ from playwright.sync_api import BrowserContext, Playwright, sync_playwright
7
7
 
8
8
 
9
9
  class BrowserSession:
@@ -32,7 +32,6 @@ class BrowserSession:
32
32
  self._context.browser.close()
33
33
  if self._pw:
34
34
  self._pw.stop()
35
- return None
36
35
 
37
36
 
38
37
  def save_session(context: BrowserContext, path: Path) -> None:
package/src/gslide/gen.py CHANGED
@@ -16,11 +16,10 @@ import time
16
16
  import click
17
17
  from playwright.sync_api import Page, TimeoutError as PwTimeout
18
18
 
19
- from gslide.auth import get_storage_path
19
+ from gslide.auth import require_login
20
20
  from gslide.browser import BrowserSession
21
21
  from gslide.prompts import PromptsData
22
22
 
23
-
24
23
  PRESENTATION_URL = "https://docs.google.com/presentation/d/{id}/edit"
25
24
  DEFAULT_TIMEOUT_MS = 120_000 # 120s — generation can take 30-60s
26
25
  PANEL_LOAD_TIMEOUT_MS = 10_000
@@ -53,8 +52,8 @@ def navigate_to_presentation(page: Page, presentation_id: str) -> None:
53
52
  page.wait_for_selector(
54
53
  '[aria-label="filmstrip"]', timeout=SLIDES_LOAD_TIMEOUT_MS
55
54
  )
56
- except PwTimeout:
57
- raise GenerationError("Google Slides UI did not load in time")
55
+ except PwTimeout as e:
56
+ raise GenerationError("Google Slides UI did not load in time") from e
58
57
 
59
58
 
60
59
  def open_panel(page: Page) -> None:
@@ -71,16 +70,10 @@ def open_panel(page: Page) -> None:
71
70
 
72
71
 
73
72
  def _reopen_panel(page: Page, retries: int = 2) -> None:
74
- """Reopen the HMV panel after insert overlay is dismissed.
75
-
76
- After inserting a slide, the panel may close. This clicks the HMV icon
77
- and waits for tabs to reappear, with retry logic.
78
- """
73
+ """Reopen the HMV panel after insert overlay is dismissed."""
79
74
  for attempt in range(retries + 1):
80
75
  try:
81
- hmv = page.locator('div[aria-label="Help me visualize"]')
82
- hmv.click()
83
- page.wait_for_selector('[role="tab"]', timeout=PANEL_LOAD_TIMEOUT_MS)
76
+ open_panel(page)
84
77
  return
85
78
  except (PwTimeout, Exception):
86
79
  if attempt < retries:
@@ -97,12 +90,9 @@ def select_tab(page: Page, tab_name: str) -> None:
97
90
 
98
91
 
99
92
  def _find_visible_textarea(page: Page) -> object:
100
- """Find the first visible textarea in the panel."""
101
- textareas = page.locator("textarea")
102
- for i in range(textareas.count()):
103
- ta = textareas.nth(i)
104
- if ta.is_visible():
105
- return ta
93
+ ta = page.locator("textarea").first
94
+ if ta.is_visible():
95
+ return ta
106
96
  raise GenerationError("No visible text input found in panel")
107
97
 
108
98
 
@@ -179,12 +169,7 @@ def fill_and_create(
179
169
 
180
170
 
181
171
  def _click_preview_image(page: Page) -> None:
182
- """Click the generated preview image to open the insert overlay.
183
-
184
- Validated flow: after generation, the preview appears as an img hosted on
185
- googleusercontent.com. Clicking it opens a fullscreen preview with
186
- "Insert on new slide" button.
187
- """
172
+ """Click the generated preview image to open the insert overlay."""
188
173
  preview = page.locator('img[src*="googleusercontent.com"]')
189
174
  for i in range(preview.count()):
190
175
  try:
@@ -203,73 +188,48 @@ def _click_preview_image(page: Page) -> None:
203
188
  raise GenerationError("Generated preview image not found")
204
189
 
205
190
 
206
- def insert_infographic(page: Page) -> None:
207
- """Click preview 'Insert on new slide' for infographic tab."""
208
- _click_preview_image(page)
209
-
210
- btn = page.get_by_text("Insert on new slide")
191
+ def _click_insert_button(page: Page, text: str) -> None:
192
+ """Click an insert button by text, raising GenerationError if not found."""
193
+ btn = page.get_by_text(text)
211
194
  if btn.count() > 0 and btn.first.is_visible():
212
195
  btn.first.click()
213
196
  else:
214
- raise GenerationError("'Insert on new slide' button not found after clicking preview")
215
- _wait_for_new_slide(page)
197
+ raise GenerationError(f"'{text}' button not found after clicking preview")
216
198
 
217
199
 
218
- def insert_slide(page: Page) -> None:
219
- """Click preview 'Insert on new slide' for slide tab."""
200
+ def _insert_on_new_slide(page: Page) -> None:
201
+ """Click preview then 'Insert on new slide' (used by both infographic and slide tabs)."""
220
202
  _click_preview_image(page)
221
-
222
- # Slide tab uses same "Insert on new slide" button after clicking preview
223
- btn = page.get_by_text("Insert on new slide")
224
- if btn.count() > 0 and btn.first.is_visible():
225
- btn.first.click()
226
- else:
227
- raise GenerationError("'Insert on new slide' button not found for slide tab")
228
- _wait_for_new_slide(page)
203
+ _click_insert_button(page, "Insert on new slide")
204
+ page.wait_for_timeout(2000)
229
205
 
230
206
 
231
207
  def insert_image(page: Page, insert_as: str = "image") -> None:
232
- """Click preview insert as image or background."""
208
+ """Click preview then insert as image or background."""
233
209
  _click_preview_image(page)
234
210
 
235
- # Image tab may show different insert options via dropdown
236
- if insert_as == "background":
237
- option_text = "Insert as background"
238
- else:
239
- option_text = "Insert as image"
211
+ option_text = "Insert as background" if insert_as == "background" else "Insert as image"
240
212
 
241
- btn = page.get_by_text(option_text)
242
- if btn.count() > 0 and btn.first.is_visible():
243
- btn.first.click()
244
- else:
245
- # Fallback: try "Insert on new slide" or dropdown
246
- fallback = page.get_by_text("Insert on new slide")
247
- if fallback.count() > 0 and fallback.first.is_visible():
248
- fallback.first.click()
249
- else:
250
- raise GenerationError(f"'{option_text}' button not found")
213
+ try:
214
+ _click_insert_button(page, option_text)
215
+ except GenerationError:
216
+ # Fallback: try "Insert on new slide"
217
+ _click_insert_button(page, "Insert on new slide")
251
218
 
252
219
  page.wait_for_timeout(2000)
253
220
 
254
221
 
255
-
256
222
  def check_url(page: Page, presentation_id: str) -> None:
257
- """Verify browser URL still contains the target presentation ID."""
258
223
  if presentation_id not in page.url:
259
224
  raise GenerationError("Browser navigated away from target presentation")
260
225
 
261
226
 
262
- def _wait_for_new_slide(page: Page) -> None:
263
- """Wait for new slide to appear in filmstrip."""
264
- page.wait_for_timeout(2000)
265
-
266
-
267
227
  # --- Orchestration ---
268
228
 
269
229
 
270
230
  _INSERT_FN = {
271
- "infographic": lambda page, **_: insert_infographic(page),
272
- "slide": lambda page, **_: insert_slide(page),
231
+ "infographic": lambda page, **_: _insert_on_new_slide(page),
232
+ "slide": lambda page, **_: _insert_on_new_slide(page),
273
233
  "image": lambda page, **opts: insert_image(page, opts.get("insert_as", "image")),
274
234
  }
275
235
 
@@ -284,11 +244,7 @@ def gen_single(
284
244
  insert_as: str = "image",
285
245
  ) -> None:
286
246
  """Generate a single slide/infographic/image via browser automation."""
287
- storage_path = get_storage_path()
288
- if not storage_path.exists():
289
- click.echo("Not logged in. Run: gslide auth login", err=True)
290
- sys.exit(1)
291
-
247
+ storage_path = require_login()
292
248
  timeout_ms = timeout * 1000
293
249
 
294
250
  with BrowserSession(storage_state=storage_path) as context:
@@ -296,7 +252,6 @@ def gen_single(
296
252
 
297
253
  try:
298
254
  navigate_to_presentation(page, presentation_id)
299
- check_url(page, presentation_id)
300
255
 
301
256
  if tab == "image" and slide_index is not None:
302
257
  _navigate_to_slide(page, slide_index)
@@ -315,7 +270,6 @@ def gen_single(
315
270
 
316
271
  except GenerationError as e:
317
272
  click.echo(f"Error: {e}", err=True)
318
- # Take screenshot for debugging
319
273
  try:
320
274
  page.screenshot(path="/tmp/gslide_error.png")
321
275
  click.echo("Debug screenshot saved to /tmp/gslide_error.png")
@@ -344,11 +298,7 @@ def gen_batch(
344
298
  timeout: int = 120,
345
299
  ) -> None:
346
300
  """Generate all slides from prompts data in a single browser session."""
347
- storage_path = get_storage_path()
348
- if not storage_path.exists():
349
- click.echo("Not logged in. Run: gslide auth login", err=True)
350
- sys.exit(1)
351
-
301
+ storage_path = require_login()
352
302
  timeout_ms = timeout * 1000
353
303
  total_slides = len(prompts_data.slides)
354
304
  total_images = len(prompts_data.images)
@@ -397,7 +347,7 @@ def gen_batch(
397
347
  page.wait_for_timeout(500)
398
348
  _reopen_panel(page)
399
349
 
400
- except (GenerationError, PwTimeout, Exception) as e:
350
+ except Exception as e:
401
351
  errors.append((i, slide.tab, str(e)))
402
352
  click.echo(f" {label} FAILED: {e}")
403
353
  if not continue_on_error:
@@ -412,7 +362,7 @@ def gen_batch(
412
362
  check_url(page, prompts_data.presentation_id)
413
363
  _navigate_to_slide(page, img.target_slide)
414
364
 
415
- if "image" != current_tab:
365
+ if current_tab != "image":
416
366
  select_tab(page, "Image")
417
367
  current_tab = "image"
418
368
 
@@ -423,13 +373,12 @@ def gen_batch(
423
373
  elapsed = time.monotonic() - t0
424
374
  click.echo(f" {label} done ({elapsed:.1f}s)")
425
375
 
426
- except (GenerationError, PwTimeout, Exception) as e:
376
+ except Exception as e:
427
377
  errors.append((total_slides + i, "image", str(e)))
428
378
  click.echo(f" {label} FAILED: {e}")
429
379
  if not continue_on_error:
430
380
  break
431
381
 
432
-
433
382
  # Summary
434
383
  total_elapsed = time.monotonic() - start_time
435
384
  total_ops = total_slides + total_images
@@ -442,5 +391,5 @@ def gen_batch(
442
391
  for idx, tab, err in errors:
443
392
  click.echo(f" #{idx} ({tab}): {err}")
444
393
  click.echo(
445
- f"Presentation: https://docs.google.com/presentation/d/{prompts_data.presentation_id}/edit"
394
+ f"Presentation: {PRESENTATION_URL.format(id=prompts_data.presentation_id)}"
446
395
  )
@@ -33,9 +33,7 @@ class PromptsData:
33
33
 
34
34
 
35
35
  def load_prompts(path: Path) -> PromptsData:
36
- """Load and validate a prompts.json file."""
37
- with open(path) as f:
38
- raw = json.load(f)
36
+ raw = json.loads(path.read_text())
39
37
 
40
38
  if not isinstance(raw, dict):
41
39
  raise ValidationError("Prompts file must be a JSON object")