Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions crates/google-workspace-cli/src/helpers/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
})
}
Expand Down Expand Up @@ -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::<String>("document").unwrap();
let find = matches.get_one::<String>("find").unwrap();
let replace = matches.get_one::<String>("replace").unwrap();
let state_file_raw = matches.get_one::<String>("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}")))?;
Comment on lines +265 to +266
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using synchronous std::fs::write inside an async function blocks the async executor thread. Use tokio::fs::write instead to maintain non-blocking behavior.

Suggested change
std::fs::write(script_file.path(), SCRIPT)
.map_err(|e| GwsError::Other(anyhow!("Failed to write script to temp file: {e}")))?;
tokio::fs::write(script_file.path(), SCRIPT)
.await
.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| {
Comment on lines +299 to +300
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Parsing the entire stdout as JSON is fragile. If Node.js or any lifecycle scripts emit warnings or other text to stdout, serde_json::from_str will fail. It is safer to extract the last line or use a specific prefix to identify the JSON payload.

Suggested change
let result: serde_json::Value =
serde_json::from_str(stdout.trim()).map_err(|e| {
let json_str = stdout.lines().last().unwrap_or("").trim();
let result: serde_json::Value =
serde_json::from_str(json_str).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::*;
Expand Down
198 changes: 198 additions & 0 deletions scripts/playwright-suggest.mjs
Original file line number Diff line number Diff line change
@@ -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 <action> [args...]
*
* Actions:
* suggest <doc_id> <find> <replace> <state_file>
*
* 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The script uses page.waitForTimeout() (hardcoded sleeps) in several places, which is a major anti-pattern in Playwright. This leads to unnecessary delays (e.g., 6 seconds of forced waiting during document open) and makes the script flaky. Use web-first assertions or page.waitForSelector() to wait for specific UI elements or states instead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — the hardcoded timeouts are a pragmatic choice here. Google Docs uses a custom canvas-based rendering engine (kix-appview-editor) where DOM state doesn't reliably reflect UI readiness. Waiting for a selector often passes while the editor is still initializing internal state, leading to flaky interactions. The 2s waits during openDoc are specifically for dismissing tutorial popups that appear asynchronously after the editor selector is present.

Happy to revisit this if there are specific selectors that reliably indicate readiness, but in our production experience with sava these timeouts have been the most stable approach.

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);
}
Comment on lines +82 to +93
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update clickDialogButton to return a boolean indicating whether a matching button was found and clicked. This allows the caller to handle cases where the UI is in a different language or the dialog didn't open correctly, preventing silent failures.

Suggested change
async function clickDialogButton(page, text) {
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; }
}
}
}
}, text);
}
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);
}

Comment on lines +82 to +93
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The clickDialogButton function fails silently if the requested button is not found. This is particularly problematic when combined with the locale dependency issue: if the "Replace" button text is localized (e.g., "Remplacer" in French), the script will fail to click it but still return { ok: true }, misleading the user into thinking the suggestion was successful.

async function clickDialogButton(page, text) {
  const clicked = 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);
  if (!clicked) throw new Error('Button "' + text + '" not found. Check if the Google Docs UI is in English.');
}


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+)/);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The script relies on English-specific UI strings and patterns, such as the "Next" and "Replace" button labels and the regex /(\d+)\s+of\s+(\d+)/ for match counts. This will cause the command to fail for any user whose Google Docs UI is set to a non-English language. Consider using language-agnostic selectors (like ARIA roles or data attributes) or at least prominently documenting this limitation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unknown-status path already aborts with a clear error message pointing to the locale issue (added in 9e1f663). Unfortunately Google Docs doesn't expose stable ARIA attributes or data-* selectors for the Find & Replace dialog buttons — the DOM is heavily obfuscated with auto-generated class names.

A possible future improvement would be to use aria-keyshortcuts or positional indexing within the dialog, but these would need extensive testing across locales. For now the explicit abort-on-unknown approach avoids silent failures while keeping the implementation maintainable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The regex for matching the result count is hardcoded to the English word "of". This will cause the command to fail for users whose Google Docs interface is set to a different language (e.g., "1 de 1" in Spanish). Using a non-digit separator \D+ would make this more robust.

Suggested change
const m = text.match(/(\d+)\s+of\s+(\d+)/);
const m = text.match(/(\d+)\s+\D+\s+(\d+)/);

if (m) return { status: "found", current: +m[1], total: +m[2] };
return { status: "unknown" };
});
Comment on lines +138 to +147
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for validating the match count is highly dependent on the English locale. Google Docs UI language is determined by the user's account settings; in non-English locales, strings like "of", "not found", or "No results" will not match, causing matchInfo.status to be "unknown". This bypasses the safety checks that ensure exactly one match exists before proceeding with the replacement.


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 <doc_id> <find> <replace> <state_file>` });
process.exit(1);
}
Loading
Loading