-
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 all commits
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 --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::<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}")))?; | ||||||||||||
|
|
||||||||||||
| 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
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. Parsing the entire
Suggested change
|
||||||||||||
| 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::*; | ||||||||||||
|
|
||||||||||||
| 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); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
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 }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| /** 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
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 (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+)/); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
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 relies on English-specific UI strings and patterns, such as the "Next" and "Replace" button labels and the regex
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. 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 A possible future improvement would be to use
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 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
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (m) return { status: "found", current: +m[1], total: +m[2] }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| return { status: "unknown" }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+138
to
+147
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 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 |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
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.
Using synchronous
std::fs::writeinside anasyncfunction blocks the async executor thread. Usetokio::fs::writeinstead to maintain non-blocking behavior.