diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index d3ef7fa2..6ae683ff 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -17,8 +17,10 @@ use crate::auth; use crate::error::GwsError; use crate::executor; use clap::{Arg, ArgMatches, Command}; +use anyhow::anyhow; use serde_json::json; use std::future::Future; +use std::path::PathBuf; use std::pin::Pin; pub struct DocsHelper; @@ -56,6 +58,60 @@ TIPS: For rich formatting, use the raw batchUpdate API instead.", ), ); + cmd = cmd.subcommand( + Command::new("+suggest") + .about("[Helper] Suggest an edit in a document (uses Playwright)") + .long_about( + "Create a tracked suggestion in a Google Doc by automating the browser UI.\n\n\ + The Google Docs API has no support for Suggesting mode — all API writes are \ + direct edits. This command works around that limitation by launching a headless \ + browser via Playwright, switching to Suggesting mode, and performing a \ + Find & Replace so the change appears as a suggestion that collaborators can \ + accept or reject.\n\n\ + PREREQUISITES:\n \ + - Node.js 18+ and Playwright: npx playwright install chromium\n \ + - A saved browser session: npx playwright codegen --save-storage=state.json docs.google.com\n \ + (log in, then close the browser to save the state file)", + ) + .arg( + Arg::new("document") + .long("document") + .help("Document ID") + .required(true) + .value_name("ID"), + ) + .arg( + Arg::new("find") + .long("find") + .help("Exact text to find (must match exactly once)") + .required(true) + .value_name("TEXT"), + ) + .arg( + Arg::new("replace") + .long("replace") + .help("Replacement text (recorded as a suggestion)") + .required(true) + .value_name("TEXT"), + ) + .arg( + Arg::new("state-file") + .long("state-file") + .help("Path to Playwright browser state JSON") + .value_name("PATH") + .default_value("~/.config/gws/playwright-state.json"), + ) + .after_help( + "\ +EXAMPLES: + gws docs +suggest --document DOC_ID --find 'old text' --replace 'new text' + +WHY: + The Google Docs API v1 has no method to create suggestions. This command + automates the browser UI to work around that decade-old limitation. + See: https://issuetracker.google.com/issues/36054544", + ), + ); cmd } @@ -111,6 +167,11 @@ TIPS: return Ok(true); } + + if let Some(matches) = matches.subcommand_matches("+suggest") { + return run_suggest(matches).await.map(|_| true); + } + Ok(false) }) } @@ -157,6 +218,104 @@ fn build_write_request( Ok((params.to_string(), body.to_string(), scopes)) } +/// Run the Playwright-based suggest script as a subprocess. +/// +/// The Google Docs API has no Suggesting mode support (see +/// https://issuetracker.google.com/issues/36054544). This function shells out +/// to a bundled Node.js script that automates the Docs UI via Playwright to +/// create tracked suggestions. +async fn run_suggest(matches: &ArgMatches) -> Result<(), GwsError> { + let document = matches.get_one::("document").unwrap(); + let find = matches.get_one::("find").unwrap(); + let replace = matches.get_one::("replace").unwrap(); + let state_file_raw = matches.get_one::("state-file").unwrap(); + + // Expand ~ to home directory + let state_file = if state_file_raw.starts_with("~/") { + dirs::home_dir() + .map(|h| h.join(&state_file_raw[2..])) + .unwrap_or_else(|| PathBuf::from(state_file_raw)) + } else { + PathBuf::from(state_file_raw) + }; + + if !state_file.exists() { + return Err(GwsError::Validation(format!( + "Browser state file not found: {}\n\ + \n\ + To create one, run:\n \ + npx playwright install chromium\n \ + npx playwright codegen --save-storage=state.json docs.google.com\n\ + \n\ + Log in to your Google account in the browser that opens, then close it.\n\ + Move the saved state to: {}", + state_file.display(), + state_file.display(), + ))); + } + + // Embed the Playwright script in the binary so it works regardless of + // install method (cargo install, pre-built binary, npm, etc.) + static SCRIPT: &str = include_str!("../../../../scripts/playwright-suggest.mjs"); + + let script_file = tempfile::Builder::new() + .suffix(".mjs") + .tempfile() + .map_err(|e| GwsError::Other(anyhow!("Failed to create temp file for script: {e}")))?; + std::fs::write(script_file.path(), SCRIPT) + .map_err(|e| GwsError::Other(anyhow!("Failed to write script to temp file: {e}")))?; + + let output = tokio::process::Command::new("node") + .arg(script_file.path()) + .arg("suggest") + .arg(document) + .arg(find) + .arg(replace) + .arg(&state_file) + .output() + .await + .map_err(|e| { + GwsError::Other(anyhow!( + "Failed to launch Playwright script (is Node.js installed?): {e}" + )) + })?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + let detail = if !stderr.is_empty() { + stderr.to_string() + } else { + stdout.to_string() + }; + return Err(GwsError::Other(anyhow!( + "Playwright script failed (exit {}):\n{detail}", + output.status.code().unwrap_or(-1) + ))); + } + + // Parse the JSON output — treat unparseable output as an error + let result: serde_json::Value = + serde_json::from_str(stdout.trim()).map_err(|e| { + GwsError::Other(anyhow!( + "Playwright script returned invalid JSON ({e}):\n{stdout}" + )) + })?; + + if result.get("ok").and_then(|v| v.as_bool()) == Some(true) { + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + } else { + let error = result + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return Err(GwsError::Other(anyhow!(error.to_string()))); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/scripts/playwright-suggest.mjs b/scripts/playwright-suggest.mjs new file mode 100644 index 00000000..98ab8403 --- /dev/null +++ b/scripts/playwright-suggest.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Browser automation for Google Docs operations that the API cannot perform. + * + * The Google Docs API v1 has no support for "Suggesting" mode — edits made via + * the API are always direct writes. This script uses Playwright to drive the + * Docs UI in a headless browser, switching to Suggesting mode before performing + * a Find & Replace so the change appears as a tracked suggestion. + * + * Usage: + * node playwright-suggest.mjs [args...] + * + * Actions: + * suggest + * + * Requires: + * - npx playwright install chromium (one-time browser download) + * - A saved browser state JSON file with valid Google session cookies + * (obtain by running `npx playwright codegen --save-storage=state.json docs.google.com`) + * + * Outputs JSON to stdout: { "ok": true, "message": "..." } or { "ok": false, "error": "..." } + */ + +import { chromium } from "playwright"; +import { platform } from "node:os"; + +const DISMISS_LABELS = [ + "got it", "ok", "okay", "dismiss", "close", "no thanks", + "i understand", "not now", "skip", "next time", "maybe later", "continue", +]; + +function output(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +async function dismissPopups(page) { + await page.evaluate((labels) => { + for (const sel of ['[role="dialog"]', '[role="alertdialog"]', '[class*="Dialog"]']) { + for (const dialog of document.querySelectorAll(sel)) { + const buttons = dialog.querySelectorAll('button, [role="button"]'); + for (const btn of buttons) { + if (labels.includes(btn.textContent.trim().toLowerCase())) { + btn.click(); + return; + } + } + if (buttons.length === 1) { buttons[0].click(); return; } + } + } + }, DISMISS_LABELS); +} + +async function openDoc(browser, stateFile, docId) { + const context = await browser.newContext({ storageState: stateFile }); + const page = await context.newPage(); + await page.goto(`https://docs.google.com/document/d/${docId}/edit`, { + waitUntil: "domcontentloaded", + }); + await page.waitForSelector(".kix-appview-editor", { timeout: 30_000 }); + for (let i = 0; i < 3; i++) { + await page.waitForTimeout(2000); + await dismissPopups(page); + } + return { context, page }; +} + +/** Click a button in a dialog by its text. Returns true if found and clicked. */ +async function clickDialogButton(page, text) { + return await page.evaluate((text) => { + for (const sel of ['[role="dialog"]', '[role="alertdialog"]']) { + for (const dialog of document.querySelectorAll(sel)) { + for (const btn of dialog.querySelectorAll('button, [role="button"]')) { + if (btn.textContent.trim() === text) { btn.click(); return true; } + } + } + } + return false; + }, text); +} + +async function suggest(docId, find, replace, stateFile) { + const browser = await chromium.launch({ headless: true }); + try { + const { context, page } = await openDoc(browser, stateFile, docId); + await page.locator(".kix-appview-editor").click(); + await page.waitForTimeout(500); + + // Switch to Suggesting mode + let modeBtn = page.locator("#docs-toolbar-mode-switcher").first(); + if (!(await modeBtn.isVisible({ timeout: 3000 }).catch(() => false))) { + modeBtn = page.locator("[aria-label='Editing mode']").first(); + } + await modeBtn.click(); + await page.waitForTimeout(500); + await page.getByText("Suggesting", { exact: true }).click(); + await page.waitForTimeout(500); + + // Open Find & Replace (platform-aware shortcut) + await page.keyboard.press(platform() === "darwin" ? "Meta+Shift+h" : "Control+h"); + await page.waitForTimeout(2000); + + // Fill in find/replace fields + await page.evaluate(() => { + const inputs = document.querySelectorAll('[role="dialog"] input'); + if (inputs[0]) inputs[0].focus(); + }); + await page.waitForTimeout(300); + await page.keyboard.type(find, { delay: 10 }); + await page.waitForTimeout(500); + await page.keyboard.press("Tab"); + await page.waitForTimeout(300); + await page.keyboard.type(replace, { delay: 10 }); + await page.waitForTimeout(500); + + // Navigate to first match + if (!(await clickDialogButton(page, "Next"))) { + await page.keyboard.press("Escape"); + await context.storageState({ path: stateFile }).catch(() => {}); + return { ok: false, error: 'Could not find "Next" button. The Google Docs UI may be in a non-English language.' }; + } + await page.waitForTimeout(1000); + + // Validate match count + const matchInfo = await page.evaluate(() => { + const dialog = document.querySelector('[role="dialog"]'); + if (!dialog) return { status: "unknown" }; + const text = dialog.textContent || ""; + if (text.includes("not found") || text.includes("No results")) + return { status: "not_found" }; + const m = text.match(/(\d+)\s+of\s+(\d+)/); + if (m) return { status: "found", current: +m[1], total: +m[2] }; + return { status: "unknown" }; + }); + + if (matchInfo.status === "not_found") { + await page.keyboard.press("Escape"); + await context.storageState({ path: stateFile }).catch(() => {}); + return { ok: false, error: `No match found for "${find}".` }; + } + + if (matchInfo.status === "found" && matchInfo.total > 1) { + await page.keyboard.press("Escape"); + await context.storageState({ path: stateFile }).catch(() => {}); + return { + ok: false, + error: `${matchInfo.total} matches found for "${find}". Use a longer, unique quote.`, + }; + } + + if (matchInfo.status === "unknown") { + await page.keyboard.press("Escape"); + await context.storageState({ path: stateFile }).catch(() => {}); + return { ok: false, error: `Could not verify match count for "${find}". The Google Docs UI may be in a non-English language.` }; + } + + // Execute the replacement (recorded as a suggestion) + if (!(await clickDialogButton(page, "Replace"))) { + await page.keyboard.press("Escape"); + await context.storageState({ path: stateFile }).catch(() => {}); + return { ok: false, error: 'Could not find "Replace" button. The Google Docs UI may be in a non-English language.' }; + } + await page.waitForTimeout(2000); + await page.keyboard.press("Escape"); + await page.waitForTimeout(500); + + await context.storageState({ path: stateFile }).catch(() => {}); + return { ok: true, message: `Suggested: "${find}" → "${replace}"` }; + } finally { + await browser.close(); + } +} + +// --- CLI entry point --- +const [, , action, ...args] = process.argv; + +if (action === "suggest" && args.length === 4) { + const [docId, find, replace, stateFile] = args; + suggest(docId, find, replace, stateFile) + .then((r) => output(r)) + .catch((e) => output({ ok: false, error: e.message })); +} else { + output({ ok: false, error: `Usage: playwright-suggest.mjs suggest ` }); + process.exit(1); +} diff --git a/skills/gws-docs-suggest/SKILL.md b/skills/gws-docs-suggest/SKILL.md new file mode 100644 index 00000000..95516dfd --- /dev/null +++ b/skills/gws-docs-suggest/SKILL.md @@ -0,0 +1,89 @@ +--- +name: gws-docs-suggest +description: "Google Docs: Create a tracked suggestion via browser automation." +metadata: + version: 0.22.5 + openclaw: + category: "productivity" + requires: + bins: + - gws + - node + cliHelp: "gws docs +suggest --help" +--- + +# docs +suggest + +> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it. + +Create a tracked suggestion in a Google Doc using browser automation. + +The Google Docs API v1 has **no method to create suggestions** — all API writes +are direct edits. This command works around that limitation by launching a +headless browser via [Playwright](https://playwright.dev), switching the editor +to Suggesting mode, and performing a Find & Replace so the change appears as a +suggestion that collaborators can accept or reject. + +See: https://issuetracker.google.com/issues/36054544 + +## Setup (one-time) + +```bash +# Install Playwright and its Chromium browser +npx playwright install chromium + +# Save a browser session with your Google credentials +npx playwright codegen --save-storage=state.json docs.google.com +# Log in in the browser that opens, then close it. +# Move the state to: ~/.config/gws/playwright-state.json +``` + +## Usage + +```bash +gws docs +suggest --document --find --replace [--state-file ] +``` + +## Flags + +| Flag | Required | Default | Description | +|------|----------|---------|-------------| +| `--document` | yes | — | Document ID | +| `--find` | yes | — | Exact text to find (must match exactly once) | +| `--replace` | yes | — | Replacement text (recorded as a suggestion) | +| `--state-file` | — | `~/.config/gws/playwright-state.json` | Path to Playwright browser state | + +## Examples + +```bash +# Suggest replacing a word +gws docs +suggest --document 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms \ + --find 'old paragraph text' \ + --replace 'new paragraph text' +``` + +## How it works + +1. Launches headless Chromium with saved session cookies +2. Opens the document in the Google Docs editor +3. Switches from Editing to **Suggesting** mode via the toolbar +4. Opens Find & Replace (`Ctrl+H`) +5. Searches for the `--find` text and validates exactly one match exists +6. Clicks Replace — the change is recorded as a tracked suggestion +7. Closes the browser and saves the session + +## Tips + +- The `--find` text must match **exactly once** in the document. If multiple + matches exist, use a longer quote to disambiguate. +- Each invocation takes ~15-30 seconds due to browser startup and page load. +- The browser session expires periodically. If you get auth errors, re-run + `npx playwright codegen --save-storage=state.json docs.google.com` to refresh it. + +> [!CAUTION] +> This is a **write** command — confirm with the user before executing. + +## See Also + +- [gws-docs-write](../gws-docs-write/SKILL.md) — Append text directly (no suggestions) +- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth