Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
154 changes: 154 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 docs.google.com\n \
(log in, then Ctrl+C — the state file is saved automatically)",
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 playwright codegen command does not save the browser state automatically. You must use the --save-storage flag to create the state JSON file.

Suggested change
- A saved browser session: npx playwright codegen docs.google.com\n \
(log in, then Ctrl+Cthe state file is saved automatically)",
- 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.json 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,99 @@ 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 docs.google.com\n\
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 the instruction to include the --save-storage flag, as Playwright does not save state by default.

Suggested change
npx playwright codegen docs.google.com\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(),
)));
}

// Locate the bundled script relative to the binary
let script = std::env::current_exe()
.ok()
.and_then(|exe| {
exe.parent()
.map(|dir| dir.join("../scripts/playwright-suggest.mjs"))
})
.unwrap_or_else(|| PathBuf::from("scripts/playwright-suggest.mjs"));
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

Locating the Playwright script relative to the current executable is fragile and will likely fail for users who install the tool via cargo install (where the scripts directory is not co-located with the binary) or if the binary is moved. Consider embedding the script using include_str! and writing it to a temporary file at runtime to ensure the command works regardless of the installation method.

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.

Fixed in 8ddc416 — the script is now embedded in the binary via include_str! and written to a tempfile at runtime. Works with cargo install, pre-built binaries, and npm.


let output = tokio::process::Command::new("node")
.arg(&script)
.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
if let Ok(result) = serde_json::from_str::<serde_json::Value>(stdout.trim()) {
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())));
}
} else {
println!("{stdout}");
}
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.

security-high high

The subprocess output handling has two issues:

  1. If the script output is not valid JSON (e.g., due to Node.js warnings or a crash), the function prints the raw output and returns Ok(()), which masks the failure from the user.
  2. Printing the raw stdout directly violates the security rule to sanitize strings printed to the terminal to prevent escape sequence injection.

I suggest updating the parsing logic to treat invalid JSON as an error and ensuring all terminal output is sanitized.

    let result: serde_json::Value = serde_json::from_str(stdout.trim()).map_err(|_| {
        GwsError::Other(anyhow!(
            "Failed to parse script output as JSON:\n{}",
            crate::output::sanitize_for_terminal(&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())));
    }
References
  1. Sanitize error strings printed to the terminal to prevent escape sequence injection.

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.

Fixed in 8ddc416 — unparseable output now returns an error instead of silently printing raw stdout.


Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
181 changes: 181 additions & 0 deletions scripts/playwright-suggest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/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 docs.google.com` and logging in)
*
* Outputs JSON to stdout: { "ok": true, "message": "..." } or { "ok": false, "error": "..." }
*/

import { chromium } from "playwright";

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 };
}

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);
}
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
await page.keyboard.press("Control+h");
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 Control+h keyboard shortcut is platform-specific. On macOS, Control+h is often intercepted by the browser or OS for text editing (backspace), and the standard Google Docs shortcut for Find & Replace is Command+Shift+H. This will likely cause the script to fail on macOS.

Suggested change
await page.keyboard.press("Control+h");
await page.keyboard.press(process.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
await clickDialogButton(page, "Next");
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

Check the return value of clickDialogButton to ensure the 'Next' button was found. This prevents the script from proceeding with stale match information if the UI is localized or the dialog failed to open.

Suggested change
await clickDialogButton(page, "Next");
if (!(await clickDialogButton(page, "Next"))) {
await page.keyboard.press("Escape");
return { ok: false, error: "Could not find 'Next' button. 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");
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");
context.storageState({ path: stateFile }).catch(() => {});
return {
ok: false,
error: `${matchInfo.total} matches found for "${find}". Use a longer, unique quote.`,
};
}

// Execute the replacement (recorded as a suggestion)
await clickDialogButton(page, "Replace");
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.

security-critical critical

There are several issues in this block:

  1. Critical Bug: Line 150 references state_file which is undefined (the variable is stateFile), causing a ReferenceError when multiple matches are found.
  2. Safety Issue: If matchInfo.status is unknown (which happens on non-English UIs where the regex fails), the script proceeds to the replacement logic instead of aborting, violating the safety guarantee of only replacing unique matches.
  3. Robustness: clickDialogButton is called without verifying if the button was actually found. On non-English UIs, this will fail silently and report success.
Suggested change
if (matchInfo.status === "not_found") {
await page.keyboard.press("Escape");
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");
context.storageState({ path: stateFile }).catch(() => {});
return {
ok: false,
error: `${matchInfo.total} matches found for "${find}". Use a longer, unique quote.`,
};
}
// Execute the replacement (recorded as a suggestion)
await clickDialogButton(page, "Replace");
if (matchInfo.status !== "found" || matchInfo.total !== 1) {
await page.keyboard.press("Escape");
await context.storageState({ path: stateFile }).catch(() => {});
const error = matchInfo.status === "not_found"
? "No match found for \"" + find + "\"."
: matchInfo.status === "found"
? matchInfo.total + " matches found for \"" + find + "\". Use a longer, unique quote."
: "Could not verify match count (UI may be in a non-English language).";
return { ok: false, error };
}
// Execute the replacement (recorded as a suggestion)
if (!(await clickDialogButton(page, "Replace"))) {
await page.keyboard.press("Escape");
return { ok: false, error: "Could not find 'Replace' button. 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