From e6ef7a9f20363523c143836d37315132f8c050f3 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 15:50:22 -0300 Subject: [PATCH 1/9] feat(docs): add suggestion workflow helper --- .changeset/docs-suggest-workflow.md | 7 + README.md | 20 + .../google-workspace-cli/src/helpers/docs.rs | 7 + .../src/helpers/docs/read.rs | 6 +- .../src/helpers/docs/suggest.rs | 419 ++++++++++++++++++ 5 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 .changeset/docs-suggest-workflow.md create mode 100644 crates/google-workspace-cli/src/helpers/docs/suggest.rs diff --git a/.changeset/docs-suggest-workflow.md b/.changeset/docs-suggest-workflow.md new file mode 100644 index 000000000..a9948d1bf --- /dev/null +++ b/.changeset/docs-suggest-workflow.md @@ -0,0 +1,7 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `gws docs +suggest` for creating and managing Google Docs suggestions, +including suggested insertions, exact replacements, range deletions, and +accept, reject, or delete actions. \ No newline at end of file diff --git a/README.md b/README.md index 41f1c21eb..1b63f2c34 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,25 @@ For example, Docs suggestions and comments require a Cloud project enrolled in t Google still enforces API availability, OAuth scopes, document permissions and server-side validation. This flag grants no additional access. +### Docs suggestions + +On `develop`, `gws docs +suggest` provides a guided workflow for Google Docs +suggestions. It can insert text, replace one exact text run, propose a range +deletion, list the structured document with suggestion context, and accept, +reject, or delete an existing suggestion: + +```bash +gws docs +suggest insert --document DOC_ID --text 'Suggested text' +gws docs +suggest replace --document DOC_ID --find 'old text' --text 'new text' +gws docs +suggest delete-text --document DOC_ID --start-index 10 --end-index 20 +gws docs +suggest list --document DOC_ID +gws docs +suggest accept --document DOC_ID --suggestion-id SUGGESTION_ID +``` + +The helper applies the preview-only `writeMode` request fields internally, so +these commands do not need `--allow-unknown-fields`. Suggestion writes remain +subject to Google Workspace Developer Preview access and document permissions. + ```bash # Preview a suggested insertion (Docs Developer Preview). gws docs documents batchUpdate \ @@ -438,6 +457,7 @@ gws drive --help # shows +upload … | `sheets` | `+append` | Append a row to a spreadsheet | | `sheets` | `+read` | Read values from a spreadsheet | | `docs` | `+write` | Append text to a document | +| `docs` | `+suggest` | Create and manage document suggestions | | `chat` | `+send` | Send a message to a space | | `drive` | `+upload` | Upload a file with automatic metadata | | `calendar` | `+insert` | Create a new event | diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index c42c02feb..ea538921d 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -22,6 +22,7 @@ use std::future::Future; use std::pin::Pin; mod read; +mod suggest; pub struct DocsHelper; @@ -36,6 +37,7 @@ impl Helper for DocsHelper { _doc: &crate::discovery::RestDescription, ) -> Command { cmd = cmd.subcommand(read::command()); + cmd = cmd.subcommand(suggest::command()); cmd = cmd.subcommand( Command::new("+write") .about("[Helper] Append text to a document") @@ -77,6 +79,10 @@ TIPS: read::handle(doc, matches, sanitize_config).await?; return Ok(true); } + if let Some(matches) = matches.subcommand_matches("+suggest") { + suggest::handle(doc, matches, sanitize_config).await?; + return Ok(true); + } if let Some(matches) = matches.subcommand_matches("+write") { let (params_str, body_str, scopes) = build_write_request(matches, doc)?; @@ -219,4 +225,5 @@ mod tests { assert!(body.contains("endOfSegmentLocation")); assert_eq!(scopes[0], "https://scope"); } + } diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs index 4e2e8625c..b49eec62f 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -76,7 +76,7 @@ pub(super) async fn handle( /// Token acquisition is lazy so local validation and dry-run never read /// credentials. The executor remains responsible for HTTP and sanitization. -pub(super) async fn run( +pub(crate) async fn run( doc: &RestDescription, matches: &ArgMatches, sanitize: &SanitizeConfig, @@ -94,7 +94,9 @@ pub(super) async fn run( let dry_run = matches.get_flag("dry-run"); let token = if dry_run { None } else { Some(token.await?) }; let format = matches - .get_one::("format") + .try_get_one::("format") + .ok() + .flatten() .map(|f| OutputFormat::from_str(f)) .unwrap_or_default(); let result = executor::execute_method( diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs new file mode 100644 index 000000000..31a83c836 --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -0,0 +1,419 @@ +use super::read; +use crate::auth; +use crate::discovery::{RestDescription, RestMethod}; +use crate::error::GwsError; +use crate::executor::{self, AuthMethod, BodyValidationPolicy, PaginationConfig}; +use crate::formatter::OutputFormat; +use crate::helpers::modelarmor::SanitizeConfig; +use clap::{Arg, ArgMatches, Command}; +use serde_json::{json, Value}; + +pub(super) fn command() -> Command { + let document = || { + Arg::new("document") + .long("document") + .help("Document ID") + .required(true) + .value_name("ID") + }; + let mut cmd = Command::new("+suggest").about("[Helper] Create and manage Docs suggestions"); + cmd = cmd.subcommand( + Command::new("insert") + .about("Insert text as a suggestion") + .arg(document()) + .arg(text_arg()) + .arg(Arg::new("tab-id").long("tab-id").value_name("ID")), + ); + cmd = cmd.subcommand( + Command::new("replace") + .about("Replace one exact text run as a suggestion") + .arg(document()) + .arg( + Arg::new("find") + .long("find") + .help("Exact text to replace") + .required(true) + .value_name("TEXT"), + ) + .arg(text_arg()) + .arg(Arg::new("tab-id").long("tab-id").value_name("ID")), + ); + cmd = cmd.subcommand( + Command::new("delete-text") + .about("Propose deleting a document range") + .arg(document()) + .arg(index_arg("start-index")) + .arg(index_arg("end-index")) + .arg(Arg::new("tab-id").long("tab-id").value_name("ID")), + ); + cmd = cmd.subcommand( + Command::new("list") + .about("List suggestions with document context") + .arg(document()) + .arg(Arg::new("params").long("params").value_name("JSON")), + ); + for action in ["accept", "reject", "delete"] { + cmd = cmd.subcommand( + Command::new(action) + .about(format!("{} an existing suggestion", capitalize(action))) + .arg(document()) + .arg( + Arg::new("suggestion-id") + .long("suggestion-id") + .required(true) + .value_name("ID"), + ), + ); + } + cmd.after_help( + "EXAMPLES:\n gws docs +suggest insert --document DOC_ID --text 'Suggested text'\n gws docs +suggest replace --document DOC_ID --find 'old' --text 'new'\n gws docs +suggest list --document DOC_ID\n gws docs +suggest accept --document DOC_ID --suggestion-id SUGGESTION_ID\n\nTIPS:\n Suggestion writes are a Google Workspace Developer Preview feature.\n The helper opts into unknown preview fields internally; raw commands remain strict.\n Use --dry-run to preview insert, delete-text, accept, reject, and delete requests.\n replace reads the document to locate exactly one matching text run before writing.", + ) +} + +fn text_arg() -> Arg { + Arg::new("text") + .long("text") + .help("Text to insert") + .required(true) + .value_name("TEXT") +} + +fn index_arg(name: &'static str) -> Arg { + Arg::new(name) + .long(name) + .help("UTF-16 document index") + .required(true) + .value_parser(clap::value_parser!(i32)) +} + +fn capitalize(value: &str) -> String { + let mut chars = value.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +pub(super) async fn handle( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result<(), GwsError> { + let (action, action_matches) = matches + .subcommand() + .ok_or_else(|| GwsError::Validation("docs +suggest requires an action".into()))?; + if action == "list" { + return handle_list(doc, action_matches, sanitize).await; + } + + let (params, body, method) = match action { + "insert" => { + let method = batch_update_method(doc)?; + let body = build_insert_body(action_matches)?; + (document_params(action_matches)?, body, method) + } + "replace" => { + let method = batch_update_method(doc)?; + let body = build_replace_body(doc, action_matches, sanitize).await?; + (document_params(action_matches)?, body, method) + } + "delete-text" => { + let method = batch_update_method(doc)?; + let body = build_delete_text_body(action_matches)?; + (document_params(action_matches)?, body, method) + } + "accept" | "reject" | "delete" => { + let method = batch_update_method(doc)?; + let body = build_suggestion_action_body(action, action_matches)?; + (document_params(action_matches)?, body, method) + } + _ => return Err(GwsError::Validation(format!("Unknown suggestion action: {action}"))), + }; + + execute_suggestion_write(doc, method, ¶ms, &body, sanitize, action_matches).await +} + +async fn handle_list( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result<(), GwsError> { + let output = read::run(doc, matches, sanitize, async { + auth::get_token(&["https://www.googleapis.com/auth/documents.readonly"]) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}"))) + }) + .await?; + println!("{output}"); + Ok(()) +} + +async fn execute_suggestion_write( + doc: &RestDescription, + method: &RestMethod, + params: &str, + body: &str, + sanitize: &SanitizeConfig, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let dry_run = matches.get_flag("dry-run"); + let scopes: Vec<&str> = method.scopes.iter().map(String::as_str).collect(); + let token = if dry_run { + None + } else { + Some( + auth::get_token(&scopes) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}")))?, + ) + }; + executor::execute_method_with_policy( + doc, + method, + Some(params), + Some(body), + token.as_deref(), + if token.is_some() { + AuthMethod::OAuth + } else { + AuthMethod::None + }, + None, + None, + dry_run, + &PaginationConfig::default(), + sanitize.template.as_deref(), + &sanitize.mode, + &OutputFormat::default(), + false, + BodyValidationPolicy::AllowUnknownFields, + ) + .await + .map(|_| ()) +} + +fn batch_update_method(doc: &RestDescription) -> Result<&RestMethod, GwsError> { + doc.resources + .get("documents") + .and_then(|resource| resource.methods.get("batchUpdate")) + .ok_or_else(|| GwsError::Discovery("Method 'documents.batchUpdate' not found".into())) +} + +fn document_params(matches: &ArgMatches) -> Result { + let document = matches + .get_one::("document") + .ok_or_else(|| GwsError::Validation("Document ID is required".into()))?; + crate::validate::validate_resource_name(document)?; + Ok(json!({"documentId": document}).to_string()) +} + +fn tab_id(matches: &ArgMatches) -> Option<&str> { + matches.get_one::("tab-id").map(String::as_str) +} + +fn insert_location(matches: &ArgMatches) -> Value { + let mut location = json!({"segmentId": ""}); + if let Some(tab_id) = tab_id(matches) { + location["tabId"] = json!(tab_id); + } + location +} + +fn build_insert_body(matches: &ArgMatches) -> Result { + let text = matches + .get_one::("text") + .ok_or_else(|| GwsError::Validation("Text is required".into()))?; + Ok(json!({ + "requests": [{"insertText": {"text": text, "endOfSegmentLocation": insert_location(matches)}}], + "writeControl": {"writeMode": "SUGGEST"} + }) + .to_string()) +} + +async fn build_replace_body( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result { + if matches.get_flag("dry-run") { + return Err(GwsError::Validation( + "replace cannot use --dry-run because it must read the document to locate the unique match" + .into(), + )); + } + let document = matches.get_one::("document").unwrap(); + let find = matches.get_one::("find").unwrap(); + let read_matches = read_command_matches(document)?; + let normalized = read::run(doc, &read_matches, sanitize, async { + auth::get_token(&["https://www.googleapis.com/auth/documents.readonly"]) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}"))) + }) + .await?; + let (tab_id, start, end) = find_unique_text_run(&normalized, find)?; + let text = matches.get_one::("text").unwrap(); + let mut delete_range = json!({"startIndex": start, "endIndex": end}); + let mut insert_location = json!({"index": start}); + if let Some(tab_id) = tab_id { + delete_range["tabId"] = json!(tab_id); + insert_location["tabId"] = json!(tab_id); + } + Ok(json!({ + "requests": [ + {"deleteContentRange": {"range": delete_range}}, + {"insertText": {"text": text, "location": insert_location}} + ], + "writeControl": {"writeMode": "SUGGEST"} + }) + .to_string()) +} + +fn read_command_matches(document: &str) -> Result { + read::command() + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(clap::ArgAction::SetTrue), + ) + .try_get_matches_from(["+read", "--document", document]) + .map_err(|e| GwsError::Validation(format!("Unable to prepare document read: {e}"))) +} + +fn find_unique_text_run(document: &str, needle: &str) -> Result<(Option, i32, i32), GwsError> { + let mut matches = Vec::new(); + find_text_runs(document, needle, &mut matches); + match matches.as_slice() { + [(tab_id, start, end)] => Ok((tab_id.clone(), *start, *end)), + [] => Err(GwsError::Validation("Text to replace was not found in one text run".into())), + _ => Err(GwsError::Validation("Text to replace matched more than once".into())), + } +} + +fn find_text_runs(value: &str, needle: &str, matches: &mut Vec<(Option, i32, i32)>) { + let Ok(value) = serde_json::from_str::(value) else { + return; + }; + walk_text_runs(&value, needle, None, matches); +} + +fn walk_text_runs( + value: &Value, + needle: &str, + current_tab: Option, + matches: &mut Vec<(Option, i32, i32)>, +) { + match value { + Value::Object(object) => { + let tab = object + .get("tabId") + .and_then(Value::as_str) + .map(String::from) + .or(current_tab); + if object.get("type").and_then(Value::as_str) == Some("text") { + if let (Some(text), Some(start), Some(run_end)) = ( + object.get("text").and_then(Value::as_str), + object.get("startIndex").and_then(Value::as_i64), + object.get("endIndex").and_then(Value::as_i64), + ) { + if let Some(offset) = text.find(needle) { + let start = start + text[..offset].encode_utf16().count() as i64; + let end = start + needle.encode_utf16().count() as i64; + if end <= run_end { + matches.push((tab.clone(), start as i32, end as i32)); + } + } + } + } + for child in object.values() { + walk_text_runs(child, needle, tab.clone(), matches); + } + } + Value::Array(array) => { + for child in array { + walk_text_runs(child, needle, current_tab.clone(), matches); + } + } + _ => {} + } +} + +fn build_delete_text_body(matches: &ArgMatches) -> Result { + let start = *matches.get_one::("start-index").unwrap(); + let end = *matches.get_one::("end-index").unwrap(); + if start < 0 || end <= start { + return Err(GwsError::Validation( + "end-index must be greater than start-index and both must be non-negative".into(), + )); + } + let mut range = json!({"startIndex": start, "endIndex": end}); + if let Some(tab_id) = tab_id(matches) { + range["tabId"] = json!(tab_id); + } + Ok(json!({ + "requests": [{"deleteContentRange": {"range": range}}], + "writeControl": {"writeMode": "SUGGEST"} + }) + .to_string()) +} + +fn build_suggestion_action_body(action: &str, matches: &ArgMatches) -> Result { + let suggestion_id = matches.get_one::("suggestion-id").unwrap(); + let request = match action { + "accept" => json!({"acceptSuggestion": {"suggestionId": suggestion_id}}), + "reject" => json!({"rejectSuggestion": {"suggestionId": suggestion_id}}), + "delete" => json!({"deleteSuggestion": {"suggestionId": suggestion_id}}), + _ => return Err(GwsError::Validation(format!("Unknown suggestion action: {action}"))), + }; + Ok(json!({"requests": [request]}).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_body_uses_suggest_mode() { + let matches = command() + .try_get_matches_from(["+suggest", "insert", "--document", "doc", "--text", "hello"]) + .unwrap(); + let body = build_insert_body(matches.subcommand_matches("insert").unwrap()).unwrap(); + let body: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(body["writeControl"]["writeMode"], "SUGGEST"); + assert_eq!(body["requests"][0]["insertText"]["text"], "hello"); + } + + #[test] + fn delete_text_rejects_invalid_range() { + let matches = command() + .try_get_matches_from([ + "+suggest", "delete-text", "--document", "doc", "--start-index", "4", + "--end-index", "4", + ]) + .unwrap(); + let error = build_delete_text_body(matches.subcommand_matches("delete-text").unwrap()) + .unwrap_err(); + assert!(error.to_string().contains("end-index")); + } + + #[test] + fn suggestion_action_uses_requested_id() { + let matches = command() + .try_get_matches_from([ + "+suggest", "accept", "--document", "doc", "--suggestion-id", "s1", + ]) + .unwrap(); + let body = build_suggestion_action_body("accept", matches.subcommand_matches("accept").unwrap()).unwrap(); + let body: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(body["requests"][0]["acceptSuggestion"]["suggestionId"], "s1"); + assert!(body.get("writeControl").is_none()); + } + + #[test] + fn finds_unique_match_using_utf16_indices() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"A😀BC","startIndex":5,"endIndex":10}]}]}]}"#; + assert_eq!( + find_unique_text_run(document, "😀B").unwrap(), + (Some("tab-1".into()), 6, 9) + ); + } +} From 4716d5df008cb2b416bd872841a98c79affedff4 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 15:53:57 -0300 Subject: [PATCH 2/9] style: format docs suggestion helper --- .../src/helpers/docs/suggest.rs | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs index 31a83c836..452c09faf 100644 --- a/crates/google-workspace-cli/src/helpers/docs/suggest.rs +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -127,7 +127,11 @@ pub(super) async fn handle( let body = build_suggestion_action_body(action, action_matches)?; (document_params(action_matches)?, body, method) } - _ => return Err(GwsError::Validation(format!("Unknown suggestion action: {action}"))), + _ => { + return Err(GwsError::Validation(format!( + "Unknown suggestion action: {action}" + ))) + } }; execute_suggestion_write(doc, method, ¶ms, &body, sanitize, action_matches).await @@ -279,13 +283,20 @@ fn read_command_matches(document: &str) -> Result { .map_err(|e| GwsError::Validation(format!("Unable to prepare document read: {e}"))) } -fn find_unique_text_run(document: &str, needle: &str) -> Result<(Option, i32, i32), GwsError> { +fn find_unique_text_run( + document: &str, + needle: &str, +) -> Result<(Option, i32, i32), GwsError> { let mut matches = Vec::new(); find_text_runs(document, needle, &mut matches); match matches.as_slice() { [(tab_id, start, end)] => Ok((tab_id.clone(), *start, *end)), - [] => Err(GwsError::Validation("Text to replace was not found in one text run".into())), - _ => Err(GwsError::Validation("Text to replace matched more than once".into())), + [] => Err(GwsError::Validation( + "Text to replace was not found in one text run".into(), + )), + _ => Err(GwsError::Validation( + "Text to replace matched more than once".into(), + )), } } @@ -362,7 +373,11 @@ fn build_suggestion_action_body(action: &str, matches: &ArgMatches) -> Result json!({"acceptSuggestion": {"suggestionId": suggestion_id}}), "reject" => json!({"rejectSuggestion": {"suggestionId": suggestion_id}}), "delete" => json!({"deleteSuggestion": {"suggestionId": suggestion_id}}), - _ => return Err(GwsError::Validation(format!("Unknown suggestion action: {action}"))), + _ => { + return Err(GwsError::Validation(format!( + "Unknown suggestion action: {action}" + ))) + } }; Ok(json!({"requests": [request]}).to_string()) } @@ -386,12 +401,18 @@ mod tests { fn delete_text_rejects_invalid_range() { let matches = command() .try_get_matches_from([ - "+suggest", "delete-text", "--document", "doc", "--start-index", "4", - "--end-index", "4", + "+suggest", + "delete-text", + "--document", + "doc", + "--start-index", + "4", + "--end-index", + "4", ]) .unwrap(); - let error = build_delete_text_body(matches.subcommand_matches("delete-text").unwrap()) - .unwrap_err(); + let error = + build_delete_text_body(matches.subcommand_matches("delete-text").unwrap()).unwrap_err(); assert!(error.to_string().contains("end-index")); } @@ -399,12 +420,22 @@ mod tests { fn suggestion_action_uses_requested_id() { let matches = command() .try_get_matches_from([ - "+suggest", "accept", "--document", "doc", "--suggestion-id", "s1", + "+suggest", + "accept", + "--document", + "doc", + "--suggestion-id", + "s1", ]) .unwrap(); - let body = build_suggestion_action_body("accept", matches.subcommand_matches("accept").unwrap()).unwrap(); + let body = + build_suggestion_action_body("accept", matches.subcommand_matches("accept").unwrap()) + .unwrap(); let body: Value = serde_json::from_str(&body).unwrap(); - assert_eq!(body["requests"][0]["acceptSuggestion"]["suggestionId"], "s1"); + assert_eq!( + body["requests"][0]["acceptSuggestion"]["suggestionId"], + "s1" + ); assert!(body.get("writeControl").is_none()); } From 57542582650c0633297d2ac10832d393466fac2f Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 15:54:19 -0300 Subject: [PATCH 3/9] style: apply rustfmt to docs helper tests --- crates/google-workspace-cli/src/helpers/docs.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index ea538921d..ac9d712e6 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -225,5 +225,4 @@ mod tests { assert!(body.contains("endOfSegmentLocation")); assert_eq!(scopes[0], "https://scope"); } - } From b0c6482b23e0e5f77ed5e7a8d6f7d2c830099a8d Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 16:25:36 -0300 Subject: [PATCH 4/9] feat(docs): read comments and referenced text --- .changeset/docs-read-comments.md | 6 + README.md | 13 ++ .../src/helpers/docs/read.rs | 186 +++++++++++++++++- .../src/helpers/docs/read_tests.rs | 66 ++++++- 4 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 .changeset/docs-read-comments.md diff --git a/.changeset/docs-read-comments.md b/.changeset/docs-read-comments.md new file mode 100644 index 000000000..dca2acc63 --- /dev/null +++ b/.changeset/docs-read-comments.md @@ -0,0 +1,6 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `--include-comments` to `gws docs +read` to return comment threads and the +text referenced by each comment anchor range. \ No newline at end of file diff --git a/README.md b/README.md index 1b63f2c34..56f8063ea 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,19 @@ The helper applies the preview-only `writeMode` request fields internally, so these commands do not need `--allow-unknown-fields`. Suggestion writes remain subject to Google Workspace Developer Preview access and document permissions. +### Reading comments and their text anchors + +Use `--include-comments` with `gws docs +read` to retrieve comment threads and +resolve each anchored range to the text it refers to: + +```bash +gws docs +read --document DOC_ID --include-comments +``` + +Each comment includes its thread data, anchor ranges, and `referencedText`, an +array with one value per anchored range. Unresolvable ranges are returned as +`null`; comments remain opt-in because they may contain sensitive content. + ```bash # Preview a suggested insertion (Docs Developer Preview). gws docs documents batchUpdate \ diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs index b49eec62f..f0e9af68d 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -29,6 +29,7 @@ pub(super) fn command() -> Command { .about("[Helper] Read a document as compact structured content") .arg(Arg::new("document").long("document").help("Document ID").required(true).value_name("ID")) .arg(Arg::new("params").long("params").help("Additional documents.get API parameters as JSON").value_name("JSON")) + .arg(Arg::new("include-comments").long("include-comments").help("Include comments and their referenced text").action(clap::ArgAction::SetTrue)) .after_help( "\ EXAMPLES: @@ -52,7 +53,8 @@ TIPS: revisionId and suggestionsViewMode are retained when returned. Missing revisionId is not synthesized. source=legacyBody indicates a fallback response without populated tabs; all-tab coverage cannot be confirmed. This is a content view, not a layout renderer or lossless API round trip. Inherited styles are not resolved. - Suggestions remain inline, including proposed deletions; this helper does not accept or reject suggestions. + Suggestions remain inline, including proposed deletions; this helper does not accept or reject suggestions. + --include-comments requests comment threads and resolves each comment anchor to referenced text. Use raw documents get for unsupported views or field masks. Missing body content produces an error. --dry-run validates and prints a request plan without acquiring credentials or fetching document content. --sanitize uses the existing Model Armor policy before normalization and retains _sanitization metadata.", @@ -82,9 +84,16 @@ pub(crate) async fn run( sanitize: &SanitizeConfig, token: impl Future>, ) -> Result { - let params = build_params( + let include_comments = matches + .try_get_one::("include-comments") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let params = build_params_with_comments( matches.get_one::("document").unwrap(), matches.get_one::("params").map(String::as_str), + include_comments, )?; let method = doc .resources @@ -121,11 +130,21 @@ pub(crate) async fn run( ) .await? .ok_or_else(|| invalid_content("expected a JSON document response"))?; - let output = if dry_run { result } else { normalize(&result)? }; + let output = if dry_run { + result + } else if include_comments { + normalize_with_comments(&result)? + } else { + normalize(&result)? + }; Ok(format_value(&output, &format)) } -pub(super) fn build_params(document: &str, params: Option<&str>) -> Result { +pub(super) fn build_params_with_comments( + document: &str, + params: Option<&str>, + include_comments: bool, +) -> Result { crate::validate::validate_resource_name(document)?; let mut params: Map = match params { Some(raw) => serde_json::from_str(raw) @@ -153,6 +172,22 @@ pub(super) fn build_params(document: &str, params: Option<&str>) -> Result Result { Ok(Value::Object(result)) } +pub(super) fn normalize_with_comments(document: &Value) -> Result { + let mut output = normalize(document)?; + let comments = document + .get("comments") + .and_then(Value::as_array) + .ok_or_else(|| { + invalid_content("comments were requested but response has no comments array") + })?; + let anchors = collect_comment_anchors(document); + let text_runs = collect_text_runs(&output); + let enriched = comments + .iter() + .map(|comment| { + let mut comment = object(comment)?; + let anchor_id = comment.get("anchorId").and_then(Value::as_str); + let referenced_text = anchor_id + .and_then(|id| anchors.get(id)) + .map(|ranges| { + ranges + .iter() + .map(|range| resolve_range(range, &text_runs)) + .collect::>() + }) + .unwrap_or_default(); + comment.insert("referencedText".into(), Value::Array(referenced_text)); + Ok(Value::Object(comment)) + }) + .collect::, GwsError>>()?; + output["comments"] = Value::Array(enriched); + Ok(output) +} + +fn collect_comment_anchors(document: &Value) -> std::collections::HashMap> { + let mut anchors = std::collections::HashMap::new(); + collect_comment_anchors_recursive(document, &mut anchors); + anchors +} + +fn collect_comment_anchors_recursive( + value: &Value, + anchors: &mut std::collections::HashMap>, +) { + match value { + Value::Object(object) => { + if let Some(comment_anchors) = object.get("commentAnchors").and_then(Value::as_object) { + for (id, anchor) in comment_anchors { + let ranges = anchor + .get("ranges") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + anchors.insert(id.clone(), ranges); + } + } + for child in object.values() { + collect_comment_anchors_recursive(child, anchors); + } + } + Value::Array(array) => { + for child in array { + collect_comment_anchors_recursive(child, anchors); + } + } + _ => {} + } +} + +type TextRun = (Option, i64, i64, String); + +fn collect_text_runs(document: &Value) -> Vec { + let mut runs = Vec::new(); + collect_text_runs_recursive(document, None, &mut runs); + runs +} + +fn collect_text_runs_recursive(value: &Value, tab_id: Option, runs: &mut Vec) { + match value { + Value::Object(object) => { + let tab_id = object + .get("tabId") + .and_then(Value::as_str) + .map(String::from) + .or(tab_id); + if object.get("type").and_then(Value::as_str) == Some("text") { + if let (Some(start), Some(end), Some(text)) = ( + object.get("startIndex").and_then(Value::as_i64), + object.get("endIndex").and_then(Value::as_i64), + object.get("text").and_then(Value::as_str), + ) { + runs.push((tab_id.clone(), start, end, text.to_string())); + } + } + for child in object.values() { + collect_text_runs_recursive(child, tab_id.clone(), runs); + } + } + Value::Array(array) => { + for child in array { + collect_text_runs_recursive(child, tab_id.clone(), runs); + } + } + _ => {} + } +} + +fn resolve_range(range: &Value, runs: &[TextRun]) -> Value { + let Some(start) = range.get("startIndex").and_then(Value::as_i64) else { + return Value::Null; + }; + let Some(end) = range.get("endIndex").and_then(Value::as_i64) else { + return Value::Null; + }; + let tab_id = range.get("tabId").and_then(Value::as_str); + let tab_count = runs + .iter() + .filter_map(|(run_tab, _, _, _)| run_tab.as_deref()) + .collect::>() + .len(); + let mut fragments = Vec::new(); + for (run_tab, run_start, run_end, text) in runs { + let tab_matches = match tab_id { + Some(tab_id) => run_tab.as_deref() == Some(tab_id), + None => run_tab.is_none() || tab_count <= 1, + }; + if !tab_matches || *run_end <= start || *run_start >= end { + continue; + } + let from = (start.max(*run_start) - *run_start) as usize; + let to = (end.min(*run_end) - *run_start) as usize; + fragments.push(slice_utf16(text, from, to)); + } + if fragments.is_empty() { + Value::Null + } else { + Value::String(fragments.concat()) + } +} + +fn slice_utf16(text: &str, start: usize, end: usize) -> String { + let units: Vec = text.encode_utf16().collect(); + String::from_utf16_lossy(&units[start.min(units.len())..end.min(units.len())]) +} + fn normalize_tab( tab: &Value, parent: &Value, diff --git a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs index d0152bf9f..050968b36 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs @@ -110,8 +110,12 @@ fn auto_text_preserves_page_number_and_count_with_indices_and_styles() { #[test] fn request_requires_full_inline_tabs_and_preserves_other_params() { - let params = - read::build_params("synthetic", Some(r#"{"fields":"*","prettyPrint":false}"#)).unwrap(); + let params = read::build_params_with_comments( + "synthetic", + Some(r#"{"fields":"*","prettyPrint":false}"#), + false, + ) + .unwrap(); assert_eq!( params, json!({ @@ -120,12 +124,54 @@ fn request_requires_full_inline_tabs_and_preserves_other_params() { }) ); assert_eq!( - read::build_params("id", None).unwrap()["includeTabsContent"], + read::build_params_with_comments("id", None, false).unwrap()["includeTabsContent"], true ); - assert!(read::build_params("id", Some( + assert!(read::build_params_with_comments("id", Some( r#"{"includeTabsContent":true,"suggestionsViewMode":"SUGGESTIONS_INLINE","documentId":"id"}"# - )).is_ok()); + ), false).is_ok()); +} + +#[test] +fn comments_are_opt_in_and_request_includes_comment_view_mode() { + let params = read::build_params_with_comments("synthetic", None, true).unwrap(); + assert_eq!(params["commentsViewMode"], "COMMENTS_VIEW_MODE_INCLUDED"); +} + +#[test] +fn comments_include_text_for_each_anchor_range() { + let mut input = legacy(); + input["comments"] = json!([{ + "commentId": "c1", + "anchorId": "a1", + "headPost": {"content": "Please review"}, + "status": "OPEN" + }]); + input["tabs"] = json!([{ + "tabProperties": {"tabId": "tab-1"}, + "documentTab": { + "body": {"content": [{ + "startIndex": 1, "endIndex": 12, + "paragraph": {"elements": [{ + "startIndex": 1, "endIndex": 12, + "textRun": {"content": "Hello world"} + }]} + }]}, + "commentAnchors": { + "a1": {"anchorId": "a1", "ranges": [ + {"startIndex": 7, "endIndex": 12}, + {"startIndex": 1, "endIndex": 6} + ]} + } + } + }]); + + let output = read::normalize_with_comments(&input).unwrap(); + assert_eq!(output["comments"][0]["commentId"], "c1"); + assert_eq!( + output["comments"][0]["referencedText"], + json!(["world", "Hello"]) + ); } #[test] @@ -147,7 +193,10 @@ fn request_rejects_partial_masks_lossy_views_and_parameter_bypasses() { "null", "{", ] { - assert!(read::build_params("id", Some(params)).is_err(), "{params}"); + assert!( + read::build_params_with_comments("id", Some(params), false).is_err(), + "{params}" + ); } for id in [ "", @@ -157,7 +206,10 @@ fn request_rejects_partial_masks_lossy_views_and_parameter_bypasses() { "id\n", "%2e%2e", ] { - assert!(read::build_params(id, None).is_err(), "{id:?}"); + assert!( + read::build_params_with_comments(id, None, false).is_err(), + "{id:?}" + ); } } From f81bf9f61979689a83e30dd007bb7619c2dd07a0 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 16:44:58 -0300 Subject: [PATCH 5/9] feat(docs): add comment creation helper --- .changeset/docs-comment-create.md | 6 + README.md | 14 ++ .../google-workspace-cli/src/helpers/docs.rs | 6 + .../src/helpers/docs/comment.rs | 215 ++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 .changeset/docs-comment-create.md create mode 100644 crates/google-workspace-cli/src/helpers/docs/comment.rs diff --git a/.changeset/docs-comment-create.md b/.changeset/docs-comment-create.md new file mode 100644 index 000000000..b0899d14b --- /dev/null +++ b/.changeset/docs-comment-create.md @@ -0,0 +1,6 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `gws docs +comment create` for creating anchored Google Docs comments +without manually using preview-only request fields. \ No newline at end of file diff --git a/README.md b/README.md index 56f8063ea..a007bb3eb 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,20 @@ Each comment includes its thread data, anchor ranges, and `referencedText`, an array with one value per anchored range. Unresolvable ranges are returned as `null`; comments remain opt-in because they may contain sensitive content. +Create a comment without manually constructing the preview API payload: + +```bash +gws docs +comment create \ + --document DOC_ID \ + --text 'Please review this.' \ + --start-index 1 \ + --end-index 20 +``` + +The helper validates UTF-16 ranges and applies the preview-field opt-in +internally, so `--allow-unknown-fields` is not required. The request still +requires edit access and Google Workspace Developer Preview availability. + ```bash # Preview a suggested insertion (Docs Developer Preview). gws docs documents batchUpdate \ diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index ac9d712e6..3f2c29c7e 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -21,6 +21,7 @@ use serde_json::json; use std::future::Future; use std::pin::Pin; +mod comment; mod read; mod suggest; @@ -38,6 +39,7 @@ impl Helper for DocsHelper { ) -> Command { cmd = cmd.subcommand(read::command()); cmd = cmd.subcommand(suggest::command()); + cmd = cmd.subcommand(comment::command()); cmd = cmd.subcommand( Command::new("+write") .about("[Helper] Append text to a document") @@ -83,6 +85,10 @@ TIPS: suggest::handle(doc, matches, sanitize_config).await?; return Ok(true); } + if let Some(matches) = matches.subcommand_matches("+comment") { + comment::handle(doc, matches, sanitize_config).await?; + return Ok(true); + } if let Some(matches) = matches.subcommand_matches("+write") { let (params_str, body_str, scopes) = build_write_request(matches, doc)?; diff --git a/crates/google-workspace-cli/src/helpers/docs/comment.rs b/crates/google-workspace-cli/src/helpers/docs/comment.rs new file mode 100644 index 000000000..5f605803e --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/docs/comment.rs @@ -0,0 +1,215 @@ +use crate::auth; +use crate::discovery::{RestDescription, RestMethod}; +use crate::error::GwsError; +use crate::executor::{self, AuthMethod, BodyValidationPolicy, PaginationConfig}; +use crate::formatter::OutputFormat; +use crate::helpers::modelarmor::SanitizeConfig; +use clap::{Arg, ArgMatches, Command}; +use serde_json::json; + +pub(super) fn command() -> Command { + Command::new("+comment") + .about("[Helper] Create a comment anchored to document text") + .subcommand( + Command::new("create") + .about("Create a comment on a document range") + .arg( + Arg::new("document") + .long("document") + .help("Document ID") + .required(true) + .value_name("ID"), + ) + .arg( + Arg::new("text") + .long("text") + .help("Comment text") + .required(true) + .value_name("TEXT"), + ) + .arg(index_arg("start-index")) + .arg(index_arg("end-index")) + .arg(Arg::new("tab-id").long("tab-id").value_name("ID")), + ) + .after_help( + "EXAMPLES:\n gws docs +comment create --document DOC_ID --text 'Please review this.' --start-index 1 --end-index 20\n\nTIPS:\n Indexes are UTF-16 document indexes.\n Comment creation is a Google Workspace Developer Preview feature.\n Use --dry-run to validate without authentication or sending the request.", + ) +} + +fn index_arg(name: &'static str) -> Arg { + Arg::new(name) + .long(name) + .help("UTF-16 document index") + .required(true) + .value_parser(clap::value_parser!(i32)) +} + +pub(super) async fn handle( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result<(), GwsError> { + let (action, action_matches) = matches + .subcommand() + .ok_or_else(|| GwsError::Validation("docs +comment requires an action".into()))?; + if action != "create" { + return Err(GwsError::Validation(format!( + "Unknown comment action: {action}" + ))); + } + let method = batch_update_method(doc)?; + let params = document_params(action_matches)?; + let body = build_comment_create_body(action_matches)?; + let dry_run = action_matches.get_flag("dry-run"); + let scopes: Vec<&str> = method.scopes.iter().map(String::as_str).collect(); + let token = if dry_run { + None + } else { + Some( + auth::get_token(&scopes) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}")))?, + ) + }; + executor::execute_method_with_policy( + doc, + method, + Some(¶ms), + Some(&body), + token.as_deref(), + if token.is_some() { + AuthMethod::OAuth + } else { + AuthMethod::None + }, + None, + None, + dry_run, + &PaginationConfig::default(), + sanitize.template.as_deref(), + &sanitize.mode, + &OutputFormat::default(), + false, + BodyValidationPolicy::AllowUnknownFields, + ) + .await + .map(|_| ()) +} + +fn batch_update_method(doc: &RestDescription) -> Result<&RestMethod, GwsError> { + doc.resources + .get("documents") + .and_then(|resource| resource.methods.get("batchUpdate")) + .ok_or_else(|| GwsError::Discovery("Method 'documents.batchUpdate' not found".into())) +} + +fn document_params(matches: &ArgMatches) -> Result { + let document = matches + .get_one::("document") + .ok_or_else(|| GwsError::Validation("Document ID is required".into()))?; + crate::validate::validate_resource_name(document)?; + Ok(json!({"documentId": document}).to_string()) +} + +fn build_comment_create_body(matches: &ArgMatches) -> Result { + let text = matches + .get_one::("text") + .ok_or_else(|| GwsError::Validation("Comment text is required".into()))?; + let start = *matches + .get_one::("start-index") + .ok_or_else(|| GwsError::Validation("start-index is required".into()))?; + let end = *matches + .get_one::("end-index") + .ok_or_else(|| GwsError::Validation("end-index is required".into()))?; + if start < 0 || end <= start { + return Err(GwsError::Validation( + "end-index must be greater than start-index and both must be non-negative".into(), + )); + } + let mut range = json!({"startIndex": start, "endIndex": end}); + if let Some(tab_id) = matches.get_one::("tab-id") { + range["tabId"] = json!(tab_id); + } + Ok(json!({ + "requests": [{"insertComment": {"content": text, "range": range}}] + }) + .to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn comment_create_body_uses_requested_range_and_content() { + let matches = Command::new("+comment") + .arg(Arg::new("document").long("document")) + .arg(Arg::new("text").long("text")) + .arg( + Arg::new("start-index") + .long("start-index") + .value_parser(clap::value_parser!(i32)), + ) + .arg( + Arg::new("end-index") + .long("end-index") + .value_parser(clap::value_parser!(i32)), + ) + .arg(Arg::new("tab-id").long("tab-id")) + .try_get_matches_from([ + "+comment", + "--document", + "doc", + "--text", + "Review this", + "--start-index", + "1", + "--end-index", + "10", + ]) + .unwrap(); + let body = build_comment_create_body(&matches).unwrap(); + let body: Value = serde_json::from_str(&body).unwrap(); + assert_eq!( + body["requests"][0]["insertComment"]["content"], + "Review this" + ); + assert_eq!( + body["requests"][0]["insertComment"]["range"]["startIndex"], + 1 + ); + assert_eq!( + body["requests"][0]["insertComment"]["range"]["endIndex"], + 10 + ); + } + + #[test] + fn comment_create_rejects_non_positive_range() { + let matches = Command::new("+comment") + .arg(Arg::new("text").long("text")) + .arg( + Arg::new("start-index") + .long("start-index") + .value_parser(clap::value_parser!(i32)), + ) + .arg( + Arg::new("end-index") + .long("end-index") + .value_parser(clap::value_parser!(i32)), + ) + .arg(Arg::new("tab-id").long("tab-id")) + .try_get_matches_from([ + "+comment", + "--text", + "Review this", + "--start-index", + "10", + "--end-index", + "10", + ]) + .unwrap(); + assert!(build_comment_create_body(&matches).is_err()); + } +} From 16d8357a13636b71a56cee0da157e92fad65a78a Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 16:53:14 -0300 Subject: [PATCH 6/9] fix(docs): harden suggested replacements --- .../src/helpers/docs/suggest.rs | 74 +++++++++++++++++-- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs index 452c09faf..da8fe046b 100644 --- a/crates/google-workspace-cli/src/helpers/docs/suggest.rs +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -247,6 +247,11 @@ async fn build_replace_body( } let document = matches.get_one::("document").unwrap(); let find = matches.get_one::("find").unwrap(); + if find.is_empty() { + return Err(GwsError::Validation( + "find must not be empty when replacing text".into(), + )); + } let read_matches = read_command_matches(document)?; let normalized = read::run(doc, &read_matches, sanitize, async { auth::get_token(&["https://www.googleapis.com/auth/documents.readonly"]) @@ -254,7 +259,19 @@ async fn build_replace_body( .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}"))) }) .await?; - let (tab_id, start, end) = find_unique_text_run(&normalized, find)?; + let normalized_value: Value = serde_json::from_str(&normalized) + .map_err(|e| GwsError::Validation(format!("Invalid normalized Docs response: {e}")))?; + let revision_id = normalized_value + .get("revisionId") + .and_then(Value::as_str) + .filter(|revision| !revision.is_empty()) + .ok_or_else(|| { + GwsError::Validation( + "Docs response did not include a revisionId; refusing an unprotected replacement" + .into(), + ) + })?; + let (tab_id, start, end) = find_unique_text_run(&normalized, find, tab_id(matches))?; let text = matches.get_one::("text").unwrap(); let mut delete_range = json!({"startIndex": start, "endIndex": end}); let mut insert_location = json!({"index": start}); @@ -267,7 +284,7 @@ async fn build_replace_body( {"deleteContentRange": {"range": delete_range}}, {"insertText": {"text": text, "location": insert_location}} ], - "writeControl": {"writeMode": "SUGGEST"} + "writeControl": {"writeMode": "SUGGEST", "requiredRevisionId": revision_id} }) .to_string()) } @@ -286,9 +303,15 @@ fn read_command_matches(document: &str) -> Result { fn find_unique_text_run( document: &str, needle: &str, + requested_tab: Option<&str>, ) -> Result<(Option, i32, i32), GwsError> { + if needle.is_empty() { + return Err(GwsError::Validation( + "Text to replace must not be empty".into(), + )); + } let mut matches = Vec::new(); - find_text_runs(document, needle, &mut matches); + find_text_runs(document, needle, requested_tab, &mut matches); match matches.as_slice() { [(tab_id, start, end)] => Ok((tab_id.clone(), *start, *end)), [] => Err(GwsError::Validation( @@ -300,11 +323,27 @@ fn find_unique_text_run( } } -fn find_text_runs(value: &str, needle: &str, matches: &mut Vec<(Option, i32, i32)>) { +fn find_text_runs( + value: &str, + needle: &str, + requested_tab: Option<&str>, + matches: &mut Vec<(Option, i32, i32)>, +) { let Ok(value) = serde_json::from_str::(value) else { return; }; - walk_text_runs(&value, needle, None, matches); + let Some(tabs) = value.get("tabs").and_then(Value::as_array) else { + return; + }; + for tab in tabs { + let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); + if requested_tab.is_some_and(|requested| tab_id.as_deref() != Some(requested)) { + continue; + } + if let Some(blocks) = tab.get("blocks") { + walk_text_runs(blocks, needle, tab_id, matches); + } + } } fn walk_text_runs( @@ -326,7 +365,7 @@ fn walk_text_runs( object.get("startIndex").and_then(Value::as_i64), object.get("endIndex").and_then(Value::as_i64), ) { - if let Some(offset) = text.find(needle) { + for offset in text.match_indices(needle).map(|(offset, _)| offset) { let start = start + text[..offset].encode_utf16().count() as i64; let end = start + needle.encode_utf16().count() as i64; if end <= run_end { @@ -443,8 +482,29 @@ mod tests { fn finds_unique_match_using_utf16_indices() { let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"A😀BC","startIndex":5,"endIndex":10}]}]}]}"#; assert_eq!( - find_unique_text_run(document, "😀B").unwrap(), + find_unique_text_run(document, "😀B", None).unwrap(), (Some("tab-1".into()), 6, 9) ); } + + #[test] + fn rejects_empty_replacement_text() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"hello","startIndex":1,"endIndex":6}]}]}]}"#; + assert!(find_unique_text_run(document, "", None).is_err()); + } + + #[test] + fn rejects_duplicate_matches_within_one_text_run() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"foo foo","startIndex":1,"endIndex":8}]}]}]}"#; + assert!(find_unique_text_run(document, "foo", None).is_err()); + } + + #[test] + fn filters_replacement_matches_by_tab_id() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"foo","startIndex":1,"endIndex":4}]}]},{"tabId":"tab-2","blocks":[{"elements":[{"type":"text","text":"foo","startIndex":1,"endIndex":4}]}]}]}"#; + assert_eq!( + find_unique_text_run(document, "foo", Some("tab-2")).unwrap(), + (Some("tab-2".into()), 1, 4) + ); + } } From 92ff044f22377c34898ca5f7db3bafed717f221f Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 16:57:50 -0300 Subject: [PATCH 7/9] fix(docs): address comment review findings --- README.md | 1 + crates/google-workspace-cli/src/helpers/docs/read.rs | 12 ++++++------ .../src/helpers/docs/read_tests.rs | 6 ++++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a007bb3eb..c0058383d 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,7 @@ gws drive --help # shows +upload … | `sheets` | `+read` | Read values from a spreadsheet | | `docs` | `+write` | Append text to a document | | `docs` | `+suggest` | Create and manage document suggestions | +| `docs` | `+comment` | Create anchored document comments | | `chat` | `+send` | Send a message to a space | | `drive` | `+upload` | Upload a file with automatic metadata | | `calendar` | `+insert` | Create a new event | diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs index f0e9af68d..117e8ff91 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -274,12 +274,12 @@ pub(super) fn normalize(document: &Value) -> Result { pub(super) fn normalize_with_comments(document: &Value) -> Result { let mut output = normalize(document)?; - let comments = document - .get("comments") - .and_then(Value::as_array) - .ok_or_else(|| { - invalid_content("comments were requested but response has no comments array") - })?; + let comments: &[Value] = match document.get("comments") { + Some(comments) => comments + .as_array() + .ok_or_else(|| invalid_content("response comments field is not an array"))?, + None => &[], + }; let anchors = collect_comment_anchors(document); let text_runs = collect_text_runs(&output); let enriched = comments diff --git a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs index 050968b36..95f33d673 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs @@ -174,6 +174,12 @@ fn comments_include_text_for_each_anchor_range() { ); } +#[test] +fn comments_without_threads_are_returned_as_empty() { + let output = read::normalize_with_comments(&legacy()).unwrap(); + assert_eq!(output["comments"], json!([])); +} + #[test] fn request_rejects_partial_masks_lossy_views_and_parameter_bypasses() { for params in [ From b2d17879f1d73e9c823ad475f0b9ff8fc0f7ed3e Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 17:03:05 -0300 Subject: [PATCH 8/9] fix(docs): handle nested suggestion replacements --- .../src/helpers/docs/suggest.rs | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs index da8fe046b..a638c096e 100644 --- a/crates/google-workspace-cli/src/helpers/docs/suggest.rs +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -161,7 +161,7 @@ async fn execute_suggestion_write( matches: &ArgMatches, ) -> Result<(), GwsError> { let dry_run = matches.get_flag("dry-run"); - let scopes: Vec<&str> = method.scopes.iter().map(String::as_str).collect(); + let scopes: Vec<&str> = crate::select_scope(&method.scopes).into_iter().collect(); let token = if dry_run { None } else { @@ -332,16 +332,28 @@ fn find_text_runs( let Ok(value) = serde_json::from_str::(value) else { return; }; - let Some(tabs) = value.get("tabs").and_then(Value::as_array) else { - return; - }; - for tab in tabs { - let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); - if requested_tab.is_some_and(|requested| tab_id.as_deref() != Some(requested)) { - continue; + if let Some(tabs) = value.get("tabs").and_then(Value::as_array) { + for tab in tabs { + walk_tab_text_runs(tab, needle, requested_tab, matches); } + } +} + +fn walk_tab_text_runs( + tab: &Value, + needle: &str, + requested_tab: Option<&str>, + matches: &mut Vec<(Option, i32, i32)>, +) { + let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); + if requested_tab.is_none_or(|requested| tab_id.as_deref() == Some(requested)) { if let Some(blocks) = tab.get("blocks") { - walk_text_runs(blocks, needle, tab_id, matches); + walk_text_runs(blocks, needle, tab_id.clone(), matches); + } + } + if let Some(children) = tab.get("childTabs").and_then(Value::as_array) { + for child in children { + walk_tab_text_runs(child, needle, requested_tab, matches); } } } @@ -365,7 +377,9 @@ fn walk_text_runs( object.get("startIndex").and_then(Value::as_i64), object.get("endIndex").and_then(Value::as_i64), ) { - for offset in text.match_indices(needle).map(|(offset, _)| offset) { + for offset in text.char_indices().filter_map(|(offset, _)| { + text[offset..].starts_with(needle).then_some(offset) + }) { let start = start + text[..offset].encode_utf16().count() as i64; let end = start + needle.encode_utf16().count() as i64; if end <= run_end { @@ -507,4 +521,19 @@ mod tests { (Some("tab-2".into()), 1, 4) ); } + + #[test] + fn rejects_overlapping_matches() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"aaa","startIndex":1,"endIndex":4}]}]}]}"#; + assert!(find_unique_text_run(document, "aa", None).is_err()); + } + + #[test] + fn finds_text_in_child_tabs() { + let document = r#"{"tabs":[{"tabId":"root","blocks":[],"childTabs":[{"tabId":"child","blocks":[{"elements":[{"type":"text","text":"child text","startIndex":1,"endIndex":11}]}],"childTabs":[]}]}]}"#; + assert_eq!( + find_unique_text_run(document, "child", Some("child")).unwrap(), + (Some("child".into()), 1, 6) + ); + } } From f941a79f170580fdfd8dc4bf77834c32c1a44e54 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 17:11:04 -0300 Subject: [PATCH 9/9] fix(docs): preserve comment anchor segments --- .../src/helpers/docs/read.rs | 72 +++++++++++++++---- .../src/helpers/docs/read_tests.rs | 35 +++++++++ 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs index 117e8ff91..33a06e2d9 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -339,38 +339,74 @@ fn collect_comment_anchors_recursive( } } -type TextRun = (Option, i64, i64, String); +type TextRun = (Option, Option, i64, i64, String); fn collect_text_runs(document: &Value) -> Vec { let mut runs = Vec::new(); - collect_text_runs_recursive(document, None, &mut runs); + if let Some(tabs) = document.get("tabs").and_then(Value::as_array) { + for tab in tabs { + collect_tab_text_runs(tab, &mut runs); + } + } runs } -fn collect_text_runs_recursive(value: &Value, tab_id: Option, runs: &mut Vec) { +fn collect_tab_text_runs(tab: &Value, runs: &mut Vec) { + let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); + if let Some(blocks) = tab.get("blocks") { + collect_text_runs_recursive(blocks, tab_id.clone(), None, runs); + } + for segment_name in ["headers", "footers", "footnotes"] { + if let Some(segments) = tab.get(segment_name).and_then(Value::as_object) { + for (segment_id, segment) in segments { + if let Some(blocks) = segment.get("blocks") { + collect_text_runs_recursive( + blocks, + tab_id.clone(), + Some(segment_id.clone()), + runs, + ); + } + } + } + } + if let Some(children) = tab.get("childTabs").and_then(Value::as_array) { + for child in children { + collect_tab_text_runs(child, runs); + } + } +} + +fn collect_text_runs_recursive( + value: &Value, + tab_id: Option, + segment_id: Option, + runs: &mut Vec, +) { match value { Value::Object(object) => { - let tab_id = object - .get("tabId") - .and_then(Value::as_str) - .map(String::from) - .or(tab_id); if object.get("type").and_then(Value::as_str) == Some("text") { if let (Some(start), Some(end), Some(text)) = ( object.get("startIndex").and_then(Value::as_i64), object.get("endIndex").and_then(Value::as_i64), object.get("text").and_then(Value::as_str), ) { - runs.push((tab_id.clone(), start, end, text.to_string())); + runs.push(( + tab_id.clone(), + segment_id.clone(), + start, + end, + text.to_string(), + )); } } for child in object.values() { - collect_text_runs_recursive(child, tab_id.clone(), runs); + collect_text_runs_recursive(child, tab_id.clone(), segment_id.clone(), runs); } } Value::Array(array) => { for child in array { - collect_text_runs_recursive(child, tab_id.clone(), runs); + collect_text_runs_recursive(child, tab_id.clone(), segment_id.clone(), runs); } } _ => {} @@ -385,18 +421,26 @@ fn resolve_range(range: &Value, runs: &[TextRun]) -> Value { return Value::Null; }; let tab_id = range.get("tabId").and_then(Value::as_str); + let segment_id = range + .get("segmentId") + .and_then(Value::as_str) + .filter(|segment| !segment.is_empty()); let tab_count = runs .iter() - .filter_map(|(run_tab, _, _, _)| run_tab.as_deref()) + .filter_map(|(run_tab, _, _, _, _)| run_tab.as_deref()) .collect::>() .len(); let mut fragments = Vec::new(); - for (run_tab, run_start, run_end, text) in runs { + for (run_tab, run_segment, run_start, run_end, text) in runs { let tab_matches = match tab_id { Some(tab_id) => run_tab.as_deref() == Some(tab_id), None => run_tab.is_none() || tab_count <= 1, }; - if !tab_matches || *run_end <= start || *run_start >= end { + if !tab_matches + || run_segment.as_deref() != segment_id + || *run_end <= start + || *run_start >= end + { continue; } let from = (start.max(*run_start) - *run_start) as usize; diff --git a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs index 95f33d673..ca8b4c07b 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs @@ -180,6 +180,41 @@ fn comments_without_threads_are_returned_as_empty() { assert_eq!(output["comments"], json!([])); } +#[test] +fn comment_anchor_resolution_respects_document_segments() { + let mut input = legacy(); + input["comments"] = json!([ + {"commentId": "body-comment", "anchorId": "body-anchor"}, + {"commentId": "header-comment", "anchorId": "header-anchor"} + ]); + input["tabs"] = json!([{ + "tabProperties": {"tabId": "tab-1"}, + "documentTab": { + "body": {"content": [{ + "startIndex": 1, "endIndex": 7, + "paragraph": {"elements": [{ + "startIndex": 1, "endIndex": 7, + "textRun": {"content": "body text"} + }]} + }]}, + "headers": {"header-1": {"content": [{ + "startIndex": 1, "endIndex": 7, + "paragraph": {"elements": [{ + "startIndex": 1, "endIndex": 7, + "textRun": {"content": "header text"} + }]} + }]}}, + "commentAnchors": { + "body-anchor": {"ranges": [{"startIndex": 1, "endIndex": 5}]}, + "header-anchor": {"ranges": [{"segmentId": "header-1", "startIndex": 1, "endIndex": 7}]} + } + } + }]); + let output = read::normalize_with_comments(&input).unwrap(); + assert_eq!(output["comments"][0]["referencedText"], json!(["body"])); + assert_eq!(output["comments"][1]["referencedText"], json!(["header"])); +} + #[test] fn request_rejects_partial_masks_lossy_views_and_parameter_bypasses() { for params in [