-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(docs): add +suggest skill via Playwright #666
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 docs.google.com\n \ | ||
| (log in, then Ctrl+C — the state file is saved automatically)", | ||
| ) | ||
| .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,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\ | ||
|
||
| \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")); | ||
|
||
|
|
||
| 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}"); | ||
| } | ||
|
||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| 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); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The script uses
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Comment on lines
+82
to
+93
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 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"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await page.keyboard.press("Control+h"); | |
| await page.keyboard.press(process.platform === "darwin" ? "Meta+Shift+h" : "Control+h"); |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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." }; | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| const m = text.match(/(\d+)\s+of\s+(\d+)/); | |
| const m = text.match(/(\d+)\s+\D+\s+(\d+)/); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are several issues in this block:
- Critical Bug: Line 150 references state_file which is undefined (the variable is stateFile), causing a ReferenceError when multiple matches are found.
- 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.
- Robustness: clickDialogButton is called without verifying if the button was actually found. On non-English UIs, this will fail silently and report success.
| 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." }; | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The playwright codegen command does not save the browser state automatically. You must use the --save-storage flag to create the state JSON file.