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/.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/.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..c0058383d 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,52 @@ 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. + +### 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. + +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 \ @@ -438,6 +484,8 @@ 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 | +| `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.rs b/crates/google-workspace-cli/src/helpers/docs.rs index c42c02feb..3f2c29c7e 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -21,7 +21,9 @@ use serde_json::json; use std::future::Future; use std::pin::Pin; +mod comment; mod read; +mod suggest; pub struct DocsHelper; @@ -36,6 +38,8 @@ impl Helper for DocsHelper { _doc: &crate::discovery::RestDescription, ) -> 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") @@ -77,6 +81,14 @@ 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("+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()); + } +} diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs index 4e2e8625c..33a06e2d9 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.", @@ -76,15 +78,22 @@ 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, 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 @@ -94,7 +103,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( @@ -119,11 +130,21 @@ pub(super) 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) @@ -151,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: &[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 + .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, Option, i64, i64, String); + +fn collect_text_runs(document: &Value) -> Vec { + let mut runs = Vec::new(); + 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_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) => { + 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(), + segment_id.clone(), + start, + end, + text.to_string(), + )); + } + } + for child in object.values() { + 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(), segment_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 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()) + .collect::>() + .len(); + let mut fragments = Vec::new(); + 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_segment.as_deref() != segment_id + || *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..ca8b4c07b 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,95 @@ 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] +fn comments_without_threads_are_returned_as_empty() { + let output = read::normalize_with_comments(&legacy()).unwrap(); + 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] @@ -147,7 +234,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 +247,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:?}" + ); } } 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..a638c096e --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -0,0 +1,539 @@ +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> = crate::select_scope(&method.scopes).into_iter().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(); + 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"]) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}"))) + }) + .await?; + 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}); + 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", "requiredRevisionId": revision_id} + }) + .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, + 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, requested_tab, &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, + requested_tab: Option<&str>, + matches: &mut Vec<(Option, i32, i32)>, +) { + let Ok(value) = serde_json::from_str::(value) else { + return; + }; + 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.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); + } + } +} + +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), + ) { + 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 { + 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", 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) + ); + } + + #[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) + ); + } +}