@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 +29 -0
- package/package.json +36 -0
- package/pyproject.toml +29 -0
- package/scripts/postinstall.sh +62 -0
- package/src/gslide/__init__.py +3 -0
- package/src/gslide/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/gslide/__pycache__/auth.cpython-313.pyc +0 -0
- package/src/gslide/__pycache__/browser.cpython-313.pyc +0 -0
- package/src/gslide/__pycache__/cli.cpython-313.pyc +0 -0
- package/src/gslide/__pycache__/gen.cpython-313.pyc +0 -0
- package/src/gslide/__pycache__/prompts.cpython-313.pyc +0 -0
- package/src/gslide/auth.py +95 -0
- package/src/gslide/browser.py +41 -0
- package/src/gslide/cli.py +178 -0
- package/src/gslide/gen.py +446 -0
- package/src/gslide/prompts.py +86 -0
- package/src/gslide.egg-info/PKG-INFO +10 -0
- package/src/gslide.egg-info/SOURCES.txt +18 -0
- package/src/gslide.egg-info/dependency_links.txt +1 -0
- package/src/gslide.egg-info/entry_points.txt +2 -0
- package/src/gslide.egg-info/requires.txt +6 -0
- package/src/gslide.egg-info/top_level.txt +1 -0
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"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gslide
|