@champpaba/gslide 0.1.0

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/bin/gslide.mjs ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const pkgRoot = join(__dirname, "..");
10
+ const venvBin = join(pkgRoot, ".venv", "bin");
11
+ const gslideExe = join(venvBin, "gslide");
12
+
13
+ if (!existsSync(gslideExe)) {
14
+ console.error("gslide Python package not installed.");
15
+ console.error("Run: npm rebuild @ChampPABA/gslide");
16
+ process.exit(1);
17
+ }
18
+
19
+ const args = process.argv.slice(2);
20
+ const child = spawn(gslideExe, args, {
21
+ stdio: "inherit",
22
+ env: { ...process.env, PATH: `${venvBin}:${process.env.PATH}` },
23
+ });
24
+
25
+ child.on("close", (code) => process.exit(code ?? 1));
26
+ child.on("error", (err) => {
27
+ console.error(`Failed to start gslide: ${err.message}`);
28
+ process.exit(1);
29
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@champpaba/gslide",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool for automating Google Slides 'Help me visualize' feature via browser automation",
5
+ "bin": {
6
+ "gslide": "./bin/gslide.mjs"
7
+ },
8
+ "scripts": {
9
+ "postinstall": "bash scripts/postinstall.sh"
10
+ },
11
+ "files": [
12
+ "bin/",
13
+ "scripts/",
14
+ "src/",
15
+ "pyproject.toml"
16
+ ],
17
+ "keywords": [
18
+ "google-slides",
19
+ "presentation",
20
+ "gemini",
21
+ "browser-automation",
22
+ "playwright"
23
+ ],
24
+ "author": "ChampPABA",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/ChampPABA/gslide.git"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "engines": {
34
+ "node": ">=16"
35
+ }
36
+ }
package/pyproject.toml ADDED
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "gslide"
3
+ version = "0.1.0"
4
+ description = "CLI tool for automating Google Slides 'Help me visualize' feature via browser automation"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "playwright>=1.40.0",
8
+ "click>=8.1.0",
9
+ ]
10
+
11
+ [project.optional-dependencies]
12
+ dev = [
13
+ "pytest>=7.4.0",
14
+ "pytest-cov>=4.1.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ gslide = "gslide.cli:cli"
19
+
20
+ [build-system]
21
+ requires = ["setuptools>=68.0"]
22
+ build-backend = "setuptools.build_meta"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+ addopts = "--tb=short -q"
@@ -0,0 +1,62 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ PKG_DIR="$(cd "$(dirname "$0")/.." && pwd)"
5
+ VENV_DIR="$PKG_DIR/.venv"
6
+ UV_BIN=""
7
+
8
+ echo "Installing gslide..."
9
+
10
+ # Step 1: Check/install uv
11
+ echo " [1/4] Checking uv..."
12
+ if command -v uv &>/dev/null; then
13
+ UV_BIN="$(command -v uv)"
14
+ echo " found: $UV_BIN"
15
+ else
16
+ echo " not found, installing..."
17
+ curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null
18
+ export PATH="$HOME/.local/bin:$PATH"
19
+ if command -v uv &>/dev/null; then
20
+ UV_BIN="$(command -v uv)"
21
+ echo " installed: $UV_BIN"
22
+ else
23
+ echo " ERROR: Failed to install uv. Install manually: https://docs.astral.sh/uv/"
24
+ exit 1
25
+ fi
26
+ fi
27
+
28
+ # Step 2: Check/install Python
29
+ echo " [2/4] Checking Python 3.10+..."
30
+ PYTHON_VERSION=$("$UV_BIN" python find 3.10 2>/dev/null || true)
31
+ if [ -z "$PYTHON_VERSION" ]; then
32
+ echo " not found, installing via uv..."
33
+ "$UV_BIN" python install 3.10 2>/dev/null
34
+ PYTHON_VERSION=$("$UV_BIN" python find 3.10 2>/dev/null || true)
35
+ fi
36
+ if [ -z "$PYTHON_VERSION" ]; then
37
+ echo " ERROR: Could not install Python 3.10+."
38
+ exit 1
39
+ fi
40
+ echo " found: $PYTHON_VERSION"
41
+
42
+ # Step 3: Create venv and install gslide
43
+ echo " [3/4] Installing gslide package..."
44
+ if [ ! -d "$VENV_DIR" ]; then
45
+ "$UV_BIN" venv "$VENV_DIR" --python 3.10 2>/dev/null
46
+ fi
47
+ "$UV_BIN" pip install --python "$VENV_DIR/bin/python" -e "$PKG_DIR" 2>/dev/null
48
+ echo " done"
49
+
50
+ # Step 4: Install Playwright Chromium
51
+ echo " [4/4] Installing Chromium browser..."
52
+ PLAYWRIGHT_BIN="$VENV_DIR/bin/playwright"
53
+ if [ -f "$PLAYWRIGHT_BIN" ]; then
54
+ "$PLAYWRIGHT_BIN" install chromium 2>/dev/null
55
+ echo " done"
56
+ else
57
+ echo " WARNING: playwright not found, skipping Chromium install"
58
+ echo " Run manually: $VENV_DIR/bin/playwright install chromium"
59
+ fi
60
+
61
+ echo ""
62
+ echo "Ready! Run: gslide auth login"
@@ -0,0 +1,3 @@
1
+ """gslide — CLI for automating Google Slides 'Help me visualize' feature."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,95 @@
1
+ """Authentication session management — file I/O and browser operations."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import click
9
+
10
+
11
+ SLIDES_URL = "https://docs.google.com/presentation/"
12
+
13
+
14
+ def get_storage_path() -> Path:
15
+ """Return the path to the storage state file."""
16
+ return Path.home() / ".gslide" / "storage_state.json"
17
+
18
+
19
+ def is_logged_in() -> bool:
20
+ """Check if a storage state file exists."""
21
+ return get_storage_path().exists()
22
+
23
+
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))
29
+
30
+
31
+ 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
37
+
38
+
39
+ def login() -> None:
40
+ """Launch headed browser for Google login, save storage state on success."""
41
+ from gslide.browser import BrowserSession, save_session
42
+
43
+ click.echo("Opening browser for Google login...")
44
+ click.echo("")
45
+ click.echo("Instructions:")
46
+ click.echo("1. Complete the Google login in the browser window")
47
+ click.echo("2. Wait until you see Google Slides homepage")
48
+ click.echo("3. Press ENTER here to save and close")
49
+ click.echo("")
50
+
51
+ with BrowserSession(headed=True) as context:
52
+ page = context.new_page()
53
+ page.goto(SLIDES_URL)
54
+
55
+ try:
56
+ input("[Press ENTER when logged in] ")
57
+ except (KeyboardInterrupt, EOFError):
58
+ click.echo("\nAborted.", err=True)
59
+ sys.exit(1)
60
+
61
+ save_session(context, get_storage_path())
62
+ click.echo("Session saved.")
63
+
64
+
65
+ def status() -> None:
66
+ """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)
72
+
73
+ from gslide.browser import BrowserSession
74
+
75
+ with BrowserSession(headed=False, storage_state=path) as context:
76
+ page = context.new_page()
77
+ page.goto(SLIDES_URL, wait_until="domcontentloaded")
78
+ # Google Slides never reaches networkidle — wait for redirect or UI instead
79
+ page.wait_for_timeout(5000)
80
+
81
+ if "accounts.google.com" in page.url:
82
+ click.echo("Session expired. Run: gslide auth login")
83
+ sys.exit(1)
84
+
85
+ click.echo("Session valid.")
86
+
87
+
88
+ def logout() -> None:
89
+ """Delete saved session."""
90
+ if not is_logged_in():
91
+ click.echo("Not logged in.")
92
+ return
93
+
94
+ delete_storage_state()
95
+ click.echo("Logged out.")
@@ -0,0 +1,41 @@
1
+ """Playwright browser lifecycle management."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from playwright.sync_api import sync_playwright, BrowserContext, Playwright
7
+
8
+
9
+ class BrowserSession:
10
+ """Manages a Playwright browser context with optional storage state."""
11
+
12
+ def __init__(self, headed: bool = False, storage_state: Path | None = None) -> None:
13
+ self._headed = headed
14
+ self._storage_state = storage_state
15
+ self._pw: Playwright | None = None
16
+ self._context: BrowserContext | None = None
17
+
18
+ def __enter__(self) -> BrowserContext:
19
+ self._pw = sync_playwright().start()
20
+ browser = self._pw.chromium.launch(headless=not self._headed)
21
+
22
+ context_opts: dict[str, Any] = {}
23
+ if self._storage_state and self._storage_state.exists():
24
+ context_opts["storage_state"] = str(self._storage_state)
25
+
26
+ self._context = browser.new_context(**context_opts)
27
+ return self._context
28
+
29
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
30
+ if self._context:
31
+ self._context.close()
32
+ self._context.browser.close()
33
+ if self._pw:
34
+ self._pw.stop()
35
+ return None
36
+
37
+
38
+ def save_session(context: BrowserContext, path: Path) -> None:
39
+ """Export browser context storage state to a file."""
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ context.storage_state(path=str(path))
@@ -0,0 +1,178 @@
1
+ """Click CLI entry point for gslide."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+
9
+ @click.group()
10
+ def cli() -> None:
11
+ """gslide — Automate Google Slides 'Help me visualize' feature."""
12
+
13
+
14
+ @cli.command()
15
+ def update() -> None:
16
+ """Check for updates and self-update via npm."""
17
+ import subprocess
18
+
19
+ from gslide import __version__
20
+
21
+ click.echo(f"Current version: v{__version__}")
22
+ click.echo("Checking for updates...")
23
+
24
+ try:
25
+ result = subprocess.run(
26
+ ["npm", "view", "@ChampPABA/gslide", "version"],
27
+ capture_output=True,
28
+ text=True,
29
+ timeout=15,
30
+ )
31
+ latest = result.stdout.strip()
32
+ except (subprocess.TimeoutExpired, FileNotFoundError):
33
+ click.echo("Could not check npm registry.", err=True)
34
+ sys.exit(1)
35
+
36
+ if not latest:
37
+ click.echo("Could not determine latest version.", err=True)
38
+ sys.exit(1)
39
+
40
+ if latest == __version__:
41
+ click.echo(f"Already up to date (v{__version__}).")
42
+ return
43
+
44
+ click.echo(f"Latest version: v{latest}")
45
+ click.echo("Updating...")
46
+
47
+ proc = subprocess.run(
48
+ ["npm", "update", "-g", "@ChampPABA/gslide"],
49
+ timeout=120,
50
+ )
51
+ if proc.returncode == 0:
52
+ click.echo(f"Updated to v{latest}.")
53
+ else:
54
+ click.echo("Update failed.", err=True)
55
+ sys.exit(1)
56
+
57
+
58
+ # --- Auth commands ---
59
+
60
+
61
+ @cli.group()
62
+ def auth() -> None:
63
+ """Manage Google authentication session."""
64
+
65
+
66
+ @auth.command()
67
+ def login() -> None:
68
+ """Launch browser for Google login."""
69
+ from gslide.auth import login as do_login
70
+
71
+ do_login()
72
+
73
+
74
+ @auth.command()
75
+ def status() -> None:
76
+ """Check if saved session is valid."""
77
+ from gslide.auth import status as do_status
78
+
79
+ do_status()
80
+
81
+
82
+ @auth.command()
83
+ def logout() -> None:
84
+ """Delete saved session."""
85
+ from gslide.auth import logout as do_logout
86
+
87
+ do_logout()
88
+
89
+
90
+ # --- Gen commands ---
91
+
92
+
93
+ @cli.group()
94
+ def gen() -> None:
95
+ """Generate slides, infographics, and images."""
96
+
97
+
98
+ def _common_gen_options(f):
99
+ """Shared options for single-gen commands."""
100
+ f = click.option("--presentation", required=True, help="Presentation ID or URL")(f)
101
+ f = click.option("--prompt", required=True, help="Generation prompt")(f)
102
+ f = click.option("--timeout", default=120, type=int, help="Timeout in seconds")(f)
103
+ return f
104
+
105
+
106
+ @gen.command()
107
+ @_common_gen_options
108
+ def infographic(presentation: str, prompt: str, timeout: int) -> None:
109
+ """Generate an infographic slide."""
110
+ from gslide.gen import gen_single
111
+
112
+ gen_single(presentation, "infographic", prompt, timeout=timeout)
113
+
114
+
115
+ @gen.command()
116
+ @_common_gen_options
117
+ def slide(presentation: str, prompt: str, timeout: int) -> None:
118
+ """Generate a slide."""
119
+ from gslide.gen import gen_single
120
+
121
+ gen_single(presentation, "slide", prompt, timeout=timeout)
122
+
123
+
124
+ @gen.command()
125
+ @_common_gen_options
126
+ @click.option("--slide-index", required=True, type=int, help="Target slide index")
127
+ @click.option(
128
+ "--insert-as",
129
+ default="image",
130
+ type=click.Choice(["image", "background"]),
131
+ help="Insert as image or background",
132
+ )
133
+ def image(
134
+ presentation: str,
135
+ prompt: str,
136
+ timeout: int,
137
+ slide_index: int,
138
+ insert_as: str,
139
+ ) -> None:
140
+ """Generate and insert an image on a slide."""
141
+ from gslide.gen import gen_single
142
+
143
+ gen_single(
144
+ presentation,
145
+ "image",
146
+ prompt,
147
+ timeout=timeout,
148
+ slide_index=slide_index,
149
+ insert_as=insert_as,
150
+ )
151
+
152
+
153
+ @gen.command()
154
+ @click.option("--file", "file_path", required=True, type=click.Path(exists=True), help="Path to prompts.json")
155
+ @click.option("--continue-on-error", is_flag=True, help="Continue on individual slide errors")
156
+ @click.option("--dry-run", is_flag=True, help="Validate and show summary without generating")
157
+ @click.option("--timeout", default=60, type=int, help="Timeout per slide in seconds")
158
+ def batch(file_path: str, continue_on_error: bool, dry_run: bool, timeout: int) -> None:
159
+ """Generate slides from a prompts.json file."""
160
+ from gslide.prompts import load_prompts, ValidationError
161
+
162
+ try:
163
+ prompts_data = load_prompts(Path(file_path))
164
+ except ValidationError as e:
165
+ click.echo(f"Validation error: {e}", err=True)
166
+ sys.exit(1)
167
+
168
+ if dry_run:
169
+ tabs_used = {s.tab for s in prompts_data.slides}
170
+ click.echo(f"Presentation: {prompts_data.presentation_id}")
171
+ click.echo(f"Slides: {len(prompts_data.slides)}")
172
+ click.echo(f"Images: {len(prompts_data.images)}")
173
+ click.echo(f"Tabs: {', '.join(sorted(tabs_used))}")
174
+ return
175
+
176
+ from gslide.gen import gen_batch
177
+
178
+ gen_batch(prompts_data, continue_on_error=continue_on_error, timeout=timeout)
@@ -0,0 +1,446 @@
1
+ """Generation logic — browser automation for 'Help me visualize' feature.
2
+
3
+ Selector discoveries (validated Mar 19, 2026):
4
+ - Panel opener: div[aria-label="Help me visualize"] (right sidebar icon)
5
+ - Tabs: role="tab" with name="Slide"/"Image"/"Infographic"
6
+ - Text input: visible textarea (use keyboard.type, not fill)
7
+ - Submit: get_by_role("button", name="Create", exact=True)
8
+ - Filmstrip: [aria-label="filmstrip"] (takes ~10s to appear)
9
+ - Generation takes 30-60s, shows "Creating..." tooltip
10
+ - Insert buttons appear after generation completes (below preview)
11
+ """
12
+
13
+ import sys
14
+ import time
15
+
16
+ import click
17
+ from playwright.sync_api import Page, TimeoutError as PwTimeout
18
+
19
+ from gslide.auth import get_storage_path
20
+ from gslide.browser import BrowserSession
21
+ from gslide.prompts import PromptsData
22
+
23
+
24
+ PRESENTATION_URL = "https://docs.google.com/presentation/d/{id}/edit"
25
+ DEFAULT_TIMEOUT_MS = 120_000 # 120s — generation can take 30-60s
26
+ PANEL_LOAD_TIMEOUT_MS = 10_000
27
+ SLIDES_LOAD_TIMEOUT_MS = 30_000
28
+
29
+
30
+ class GenerationError(Exception):
31
+ """Raised when slide generation fails."""
32
+
33
+
34
+ # --- Navigation & Panel ---
35
+
36
+
37
+ def navigate_to_presentation(page: Page, presentation_id: str) -> None:
38
+ """Load presentation, wait for UI, check auth."""
39
+ url = PRESENTATION_URL.format(id=presentation_id)
40
+ page.goto(url, wait_until="domcontentloaded")
41
+
42
+ # Google Slides is heavy — give it time, check for auth redirect
43
+ page.wait_for_timeout(5000)
44
+
45
+ if "accounts.google.com" in page.url:
46
+ raise GenerationError("Session expired. Run: gslide auth login")
47
+
48
+ if presentation_id not in page.url:
49
+ raise GenerationError("Cannot access presentation")
50
+
51
+ # Wait for filmstrip sidebar to confirm Slides UI loaded
52
+ try:
53
+ page.wait_for_selector(
54
+ '[aria-label="filmstrip"]', timeout=SLIDES_LOAD_TIMEOUT_MS
55
+ )
56
+ except PwTimeout:
57
+ raise GenerationError("Google Slides UI did not load in time")
58
+
59
+
60
+ def open_panel(page: Page) -> None:
61
+ """Navigate to last slide and open 'Help me visualize' panel."""
62
+ page.keyboard.press("End")
63
+ page.wait_for_timeout(500)
64
+
65
+ # Click the "Help me visualize" sidebar icon (div, not button)
66
+ hmv = page.locator('div[aria-label="Help me visualize"]')
67
+ hmv.click()
68
+
69
+ # Wait for the panel to appear — tabs and textarea become visible
70
+ page.wait_for_selector('[role="tab"]', timeout=PANEL_LOAD_TIMEOUT_MS)
71
+
72
+
73
+ 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
+ """
79
+ for attempt in range(retries + 1):
80
+ 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)
84
+ return
85
+ except (PwTimeout, Exception):
86
+ if attempt < retries:
87
+ page.wait_for_timeout(1000)
88
+ continue
89
+ raise GenerationError("Failed to reopen HMV panel after insert")
90
+
91
+
92
+ def select_tab(page: Page, tab_name: str) -> None:
93
+ """Click the tab (Slide/Infographic/Image) in the panel."""
94
+ tab = page.get_by_role("tab", name=tab_name)
95
+ tab.click()
96
+ page.wait_for_timeout(300)
97
+
98
+
99
+ 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
106
+ raise GenerationError("No visible text input found in panel")
107
+
108
+
109
+ def _snapshot_preview_srcs(page: Page) -> set[str]:
110
+ """Capture current preview image src URLs to detect stale previews."""
111
+ srcs: set[str] = set()
112
+ preview = page.locator('img[src*="googleusercontent.com"]')
113
+ for i in range(preview.count()):
114
+ try:
115
+ src = preview.nth(i).get_attribute("src") or ""
116
+ if src:
117
+ srcs.add(src[:80])
118
+ except Exception:
119
+ continue
120
+ return srcs
121
+
122
+
123
+ def fill_and_create(
124
+ page: Page, prompt: str, timeout_ms: int = DEFAULT_TIMEOUT_MS
125
+ ) -> None:
126
+ """Type prompt, click Create, wait for NEW preview image to appear.
127
+
128
+ Validated flow (Mar 19, 2026):
129
+ - keyboard.type() triggers input events that enable the Create button
130
+ - Generation takes 30-55s, preview appears as img[src*="googleusercontent.com"]
131
+ - No Insert button appears automatically — must click preview first
132
+ - Must track stale preview URLs to avoid detecting previous generation's image
133
+ """
134
+ # Snapshot existing preview URLs BEFORE creating — used to detect stale images
135
+ stale_srcs = _snapshot_preview_srcs(page)
136
+
137
+ textarea = _find_visible_textarea(page)
138
+ textarea.click()
139
+ page.wait_for_timeout(200)
140
+
141
+ textarea.fill("")
142
+ page.keyboard.type(prompt, delay=10)
143
+ page.wait_for_timeout(500)
144
+
145
+ create_btn = page.get_by_role("button", name="Create", exact=True)
146
+ if not create_btn.is_enabled():
147
+ raise GenerationError("Create button is disabled — prompt may be too short")
148
+ create_btn.click()
149
+
150
+ # Poll for: NEW preview image appears OR error text
151
+ start = time.monotonic()
152
+ while (time.monotonic() - start) * 1000 < timeout_ms:
153
+ page.wait_for_timeout(5000)
154
+
155
+ # Check for error
156
+ error_text = page.get_by_text("We didn't quite get that", exact=False)
157
+ if error_text.count() > 0 and error_text.first.is_visible():
158
+ raise GenerationError(
159
+ "Generation failed: prompt too vague — try a more detailed prompt"
160
+ )
161
+
162
+ # Check for NEW preview image (not in stale_srcs)
163
+ preview = page.locator('img[src*="googleusercontent.com"]')
164
+ for i in range(preview.count()):
165
+ try:
166
+ img = preview.nth(i)
167
+ if img.is_visible():
168
+ bb = img.bounding_box()
169
+ src = (img.get_attribute("src") or "")[:80]
170
+ if bb and bb["width"] > 200 and src not in stale_srcs:
171
+ return # NEW preview appeared — generation complete
172
+ except Exception:
173
+ continue
174
+
175
+ raise GenerationError(f"Generation timed out after {timeout_ms // 1000}s")
176
+
177
+
178
+ # --- Tab-specific insert logic ---
179
+
180
+
181
+ 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
+ """
188
+ preview = page.locator('img[src*="googleusercontent.com"]')
189
+ for i in range(preview.count()):
190
+ try:
191
+ img = preview.nth(i)
192
+ if img.is_visible():
193
+ bb = img.bounding_box()
194
+ if bb and bb["width"] > 200:
195
+ page.mouse.click(
196
+ bb["x"] + bb["width"] / 2,
197
+ bb["y"] + bb["height"] / 2,
198
+ )
199
+ page.wait_for_timeout(2000)
200
+ return
201
+ except Exception:
202
+ continue
203
+ raise GenerationError("Generated preview image not found")
204
+
205
+
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")
211
+ if btn.count() > 0 and btn.first.is_visible():
212
+ btn.first.click()
213
+ else:
214
+ raise GenerationError("'Insert on new slide' button not found after clicking preview")
215
+ _wait_for_new_slide(page)
216
+
217
+
218
+ def insert_slide(page: Page) -> None:
219
+ """Click preview → 'Insert on new slide' for slide tab."""
220
+ _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)
229
+
230
+
231
+ def insert_image(page: Page, insert_as: str = "image") -> None:
232
+ """Click preview → insert as image or background."""
233
+ _click_preview_image(page)
234
+
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"
240
+
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")
251
+
252
+ page.wait_for_timeout(2000)
253
+
254
+
255
+
256
+ def check_url(page: Page, presentation_id: str) -> None:
257
+ """Verify browser URL still contains the target presentation ID."""
258
+ if presentation_id not in page.url:
259
+ raise GenerationError("Browser navigated away from target presentation")
260
+
261
+
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
+ # --- Orchestration ---
268
+
269
+
270
+ _INSERT_FN = {
271
+ "infographic": lambda page, **_: insert_infographic(page),
272
+ "slide": lambda page, **_: insert_slide(page),
273
+ "image": lambda page, **opts: insert_image(page, opts.get("insert_as", "image")),
274
+ }
275
+
276
+
277
+ def gen_single(
278
+ presentation_id: str,
279
+ tab: str,
280
+ prompt: str,
281
+ *,
282
+ timeout: int = 120,
283
+ slide_index: int | None = None,
284
+ insert_as: str = "image",
285
+ ) -> None:
286
+ """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
+
292
+ timeout_ms = timeout * 1000
293
+
294
+ with BrowserSession(storage_state=storage_path) as context:
295
+ page = context.new_page()
296
+
297
+ try:
298
+ navigate_to_presentation(page, presentation_id)
299
+ check_url(page, presentation_id)
300
+
301
+ if tab == "image" and slide_index is not None:
302
+ _navigate_to_slide(page, slide_index)
303
+ else:
304
+ open_panel(page)
305
+
306
+ select_tab(page, tab.capitalize())
307
+ fill_and_create(page, prompt, timeout_ms=timeout_ms)
308
+ check_url(page, presentation_id)
309
+
310
+ insert_fn = _INSERT_FN[tab]
311
+ insert_fn(page, insert_as=insert_as)
312
+
313
+ check_url(page, presentation_id)
314
+ click.echo(f"Done: {tab} generated successfully.")
315
+
316
+ except GenerationError as e:
317
+ click.echo(f"Error: {e}", err=True)
318
+ # Take screenshot for debugging
319
+ try:
320
+ page.screenshot(path="/tmp/gslide_error.png")
321
+ click.echo("Debug screenshot saved to /tmp/gslide_error.png")
322
+ except Exception:
323
+ pass
324
+ sys.exit(2)
325
+
326
+
327
+ def _navigate_to_slide(page: Page, slide_index: int) -> None:
328
+ """Navigate to a specific slide by index and open panel."""
329
+ slides = page.locator('[aria-label="filmstrip"] [role="listitem"]')
330
+ if slide_index <= slides.count():
331
+ slides.nth(slide_index - 1).click()
332
+ page.wait_for_timeout(500)
333
+
334
+ open_panel(page)
335
+
336
+
337
+ # --- Batch ---
338
+
339
+
340
+ def gen_batch(
341
+ prompts_data: PromptsData,
342
+ *,
343
+ continue_on_error: bool = False,
344
+ timeout: int = 120,
345
+ ) -> None:
346
+ """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
+
352
+ timeout_ms = timeout * 1000
353
+ total_slides = len(prompts_data.slides)
354
+ total_images = len(prompts_data.images)
355
+ errors: list[tuple[int, str, str]] = []
356
+ start_time = time.monotonic()
357
+
358
+ with BrowserSession(storage_state=storage_path) as context:
359
+ page = context.new_page()
360
+
361
+ try:
362
+ navigate_to_presentation(page, prompts_data.presentation_id)
363
+ open_panel(page)
364
+ except GenerationError as e:
365
+ click.echo(f"Error: {e}", err=True)
366
+ sys.exit(2)
367
+
368
+ current_tab: str | None = None
369
+
370
+ # Phase 1: Generate slides
371
+ for i, slide in enumerate(prompts_data.slides, 1):
372
+ label = f"[{i}/{total_slides}]"
373
+ click.echo(f" {label} {slide.tab}: {slide.prompt[:50]}...")
374
+
375
+ try:
376
+ check_url(page, prompts_data.presentation_id)
377
+
378
+ if slide.tab != current_tab:
379
+ select_tab(page, slide.tab.capitalize())
380
+ current_tab = slide.tab
381
+
382
+ t0 = time.monotonic()
383
+ fill_and_create(page, slide.prompt, timeout_ms=timeout_ms)
384
+
385
+ insert_fn = _INSERT_FN[slide.tab]
386
+ insert_fn(page)
387
+
388
+ elapsed = time.monotonic() - t0
389
+ click.echo(f" {label} done ({elapsed:.1f}s)")
390
+
391
+ # Return to last slide and reopen panel for next generation
392
+ page.keyboard.press("End")
393
+ page.wait_for_timeout(500)
394
+
395
+ # Close any lingering insert overlay, then reopen HMV panel
396
+ page.keyboard.press("Escape")
397
+ page.wait_for_timeout(500)
398
+ _reopen_panel(page)
399
+
400
+ except (GenerationError, PwTimeout, Exception) as e:
401
+ errors.append((i, slide.tab, str(e)))
402
+ click.echo(f" {label} FAILED: {e}")
403
+ if not continue_on_error:
404
+ break
405
+
406
+ # Phase 2: Generate images
407
+ for i, img in enumerate(prompts_data.images, 1):
408
+ label = f"[img {i}/{total_images}]"
409
+ click.echo(f" {label} slide {img.target_slide}: {img.prompt[:50]}...")
410
+
411
+ try:
412
+ check_url(page, prompts_data.presentation_id)
413
+ _navigate_to_slide(page, img.target_slide)
414
+
415
+ if "image" != current_tab:
416
+ select_tab(page, "Image")
417
+ current_tab = "image"
418
+
419
+ t0 = time.monotonic()
420
+ fill_and_create(page, img.prompt, timeout_ms=timeout_ms)
421
+ insert_image(page, insert_as=img.insert_as)
422
+
423
+ elapsed = time.monotonic() - t0
424
+ click.echo(f" {label} done ({elapsed:.1f}s)")
425
+
426
+ except (GenerationError, PwTimeout, Exception) as e:
427
+ errors.append((total_slides + i, "image", str(e)))
428
+ click.echo(f" {label} FAILED: {e}")
429
+ if not continue_on_error:
430
+ break
431
+
432
+
433
+ # Summary
434
+ total_elapsed = time.monotonic() - start_time
435
+ total_ops = total_slides + total_images
436
+ succeeded = total_ops - len(errors)
437
+
438
+ click.echo()
439
+ click.echo(f"Batch complete: {succeeded}/{total_ops} succeeded in {total_elapsed:.1f}s")
440
+ if errors:
441
+ click.echo(f"Failed ({len(errors)}):")
442
+ for idx, tab, err in errors:
443
+ click.echo(f" #{idx} ({tab}): {err}")
444
+ click.echo(
445
+ f"Presentation: https://docs.google.com/presentation/d/{prompts_data.presentation_id}/edit"
446
+ )
@@ -0,0 +1,86 @@
1
+ """Load and validate prompts.json files for batch generation."""
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+
8
+ class ValidationError(Exception):
9
+ """Raised when prompts.json validation fails."""
10
+
11
+
12
+ VALID_TABS = frozenset({"slide", "infographic", "image"})
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SlidePrompt:
17
+ tab: str
18
+ prompt: str
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ImagePrompt:
23
+ target_slide: int
24
+ prompt: str
25
+ insert_as: str = "image"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class PromptsData:
30
+ presentation_id: str
31
+ slides: list[SlidePrompt]
32
+ images: list[ImagePrompt] = field(default_factory=list)
33
+
34
+
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)
39
+
40
+ if not isinstance(raw, dict):
41
+ raise ValidationError("Prompts file must be a JSON object")
42
+
43
+ if "presentation_id" not in raw:
44
+ raise ValidationError("Missing required field: presentation_id")
45
+
46
+ if "slides" not in raw:
47
+ raise ValidationError("Missing required field: slides")
48
+
49
+ slides_raw = raw["slides"]
50
+ if not isinstance(slides_raw, list) or len(slides_raw) == 0:
51
+ raise ValidationError("slides must be a non-empty array")
52
+
53
+ slides: list[SlidePrompt] = []
54
+ for i, s in enumerate(slides_raw):
55
+ if "tab" not in s:
56
+ raise ValidationError(f"slides[{i}]: missing required field: tab")
57
+ if "prompt" not in s:
58
+ raise ValidationError(f"slides[{i}]: missing required field: prompt")
59
+ if s["tab"] not in VALID_TABS:
60
+ raise ValidationError(
61
+ f"slides[{i}]: invalid tab '{s['tab']}', must be one of: {', '.join(sorted(VALID_TABS))}"
62
+ )
63
+ slides.append(SlidePrompt(tab=s["tab"], prompt=s["prompt"]))
64
+
65
+ images: list[ImagePrompt] = []
66
+ for i, img in enumerate(raw.get("images", [])):
67
+ if "target_slide" not in img:
68
+ raise ValidationError(f"images[{i}]: missing required field: target_slide")
69
+ if "prompt" not in img:
70
+ raise ValidationError(f"images[{i}]: missing required field: prompt")
71
+ insert_as = img.get("insert_as", "image")
72
+ if insert_as not in ("image", "background"):
73
+ raise ValidationError(
74
+ f"images[{i}]: invalid insert_as '{insert_as}', must be 'image' or 'background'"
75
+ )
76
+ images.append(ImagePrompt(
77
+ target_slide=img["target_slide"],
78
+ prompt=img["prompt"],
79
+ insert_as=insert_as,
80
+ ))
81
+
82
+ return PromptsData(
83
+ presentation_id=raw["presentation_id"],
84
+ slides=slides,
85
+ images=images,
86
+ )
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: gslide
3
+ Version: 0.1.0
4
+ Summary: CLI tool for automating Google Slides 'Help me visualize' feature via browser automation
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: playwright>=1.40.0
7
+ Requires-Dist: click>=8.1.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
10
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
@@ -0,0 +1,18 @@
1
+ pyproject.toml
2
+ src/gslide/__init__.py
3
+ src/gslide/auth.py
4
+ src/gslide/browser.py
5
+ src/gslide/cli.py
6
+ src/gslide/gen.py
7
+ src/gslide/prompts.py
8
+ src/gslide.egg-info/PKG-INFO
9
+ src/gslide.egg-info/SOURCES.txt
10
+ src/gslide.egg-info/dependency_links.txt
11
+ src/gslide.egg-info/entry_points.txt
12
+ src/gslide.egg-info/requires.txt
13
+ src/gslide.egg-info/top_level.txt
14
+ tests/test_auth.py
15
+ tests/test_browser.py
16
+ tests/test_cli.py
17
+ tests/test_gen.py
18
+ tests/test_prompts.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gslide = gslide.cli:cli
@@ -0,0 +1,6 @@
1
+ playwright>=1.40.0
2
+ click>=8.1.0
3
+
4
+ [dev]
5
+ pytest>=7.4.0
6
+ pytest-cov>=4.1.0
@@ -0,0 +1 @@
1
+ gslide