Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/docs-comment-create.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/docs-read-comments.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/docs-suggest-workflow.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 |
Expand Down
12 changes: 12 additions & 0 deletions crates/google-workspace-cli/src/helpers/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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")
Expand Down Expand Up @@ -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)?;

Expand Down
215 changes: 215 additions & 0 deletions crates/google-workspace-cli/src/helpers/docs/comment.rs
Original file line number Diff line number Diff line change
@@ -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(&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<String, GwsError> {
let document = matches
.get_one::<String>("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<String, GwsError> {
let text = matches
.get_one::<String>("text")
.ok_or_else(|| GwsError::Validation("Comment text is required".into()))?;
let start = *matches
.get_one::<i32>("start-index")
.ok_or_else(|| GwsError::Validation("start-index is required".into()))?;
let end = *matches
.get_one::<i32>("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::<String>("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());
}
}
Loading
Loading