diff --git a/.changeset/current-clippy-baseline.md b/.changeset/current-clippy-baseline.md new file mode 100644 index 000000000..a14e86242 --- /dev/null +++ b/.changeset/current-clippy-baseline.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Keep Apps Script file selection compatible with the current Clippy checks. diff --git a/.changeset/docs-structured-read.md b/.changeset/docs-structured-read.md new file mode 100644 index 000000000..41b070876 --- /dev/null +++ b/.changeset/docs-structured-read.md @@ -0,0 +1,9 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `gws docs +read` to translate documents into compact structured content with +recursive tabs, headings and an outline, styled text, suggestions, nested tables, +figure metadata, and reference markers. Preserve API indices and revisions, +reject partial field masks, and support the existing formatters, sanitization, +and credential-free dry-run. diff --git a/.changeset/yaml-empty-collections.md b/.changeset/yaml-empty-collections.md new file mode 100644 index 000000000..2ea985ea5 --- /dev/null +++ b/.changeset/yaml-empty-collections.md @@ -0,0 +1,7 @@ +--- +"@googleworkspace/cli": patch +--- + +Fix YAML mapping values containing empty arrays or objects by separating their +inline collection syntax from the mapping colon. This also fixes structured +Docs reader output with empty outlines, child tabs, or style maps. diff --git a/crates/google-workspace-cli/src/formatter.rs b/crates/google-workspace-cli/src/formatter.rs index 08d4d287a..57ae406af 100644 --- a/crates/google-workspace-cli/src/formatter.rs +++ b/crates/google-workspace-cli/src/formatter.rs @@ -319,7 +319,10 @@ fn json_to_yaml(value: &Value, indent: usize) -> String { match val { Value::Object(_) | Value::Array(_) => { let val_str = json_to_yaml(val, indent + 1); - let _ = write!(out, "\n{prefix}{key}:{val_str}"); + // Empty collections use inline flow syntax and need a + // space after the colon; block collections start a line. + let separator = if val_str.starts_with('\n') { "" } else { " " }; + let _ = write!(out, "\n{prefix}{key}:{separator}{val_str}"); } _ => { let val_str = json_to_yaml(val, indent); @@ -637,6 +640,24 @@ mod tests { assert!(output.contains("count: 42")); } + #[test] + fn test_format_yaml_empty_collections_as_mapping_values() { + let value = json!({"array": [], "object": {}, "tail": true}); + assert_eq!( + format_value(&value, &OutputFormat::Yaml), + "\narray: []\nobject: {}\ntail: true" + ); + } + + #[test] + fn test_format_yaml_empty_collections_nested_in_sequences() { + let value = json!({"items": [[], {}, {"array": [], "object": {}}, "tail"]}); + assert_eq!( + format_value(&value, &OutputFormat::Yaml), + "\nitems:\n - []\n - {}\n - \n array: []\n object: {}\n - \"tail\"" + ); + } + #[test] fn test_format_table_empty_array() { let val = json!({"files": []}); diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index d3ef7fa21..6492cb7c9 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -21,14 +21,21 @@ use serde_json::json; use std::future::Future; use std::pin::Pin; +mod read; + pub struct DocsHelper; +#[cfg(test)] +#[path = "docs/read_tests.rs"] +mod read_tests; + impl Helper for DocsHelper { fn inject_commands( &self, mut cmd: Command, _doc: &crate::discovery::RestDescription, ) -> Command { + cmd = cmd.subcommand(read::command()); cmd = cmd.subcommand( Command::new("+write") .about("[Helper] Append text to a document") @@ -63,9 +70,13 @@ TIPS: &'a self, doc: &'a crate::discovery::RestDescription, matches: &'a ArgMatches, - _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, ) -> Pin> + Send + 'a>> { Box::pin(async move { + if let Some(matches) = matches.subcommand_matches("+read") { + read::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/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs new file mode 100644 index 000000000..4e2e8625c --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -0,0 +1,541 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Translate Docs structure, rather than render its visual layout. Keep API +//! indices and metadata; never calculate edit offsets from extracted text. + +use crate::discovery::RestDescription; +use crate::error::GwsError; +use crate::executor::{self, AuthMethod, PaginationConfig}; +use crate::formatter::{format_value, OutputFormat}; +use crate::helpers::modelarmor::SanitizeConfig; +use clap::{Arg, ArgMatches, Command}; +use serde_json::{json, Map, Value}; +use std::future::Future; + +pub(super) fn command() -> Command { + Command::new("+read") + .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")) + .after_help( + "\ +EXAMPLES: + gws docs +read --document DOC_ID + gws docs +read --document DOC_ID --format yaml + gws docs +read --document DOC_ID --params '{\"fields\":\"*\"}' --dry-run + gws docs +read --document DOC_ID | jq '.outline' + gws docs +read --document DOC_ID | jq '.. | objects | select(.paragraphStyle?.headingId? == \"HEADING_ID\")' + +TIPS: + Requests all tabs with includeTabsContent=true and suggestionsViewMode=SUGGESTIONS_INLINE. + Only those tab/suggestion options are supported; fields must be absent or exactly \"*\". + Other documents.get options pass through --params; alt must be json. $fields is rejected. + JSON/YAML preserve the structured view; table/CSV use the global formatter's array summary. + tabs[].blocks and childTabs keep API order; outline lists headings with tab IDs and JSON Pointer paths. + Paragraph text concatenates text runs only. elements retain styles, links, suggestion IDs and reference markers. + Tables contain rows[].cells[].blocks recursively. Headers, footers and footnotes have separate blocks. + figures contain image/drawing metadata, including alt text and URIs when returned; images are never downloaded. + Unknown blocks, inline elements and tab types retain type=unknown markers and raw data. + startIndex/endIndex are the API's UTF-16 offsets, scoped to each tab/segment; never offsets into extracted text. + 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. + 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.", + ) +} + +pub(super) async fn handle( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result<(), GwsError> { + let output = run(doc, matches, sanitize, async { + crate::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(()) +} + +/// 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( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, + token: impl Future>, +) -> Result { + let params = build_params( + matches.get_one::("document").unwrap(), + matches.get_one::("params").map(String::as_str), + )?; + let method = doc + .resources + .get("documents") + .and_then(|r| r.methods.get("get")) + .ok_or_else(|| GwsError::Discovery("Method 'documents.get' not found".into()))?; + let dry_run = matches.get_flag("dry-run"); + let token = if dry_run { None } else { Some(token.await?) }; + let format = matches + .get_one::("format") + .map(|f| OutputFormat::from_str(f)) + .unwrap_or_default(); + let result = executor::execute_method( + doc, + method, + Some(¶ms.to_string()), + None, + token.as_deref(), + if token.is_some() { + AuthMethod::OAuth + } else { + AuthMethod::None + }, + None, + None, + dry_run, + &PaginationConfig::default(), + sanitize.template.as_deref(), + &sanitize.mode, + &format, + true, + ) + .await? + .ok_or_else(|| invalid_content("expected a JSON document response"))?; + let output = if dry_run { result } else { normalize(&result)? }; + Ok(format_value(&output, &format)) +} + +pub(super) fn build_params(document: &str, params: Option<&str>) -> Result { + crate::validate::validate_resource_name(document)?; + let mut params: Map = match params { + Some(raw) => serde_json::from_str(raw) + .map_err(|e| GwsError::Validation(format!("Invalid --params JSON object: {e}")))?, + None => Map::new(), + }; + // A complete response is required for normalization. A narrow allowlist is + // deliberate: parsing arbitrary nested masks cannot prove completeness as + // the Docs API grows. Reject the system-parameter alias as well. + if params.contains_key("$fields") || params.get("fields").is_some_and(|v| v != "*") { + return Err(GwsError::Validation( + "docs +read requires all content: omit fields or use \"*\"; $fields is unsupported" + .into(), + )); + } + for (key, required) in [ + ("documentId", json!(document)), + ("includeTabsContent", json!(true)), + ("suggestionsViewMode", json!("SUGGESTIONS_INLINE")), + ] { + if params.get(key).is_some_and(|v| v != &required) { + return Err(GwsError::Validation(format!( + "docs +read requires {key}={required}" + ))); + } + params.insert(key.into(), required); + } + if params.get("alt").is_some_and(|v| v != "json") { + return Err(GwsError::Validation("docs +read requires alt=json".into())); + } + Ok(Value::Object(params)) +} + +fn invalid_content(detail: &str) -> GwsError { + GwsError::Validation(format!( + "Incomplete or invalid Docs response: {detail}; use raw documents get to inspect it" + )) +} + +fn object(value: &Value) -> Result, GwsError> { + value + .as_object() + .cloned() + .ok_or_else(|| invalid_content("expected an object")) +} + +fn array<'a>(value: &'a Value, field: &str) -> Result<&'a [Value], GwsError> { + value + .get(field) + .and_then(Value::as_array) + .map(Vec::as_slice) + .ok_or_else(|| invalid_content(&format!("missing or invalid {field} array"))) +} + +const TAB_CONTENT: &[&str] = &[ + "body", + "headers", + "footers", + "footnotes", + "inlineObjects", + "positionedObjects", + "lists", + "namedStyles", + "namedRanges", + "suggestedNamedStylesChanges", +]; + +pub(super) fn normalize(document: &Value) -> Result { + let mut result = object(document)?; + let mut outline = Vec::new(); + let tabs = match document.get("tabs") { + Some(_) => array(document, "tabs")?, + None => &[], + }; + let (source, tabs) = if tabs.is_empty() { + let mut tab = Map::from_iter([ + ("tabId".into(), Value::Null), + ("parentTabId".into(), Value::Null), + ("childTabs".into(), json!([])), + ]); + if let Some(title) = document.get("title") { + tab.insert("title".into(), title.clone()); + } + let content: Map = TAB_CONTENT + .iter() + .filter_map(|key| document.get(key).map(|v| ((*key).into(), v.clone()))) + .collect(); + tab.extend(contents( + &Value::Object(content), + &Value::Null, + "/tabs/0", + &mut outline, + )?); + ("legacyBody", vec![Value::Object(tab)]) + } else { + let tabs = tabs + .iter() + .enumerate() + .map(|(i, tab)| normalize_tab(tab, &Value::Null, &format!("/tabs/{i}"), &mut outline)) + .collect::, _>>()?; + ("tabs", tabs) + }; + for field in TAB_CONTENT { + result.remove(*field); + } + result.insert("source".into(), json!(source)); + result.insert("tabs".into(), json!(tabs)); + result.insert("outline".into(), json!(outline)); + Ok(Value::Object(result)) +} + +fn normalize_tab( + tab: &Value, + parent: &Value, + path: &str, + outline: &mut Vec, +) -> Result { + let mut result = tab + .get("tabProperties") + .map(object) + .transpose()? + .unwrap_or_default(); + result + .entry("parentTabId") + .or_insert_with(|| parent.clone()); + let id = result.get("tabId").cloned().unwrap_or(Value::Null); + if let Some(content) = tab.get("documentTab") { + result.extend(contents(content, &id, path, outline)?); + let mut extra = object(tab)?; + for key in ["tabProperties", "documentTab", "childTabs"] { + extra.remove(key); + } + if !extra.is_empty() { + result.insert("metadata".into(), Value::Object(extra)); + } + } else { + result.insert("type".into(), json!("unknown")); + result.insert("data".into(), tab.clone()); + } + let children = if tab.get("childTabs").is_some() { + array(tab, "childTabs")? + } else { + &[] + }; + let children = children + .iter() + .enumerate() + .map(|(i, tab)| normalize_tab(tab, &id, &format!("{path}/childTabs/{i}"), outline)) + .collect::, _>>()?; + result.insert("childTabs".into(), json!(children)); + Ok(Value::Object(result)) +} + +fn contents( + content: &Value, + tab_id: &Value, + path: &str, + outline: &mut Vec, +) -> Result, GwsError> { + let mut result = object(content)?; + let body = result + .remove("body") + .ok_or_else(|| invalid_content("missing body"))?; + result.insert( + "blocks".into(), + blocks( + array(&body, "content")?, + tab_id, + &format!("{path}/blocks"), + outline, + )?, + ); + let mut body_metadata = object(&body)?; + body_metadata.remove("content"); + if !body_metadata.is_empty() { + result.insert("bodyMetadata".into(), Value::Object(body_metadata)); + } + for kind in ["headers", "footers", "footnotes"] { + if let Some(segments) = result.get_mut(kind) { + let mut normalized = Map::new(); + for (id, segment) in object(segments)? { + let mut segment_result = object(&segment)?; + // JSON Pointer escaping, not URL escaping. + let pointer_id = id.replace('~', "~0").replace('/', "~1"); + segment_result.insert( + "blocks".into(), + blocks( + array(&segment, "content")?, + tab_id, + &format!("{path}/{kind}/{pointer_id}/blocks"), + outline, + )?, + ); + segment_result.remove("content"); + normalized.insert(id, Value::Object(segment_result)); + } + *segments = Value::Object(normalized); + } + } + let mut figures = Map::new(); + for (field, properties, placement) in [ + ("inlineObjects", "inlineObjectProperties", "inline"), + ( + "positionedObjects", + "positionedObjectProperties", + "positioned", + ), + ] { + if let Some(objects) = result.remove(field) { + for (id, value) in object(&objects)? { + let mut figure = object(&value)?; + if let Some(properties) = figure.remove(properties) { + figure.extend(object(&properties)?); + } + let kind = if figure + .get("embeddedObject") + .and_then(|v| v.get("imageProperties")) + .is_some() + { + "image" + } else if figure + .get("embeddedObject") + .and_then(|v| v.get("embeddedDrawingProperties")) + .is_some() + { + "drawing" + } else { + "unknown" + }; + figure.insert("type".into(), json!(kind)); + figure.insert("placement".into(), json!(placement)); + figure.entry("objectId").or_insert_with(|| json!(id)); + figures.insert(id, Value::Object(figure)); + } + } + } + if !figures.is_empty() { + result.insert("figures".into(), Value::Object(figures)); + } + Ok(result) +} + +/// Flatten a known union arm, retaining styles, suggestions, source indices and +/// future metadata fields. Unrecognized union arms are retained as raw markers. +fn payload(value: &Value, key: &str, kind: &str) -> Result, GwsError> { + let mut outer = object(value)?; + let mut inner = object( + &outer + .remove(key) + .ok_or_else(|| invalid_content("missing element"))?, + )?; + inner.extend(outer); + inner.insert("type".into(), json!(kind)); + Ok(inner) +} + +fn unknown(value: &Value) -> Value { + let mut marker = json!({"type": "unknown", "data": value}); + for key in ["startIndex", "endIndex"] { + if let Some(index) = value.get(key) { + marker[key] = index.clone(); + } + } + marker +} + +fn element(value: &Value) -> Result { + for (key, kind) in [ + ("textRun", "text"), + ("inlineObjectElement", "figure"), + ("footnoteReference", "footnoteReference"), + ("horizontalRule", "horizontalRule"), + ("pageBreak", "pageBreak"), + ("columnBreak", "columnBreak"), + ("equation", "equation"), + ("autoText", "autoText"), + ] { + if value.get(key).is_some() { + let mut result = payload(value, key, kind)?; + if key == "textRun" { + let text = result + .remove("content") + .filter(Value::is_string) + .ok_or_else(|| invalid_content("textRun missing content"))?; + result.insert("text".into(), text); + } else if key == "inlineObjectElement" { + if let Some(id) = result.remove("inlineObjectId") { + result.insert("objectId".into(), id); + } + } else if key == "autoText" { + // The source's `type` is content, distinct from our union tag. + if let Some(subtype) = value[key].get("type") { + result.insert("autoTextType".into(), subtype.clone()); + } + } + return Ok(Value::Object(result)); + } + } + Ok(unknown(value)) +} + +fn blocks( + content: &[Value], + tab_id: &Value, + path: &str, + outline: &mut Vec, +) -> Result { + content + .iter() + .enumerate() + .map(|(i, block)| { + let path = format!("{path}/{i}"); + if let Some(paragraph) = block.get("paragraph") { + let mut result = payload(block, "paragraph", "paragraph")?; + let elements = array(paragraph, "elements")? + .iter() + .map(element) + .collect::, _>>()?; + let text: String = elements + .iter() + .filter_map(|e| e.get("text").and_then(Value::as_str)) + .collect(); + result.insert("text".into(), json!(text)); + result.insert("elements".into(), json!(elements)); + if let Some(style) = paragraph.get("paragraphStyle") { + if let Some(level) = + style + .get("namedStyleType") + .and_then(Value::as_str) + .filter(|s| { + matches!( + *s, + "TITLE" + | "SUBTITLE" + | "HEADING_1" + | "HEADING_2" + | "HEADING_3" + | "HEADING_4" + | "HEADING_5" + | "HEADING_6" + ) + }) + { + let mut heading = + json!({"tabId": tab_id, "level": level, "text": text, "path": path}); + for key in ["startIndex", "endIndex"] { + if let Some(value) = block.get(key) { + heading[key] = value.clone(); + } + } + if let Some(id) = style.get("headingId") { + heading["headingId"] = id.clone(); + } + outline.push(heading); + } + } + Ok(Value::Object(result)) + } else if let Some(table) = block.get("table") { + let mut result = payload(block, "table", "table")?; + let rows = array(table, "tableRows")? + .iter() + .enumerate() + .map(|(r, row)| { + let mut normalized = object(row)?; + let cells = array(row, "tableCells")? + .iter() + .enumerate() + .map(|(c, cell)| { + let mut normalized = object(cell)?; + normalized.insert( + "blocks".into(), + blocks( + array(cell, "content")?, + tab_id, + &format!("{path}/rows/{r}/cells/{c}/blocks"), + outline, + )?, + ); + normalized.remove("content"); + Ok(Value::Object(normalized)) + }) + .collect::, GwsError>>()?; + normalized.remove("tableCells"); + normalized.insert("cells".into(), json!(cells)); + Ok(Value::Object(normalized)) + }) + .collect::, GwsError>>()?; + if let Some(count) = result.remove("rows") { + result.insert("rowCount".into(), count); + } + result.remove("tableRows"); + result.insert("rows".into(), json!(rows)); + Ok(Value::Object(result)) + } else if let Some(toc) = block.get("tableOfContents") { + let mut result = payload(block, "tableOfContents", "tableOfContents")?; + result.insert( + "blocks".into(), + blocks( + array(toc, "content")?, + tab_id, + &format!("{path}/blocks"), + outline, + )?, + ); + result.remove("content"); + Ok(Value::Object(result)) + } else if block.get("sectionBreak").is_some() { + payload(block, "sectionBreak", "sectionBreak").map(Value::Object) + } else { + Ok(unknown(block)) + } + }) + .collect::, _>>() + .map(Value::Array) +} diff --git a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs new file mode 100644 index 000000000..d0152bf9f --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs @@ -0,0 +1,651 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::commands::build_cli; +use crate::discovery::RestDescription; +use crate::error::GwsError; +use crate::helpers::modelarmor::{SanitizeConfig, SanitizeMode}; +use serde_json::{json, Value}; + +use super::read; + +fn discovery() -> RestDescription { + serde_json::from_value(serde_json::json!({ + "name": "docs", "version": "v1", "rootUrl": "https://docs.example.invalid/", + "servicePath": "v1/", + "resources": {"documents": {"methods": {"get": { + "id": "docs.documents.get", "httpMethod": "GET", + "path": "documents/{documentId}", "parameterOrder": ["documentId"], + "parameters": {"documentId": {"type": "string", "location": "path", "required": true}}, + "scopes": ["https://www.googleapis.com/auth/documents.readonly"] + }}}} + })) + .unwrap() +} + +#[test] +fn registers_read_alongside_write_with_required_document() { + let cli = build_cli(&discovery()); + assert!(cli.find_subcommand("+write").is_some()); + assert!( + cli.find_subcommand("+read").is_some(), + "missing structured reader" + ); + let error = cli + .clone() + .try_get_matches_from(["gws", "+read"]) + .unwrap_err(); + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + assert!(cli + .try_get_matches_from([ + "gws", + "+read", + "--document", + "synthetic", + "--params", + "{}", + "--format", + "yaml", + "--dry-run" + ]) + .is_ok()); +} + +fn matches(args: &[&str]) -> clap::ArgMatches { + build_cli(&discovery()).try_get_matches_from(args).unwrap() +} + +fn paragraph(text: &str) -> Value { + json!({"startIndex": 1, "endIndex": 5, "paragraph": { + "elements": [{"startIndex": 1, "endIndex": 5, "textRun": {"content": text}}] + }}) +} + +fn legacy() -> Value { + json!({ + "documentId": "synthetic", "title": "Example", "revisionId": "rev-1", + "suggestionsViewMode": "SUGGESTIONS_INLINE", + "body": {"content": [paragraph("Hi😀")] } + }) +} + +#[test] +fn auto_text_preserves_page_number_and_count_with_indices_and_styles() { + let mut outputs = Vec::new(); + for subtype in ["PAGE_NUMBER", "PAGE_COUNT"] { + let mut input = legacy(); + input["body"]["content"][0]["paragraph"]["elements"] = json!([{ + "startIndex": 0, "endIndex": 1, + "autoText": {"type": subtype, "textStyle": {"bold": true}, + "suggestedInsertionIds": ["s1"]} + }]); + let output = read::normalize(&input).unwrap(); + let element = &output["tabs"][0]["blocks"][0]["elements"][0]; + assert_eq!( + element, + &json!({ + "type": "autoText", "autoTextType": subtype, + "startIndex": 0, "endIndex": 1, + "textStyle": {"bold": true}, "suggestedInsertionIds": ["s1"] + }) + ); + outputs.push(output); + } + assert_ne!(outputs[0], outputs[1]); +} + +#[test] +fn request_requires_full_inline_tabs_and_preserves_other_params() { + let params = + read::build_params("synthetic", Some(r#"{"fields":"*","prettyPrint":false}"#)).unwrap(); + assert_eq!( + params, + json!({ + "documentId": "synthetic", "fields": "*", "prettyPrint": false, + "includeTabsContent": true, "suggestionsViewMode": "SUGGESTIONS_INLINE" + }) + ); + assert_eq!( + read::build_params("id", None).unwrap()["includeTabsContent"], + true + ); + assert!(read::build_params("id", Some( + r#"{"includeTabsContent":true,"suggestionsViewMode":"SUGGESTIONS_INLINE","documentId":"id"}"# + )).is_ok()); +} + +#[test] +fn request_rejects_partial_masks_lossy_views_and_parameter_bypasses() { + for params in [ + r#"{"fields":"title"}"#, + r#"{"fields":"tabs(documentTab/body/content)"}"#, + r#"{"fields":""}"#, + r#"{"fields":null}"#, + r#"{"fields": ["*"]}"#, + r#"{"includeTabsContent":false}"#, + r#"{"includeTabsContent":"true"}"#, + r#"{"suggestionsViewMode":"PREVIEW_WITHOUT_SUGGESTIONS"}"#, + r#"{"suggestionsViewMode":"DEFAULT_FOR_CURRENT_ACCESS"}"#, + r#"{"documentId":"other"}"#, + r#"{"alt":"media"}"#, + r#"{"$fields":"title"}"#, + "[]", + "null", + "{", + ] { + assert!(read::build_params("id", Some(params)).is_err(), "{params}"); + } + for id in [ + "", + "../../secret", + "id?fields=title", + "id#fragment", + "id\n", + "%2e%2e", + ] { + assert!(read::build_params(id, None).is_err(), "{id:?}"); + } +} + +#[test] +fn legacy_body_keeps_source_revision_text_and_utf16_indices() { + let output = read::normalize(&legacy()).unwrap(); + assert_eq!(output["documentId"], "synthetic"); + assert_eq!(output["revisionId"], "rev-1"); + assert_eq!(output["suggestionsViewMode"], "SUGGESTIONS_INLINE"); + assert_eq!(output["source"], "legacyBody"); + assert_eq!(output["tabs"][0]["tabId"], Value::Null); + let block = &output["tabs"][0]["blocks"][0]; + assert_eq!(block["type"], "paragraph"); + assert_eq!(block["text"], "Hi😀"); + assert_eq!(block["endIndex"], 5); + assert_eq!(block["elements"][0]["endIndex"], 5); + assert_eq!(block["elements"][0]["text"], "Hi😀"); +} + +#[test] +fn recursively_reads_tabs_and_child_tabs_in_api_order_without_duplicate_legacy_body() { + let mut input = legacy(); + input["tabs"] = json!([ + {"tabProperties": {"tabId": "a", "title": "First", "index": 0}, + "documentTab": {"body": {"content": [paragraph("first")]}}, + "childTabs": [{"tabProperties": {"tabId": "b", "title": "Child", "parentTabId": "a"}, + "documentTab": {"body": {"content": [paragraph("child")]}}, + "childTabs": [{"tabProperties": {"tabId": "c", "title": "Grandchild"}, + "documentTab": {"body": {"content": [paragraph("grandchild")]}}}]}]}, + {"tabProperties": {"tabId": "d", "title": "Last", "index": 1}, + "documentTab": {"body": {"content": [paragraph("last")]}}} + ]); + let output = read::normalize(&input).unwrap(); + assert_eq!(output["source"], "tabs"); + assert_eq!(output["tabs"].as_array().unwrap().len(), 2); + assert_eq!(output["tabs"][0]["blocks"][0]["text"], "first"); + assert_eq!(output["tabs"][0]["childTabs"][0]["parentTabId"], "a"); + assert_eq!( + output["tabs"][0]["childTabs"][0]["childTabs"][0]["parentTabId"], + "b" + ); + assert_eq!(output["tabs"][1]["tabId"], "d"); + input["tabs"] = json!([]); + assert_eq!(read::normalize(&input).unwrap()["source"], "legacyBody"); +} + +#[test] +fn preserves_styled_link_runs_and_inline_suggestion_metadata_in_outline_order() { + let input = json!({"documentId": "id", "title": "Styled", "body": {"content": [{ + "startIndex": 1, "endIndex": 9, "paragraph": { + "paragraphStyle": {"namedStyleType": "HEADING_2", "headingId": "h1"}, + "suggestedParagraphStyleChanges": {"s1": {"paragraphStyle": {"namedStyleType": "HEADING_1"}}}, + "elements": [ + {"startIndex": 1, "endIndex": 5, "textRun": { + "content": "Look", "textStyle": {"bold": true, "link": {"url": "https://example.invalid"}}, + "suggestedInsertionIds": ["s1"], + "suggestedTextStyleChanges": {"s2": {"textStyle": {"italic": true}}}}}, + {"startIndex": 5, "endIndex": 9, "textRun": { + "content": "here", "textStyle": {"italic": true, "link": {"heading": {"id": "h2", "tabId": "t2"}}}, + "suggestedDeletionIds": ["s3"]}} + ] + } + }, {"startIndex": 9, "endIndex": 14, "paragraph": { + "paragraphStyle": {"namedStyleType": "TITLE"}, "elements": [] + }}]}}); + let output = read::normalize(&input).unwrap(); + let block = &output["tabs"][0]["blocks"][0]; + assert_eq!(block["text"], "Lookhere"); + assert_eq!(block["paragraphStyle"]["headingId"], "h1"); + assert!(block["suggestedParagraphStyleChanges"]["s1"].is_object()); + assert_eq!(block["elements"][0]["textStyle"]["bold"], true); + assert_eq!( + block["elements"][0]["textStyle"]["link"]["url"], + "https://example.invalid" + ); + assert_eq!(block["elements"][0]["suggestedInsertionIds"], json!(["s1"])); + assert_eq!(block["elements"][1]["suggestedDeletionIds"], json!(["s3"])); + assert_eq!( + block["elements"][1]["textStyle"]["link"]["heading"]["tabId"], + "t2" + ); + assert!(block["elements"][0]["suggestedTextStyleChanges"]["s2"].is_object()); + assert_eq!( + output["outline"][0], + json!({ + "tabId": null, "level": "HEADING_2", "headingId": "h1", "text": "Lookhere", + "startIndex": 1, "endIndex": 9, "path": "/tabs/0/blocks/0" + }) + ); + assert_eq!(output["outline"][1]["level"], "TITLE"); +} + +#[test] +fn nested_tables_keep_row_cell_order_indices_styles_and_suggestions() { + let input = json!({"documentId": "id", "body": {"content": [{ + "startIndex": 10, "endIndex": 30, "table": {"rows": 1, "columns": 2, + "tableRows": [{"startIndex": 11, "endIndex": 29, "tableCells": [ + {"startIndex": 12, "endIndex": 25, "tableCellStyle": {"rowSpan": 1, "columnSpan": 1}, + "suggestedInsertionIds": ["cell-s"], "content": [ + paragraph("cell"), + {"startIndex": 17, "endIndex": 24, "table": {"rows": 1, "columns": 1, + "tableRows": [{"tableCells": [{"content": [paragraph("nested")]}]}]}} + ]}, + {"content": [paragraph("second")]} + ]}] + } + }, paragraph("after")]}}); + let output = read::normalize(&input).unwrap(); + let table = &output["tabs"][0]["blocks"][0]; + assert_eq!(table["type"], "table"); + assert_eq!(table["startIndex"], 10); + assert_eq!(table["rowCount"], 1); + assert_eq!(table["columns"], 2); + assert_eq!(table["rows"][0]["endIndex"], 29); + let cell = &table["rows"][0]["cells"][0]; + assert_eq!(cell["startIndex"], 12); + assert_eq!(cell["suggestedInsertionIds"], json!(["cell-s"])); + assert_eq!(cell["tableCellStyle"]["columnSpan"], 1); + assert_eq!(cell["blocks"][0]["text"], "cell"); + assert_eq!( + cell["blocks"][1]["rows"][0]["cells"][0]["blocks"][0]["text"], + "nested" + ); + assert_eq!(table["rows"][0]["cells"][1]["blocks"][0]["text"], "second"); + assert_eq!(output["tabs"][0]["blocks"][1]["text"], "after"); +} + +#[test] +fn figures_keep_references_and_metadata_even_without_content_uri() { + let mut input = legacy(); + input["body"]["content"][0]["paragraph"]["elements"] = json!([ + {"startIndex": 1, "endIndex": 2, "inlineObjectElement": { + "inlineObjectId": "image", "suggestedInsertionIds": ["s1"], "textStyle": {"baselineOffset": "SUPERSCRIPT"}}}, + {"startIndex": 2, "endIndex": 3, "inlineObjectElement": {"inlineObjectId": "missing"}} + ]); + input["body"]["content"][0]["paragraph"]["positionedObjectIds"] = json!(["drawing"]); + input["inlineObjects"] = json!({"image": { + "objectId": "image", "inlineObjectProperties": {"embeddedObject": { + "title": "Alt title", "description": "Alt text", "size": {"width": {"magnitude": 42, "unit": "PT"}}, + "imageProperties": {"sourceUri": "https://example.invalid/image.png"} + }}, "suggestedDeletionIds": ["s2"] + }}); + input["positionedObjects"] = json!({"drawing": { + "objectId": "drawing", "positionedObjectProperties": {"embeddedObject": {"embeddedDrawingProperties": {}}} + }}); + let output = read::normalize(&input).unwrap(); + let tab = &output["tabs"][0]; + assert_eq!(tab["blocks"][0]["elements"][0]["type"], "figure"); + assert_eq!(tab["blocks"][0]["elements"][0]["objectId"], "image"); + assert_eq!( + tab["blocks"][0]["elements"][0]["suggestedInsertionIds"], + json!(["s1"]) + ); + assert_eq!(tab["blocks"][0]["elements"][1]["objectId"], "missing"); + assert_eq!(tab["blocks"][0]["positionedObjectIds"], json!(["drawing"])); + assert_eq!(tab["figures"]["image"]["type"], "image"); + assert_eq!( + tab["figures"]["image"]["embeddedObject"]["description"], + "Alt text" + ); + assert!(tab["figures"]["image"]["embeddedObject"]["imageProperties"] + .get("contentUri") + .is_none()); + assert_eq!( + tab["figures"]["image"]["suggestedDeletionIds"], + json!(["s2"]) + ); + assert_eq!(tab["figures"]["drawing"]["placement"], "positioned"); +} + +#[test] +fn preserves_reference_markers_segments_unknown_blocks_and_unknown_inline_elements() { + let mut input = legacy(); + input["body"]["content"] = json!([ + {"endIndex": 1, "sectionBreak": {"sectionStyle": {"columnSeparatorStyle": "NONE"}}}, + {"startIndex": 1, "endIndex": 4, "paragraph": {"elements": [ + {"startIndex": 1, "endIndex": 2, "footnoteReference": {"footnoteId": "f1", "footnoteNumber": "1"}}, + {"startIndex": 2, "endIndex": 3, "person": {"personId": "p1"}}, + {"startIndex": 3, "endIndex": 4, "futureInline": {"label": "unrecognized"}} + ]}}, + {"startIndex": 4, "endIndex": 8, "futureBlock": {"content": "keep me"}}, + {"tableOfContents": {"content": [paragraph("toc")]}} + ]); + input["headers"] = json!({"h1": {"headerId": "h1", "content": [paragraph("header")]}}); + input["footers"] = json!({"f2": {"footerId": "f2", "content": [paragraph("footer")]}}); + input["footnotes"] = json!({"f1": {"footnoteId": "f1", "content": [paragraph("note")]}}); + input["namedStyles"] = json!({"styles": [{"namedStyleType": "NORMAL_TEXT"}]}); + let output = read::normalize(&input).unwrap(); + let tab = &output["tabs"][0]; + assert_eq!(tab["blocks"][0]["type"], "sectionBreak"); + assert!(tab["blocks"][0].get("startIndex").is_none()); + assert_eq!(tab["blocks"][1]["elements"][0]["type"], "footnoteReference"); + assert_eq!(tab["blocks"][1]["elements"][0]["footnoteId"], "f1"); + assert_eq!(tab["blocks"][1]["elements"][1]["type"], "unknown"); + assert_eq!( + tab["blocks"][1]["elements"][1]["data"]["person"]["personId"], + "p1" + ); + assert_eq!( + tab["blocks"][1]["elements"][2]["data"]["futureInline"]["label"], + "unrecognized" + ); + assert_eq!(tab["blocks"][2]["type"], "unknown"); + assert_eq!(tab["blocks"][2]["startIndex"], 4); + assert_eq!( + tab["blocks"][2]["data"]["futureBlock"]["content"], + "keep me" + ); + assert_eq!(tab["blocks"][3]["blocks"][0]["text"], "toc"); + assert_eq!(tab["headers"]["h1"]["blocks"][0]["text"], "header"); + assert_eq!(tab["footers"]["f2"]["blocks"][0]["text"], "footer"); + assert_eq!(tab["footnotes"]["f1"]["blocks"][0]["text"], "note"); + assert_eq!( + tab["namedStyles"]["styles"][0]["namedStyleType"], + "NORMAL_TEXT" + ); +} + +#[test] +fn rejects_missing_content_instead_of_claiming_empty_document() { + for input in [ + json!({"documentId": "id", "title": "metadata only"}), + json!({"body": {}}), + json!({"tabs": [{"tabProperties": {"tabId": "t"}, "documentTab": {}}]}), + json!({"tabs": [{"documentTab": {"body": {"content": "not an array"}}}]}), + json!({"tabs": "not an array", "body": {"content": []}}), + json!({"body": {"content": [{"table": {"rows": 1}}]}}), + ] { + assert!(read::normalize(&input).is_err(), "{input}"); + } + assert!(read::normalize(&json!({"body": {"content": []}})).is_ok()); +} + +#[test] +fn preserves_unknown_tab_and_sanitization_annotation() { + let output = read::normalize(&json!({ + "documentId": "id", "_sanitization": {"filterMatchState": "NO_MATCH_FOUND"}, + "tabs": [{"tabProperties": {"tabId": "future", "title": "Future"}, + "futureTab": {"content": "opaque"}}] + })) + .unwrap(); + assert_eq!( + output["_sanitization"]["filterMatchState"], + "NO_MATCH_FOUND" + ); + assert_eq!(output["tabs"][0]["type"], "unknown"); + assert_eq!(output["tabs"][0]["data"]["futureTab"]["content"], "opaque"); +} + +#[tokio::test] +async fn dry_run_uses_executor_plan_without_polling_auth_or_sanitize() { + let args = matches(&[ + "gws", + "+read", + "--document", + "a/b c", + "--dry-run", + "--params", + r#"{"prettyPrint":false}"#, + ]); + let result = read::run( + &discovery(), + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig { + template: Some("never-call".into()), + mode: SanitizeMode::Block, + }, + async { panic!("dry-run must not poll authentication") }, + ) + .await + .unwrap(); + let output: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(output["dry_run"], true); + assert_eq!(output["method"], "GET"); + assert_eq!( + output["url"], + "https://docs.example.invalid/v1/documents/a%2Fb%20c" + ); + let query = output["query_params"].as_array().unwrap(); + assert!(query.contains(&json!(["includeTabsContent", "true"]))); + assert!(query.contains(&json!(["suggestionsViewMode", "SUGGESTIONS_INLINE"]))); + assert!(query.contains(&json!(["prettyPrint", "false"]))); + assert!(output.get("tabs").is_none()); +} + +#[tokio::test] +async fn rejects_partial_mask_before_polling_authentication() { + let args = matches(&[ + "gws", + "+read", + "--document", + "id", + "--params", + r#"{"fields":"title"}"#, + ]); + let result = read::run( + &discovery(), + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig::default(), + async { panic!("invalid request must fail before authentication") }, + ) + .await; + assert!(matches!(result, Err(GwsError::Validation(_)))); +} + +#[tokio::test] +async fn propagates_auth_and_discovery_failures() { + let args = matches(&["gws", "+read", "--document", "id"]); + let result = read::run( + &discovery(), + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig::default(), + async { Err(GwsError::Auth("synthetic failure".into())) }, + ) + .await; + assert!(matches!(result, Err(GwsError::Auth(_)))); + let result = read::run( + &RestDescription::default(), + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig::default(), + async { panic!("missing method must fail before authentication") }, + ) + .await; + assert!(matches!(result, Err(GwsError::Discovery(_)))); +} + +// The transport is the only fake: real executor, request building, capture, +// normalization and formatting run against a loopback server with synthetic auth. +async fn serve(status: &str, body: String) -> (RestDescription, tokio::task::JoinHandle) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut doc = discovery(); + doc.root_url = format!("http://{}/", listener.local_addr().unwrap()); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut buffer = [0; 1024]; + let len = stream.read(&mut buffer).await.unwrap(); + assert_ne!(len, 0); + request.extend_from_slice(&buffer[..len]); + if request.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + stream.write_all(response.as_bytes()).await.unwrap(); + String::from_utf8(request).unwrap() + }); + (doc, task) +} + +// Stop the existing executor's quota lookup before it can read host config/ADC. +struct SyntheticQuota(Option); +impl SyntheticQuota { + fn new() -> Self { + let old = std::env::var_os("GOOGLE_WORKSPACE_PROJECT_ID"); + std::env::set_var("GOOGLE_WORKSPACE_PROJECT_ID", "synthetic-project"); + Self(old) + } +} +impl Drop for SyntheticQuota { + fn drop(&mut self) { + if let Some(old) = &self.0 { + std::env::set_var("GOOGLE_WORKSPACE_PROJECT_ID", old); + } else { + std::env::remove_var("GOOGLE_WORKSPACE_PROJECT_ID"); + } + } +} + +#[tokio::test] +#[serial_test::serial] +async fn executor_fetches_full_content_with_auth_and_honors_all_global_formats() { + let _quota = SyntheticQuota::new(); + for format in ["json", "yaml", "table", "csv"] { + let mut input = legacy(); + input["body"]["content"][0]["paragraph"]["elements"][0]["textRun"]["textStyle"] = json!({}); + let (doc, request) = serve("200 OK", input.to_string()).await; + let args = matches(&[ + "gws", + "+read", + "--document", + "synthetic", + "--format", + format, + ]); + let rendered = read::run( + &doc, + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig::default(), + async { Ok("synthetic-token".into()) }, + ) + .await + .unwrap(); + let request = request.await.unwrap(); + assert!(request.starts_with("GET /v1/documents/synthetic?")); + assert!(request.contains("includeTabsContent=true")); + assert!(request.contains("suggestionsViewMode=SUGGESTIONS_INLINE")); + assert!(request.contains("authorization: Bearer synthetic-token\r\n")); + match format { + "json" => assert_eq!( + serde_json::from_str::(&rendered).unwrap()["tabs"][0]["blocks"][0]["text"], + "Hi😀" + ), + // Complete consumer-visible YAML, including the empty map and + // arrays that previously lacked a mapping-value separator. + "yaml" => assert_eq!( + rendered, + concat!( + "\ndocumentId: \"synthetic\"", + "\noutline: []", + "\nrevisionId: \"rev-1\"", + "\nsource: \"legacyBody\"", + "\nsuggestionsViewMode: \"SUGGESTIONS_INLINE\"", + "\ntabs:", + "\n - ", + "\n blocks:", + "\n - ", + "\n elements:", + "\n - ", + "\n endIndex: 5", + "\n startIndex: 1", + "\n text: \"Hi😀\"", + "\n textStyle: {}", + "\n type: \"text\"", + "\n endIndex: 5", + "\n startIndex: 1", + "\n text: \"Hi😀\"", + "\n type: \"paragraph\"", + "\n childTabs: []", + "\n parentTabId: null", + "\n tabId: null", + "\n title: \"Example\"", + "\ntitle: \"Example\"" + ) + ), + "table" => assert!(rendered.contains("─") && rendered.contains("blocks")), + "csv" => assert!( + rendered.lines().next().unwrap().contains("blocks,") + && rendered.contains("\"\"text\"\"") + ), + _ => unreachable!(), + } + } +} + +#[tokio::test] +#[serial_test::serial] +async fn executor_propagates_server_errors_and_rejects_non_document_responses() { + let _quota = SyntheticQuota::new(); + for status in ["403 Forbidden", "500 Internal Server Error"] { + let (doc, request) = serve( + status, + json!({"error": {"message": "synthetic denied"}}).to_string(), + ) + .await; + let args = matches(&["gws", "+read", "--document", "synthetic"]); + let result = read::run( + &doc, + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig::default(), + async { Ok("synthetic-token".into()) }, + ) + .await; + match result.unwrap_err() { + GwsError::Api { code, message, .. } => { + assert_eq!(code, if status.starts_with("403") { 403 } else { 500 }); + assert_eq!(message, "synthetic denied"); + } + other => panic!("unexpected error: {other:?}"), + } + request.await.unwrap(); + } + for body in [r#"{"documentId":"id","title":"partial"}"#, "invalid JSON"] { + let (doc, request) = serve("200 OK", body.into()).await; + let args = matches(&["gws", "+read", "--document", "synthetic"]); + assert!(read::run( + &doc, + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig::default(), + async { Ok("synthetic-token".into()) } + ) + .await + .is_err()); + request.await.unwrap(); + } +} diff --git a/crates/google-workspace-cli/src/helpers/script.rs b/crates/google-workspace-cli/src/helpers/script.rs index 11bcdebec..4b31db62d 100644 --- a/crates/google-workspace-cli/src/helpers/script.rs +++ b/crates/google-workspace-cli/src/helpers/script.rs @@ -169,13 +169,7 @@ fn process_file(path: &Path) -> Result, GwsError> { filename.trim_end_matches(".js").trim_end_matches(".gs"), ), "html" => ("HTML", filename.trim_end_matches(".html")), - "json" => { - if filename == "appsscript.json" { - ("JSON", "appsscript") - } else { - return Ok(None); - } - } + "json" if filename == "appsscript.json" => ("JSON", "appsscript"), _ => return Ok(None), }; diff --git a/docs/docs-read.md b/docs/docs-read.md new file mode 100644 index 000000000..b95b8a66c --- /dev/null +++ b/docs/docs-read.md @@ -0,0 +1,85 @@ +# Read structured Google Docs content + +```bash +gws docs +read --document DOC_ID +gws docs +read --document DOC_ID --format yaml +gws docs +read --document DOC_ID --dry-run +gws docs +read --document DOC_ID | jq '.outline' +``` + +`+read` translates the Docs API's nested structural elements into ordered +blocks. It uses the existing authentication, request executor, Model Armor +sanitization, and output formatters. `+write` is unchanged. + +The request uses `includeTabsContent=true` and +`suggestionsViewMode=SUGGESTIONS_INLINE`. Pass other API options through +`--params`. To prevent omitted content from looking like an empty document, +`fields` must be absent or exactly `"*"`. `$fields`, other tab/suggestion views, +conflicting document IDs, and non-JSON `alt` responses are rejected before +authentication. Dry-run prints the executor's request plan without acquiring +credentials, fetching document content, or invoking Model Armor. + +## Output + +- The root retains `documentId`, `title`, `revisionId`, `suggestionsViewMode`, + and `_sanitization` when returned. No revision is invented when absent. +- `tabs` and recursive `childTabs` preserve API order, IDs, titles, and parents. + Each tab has ordered `blocks`. `source: "legacyBody"` means the response had + no populated tabs; that fallback cannot confirm coverage of other tabs. +- Paragraph blocks have `text`, ordered `elements`, and `paragraphStyle`. + Text elements retain separate runs, text styles, links (including tab-aware + internal links), and suggested insertion/deletion/style changes. Other + returned paragraph metadata, such as bullets and positioned object IDs, + stays on the block. Automatic-text markers use `type: "autoText"` and retain + their source subtype (`PAGE_NUMBER` or `PAGE_COUNT`) as `autoTextType`. +- Table blocks have `rowCount`, `columns`, and + `rows[].cells[].blocks`, including nested tables. Row/cell styles and + suggestion metadata are retained. +- Each tab's `figures` map contains inline and positioned object metadata. + `embeddedObject` retains alt text, dimensions, and image properties when + available. Figure elements reference `objectId`. Missing metadata or + `contentUri` does not remove the reference. +- Headers, footers, and footnotes remain separate maps with their own `blocks`. + Footnote references and structural markers remain in content order. + Unsupported elements use `type: "unknown"` with their original `data`. +- `outline` contains titles, subtitles, and headings with text, style level, + tab ID, heading ID when present, source indices, and a JSON Pointer `path` + to the normalized paragraph, including headings in tables and segments. + +For example, select a heading by its returned ID: + +```bash +gws docs +read --document DOC_ID | + jq '.. | objects | select(.paragraphStyle?.headingId? == "HEADING_ID")' +``` + +To select the blocks between two top-level headings in a particular tab: + +```bash +gws docs +read --document DOC_ID | + jq --arg tab TAB_ID --argjson start 10 --argjson end 50 \ + '.. | objects | select(.tabId? == $tab and has("blocks")) | + .blocks[] | select(.startIndex >= $start and .startIndex < $end)' +``` + +Use indices actually returned for that tab. `startIndex` and `endIndex` are +UTF-16 offsets in the API's tab/segment, **not** byte or character offsets into +the extracted `text`. The text convenience field concatenates text runs only; +figures and other markers remain in `elements`. + +## Limits + +This is a structured content view, not a visual layout renderer or a lossless +API round trip. It does not resolve inherited styles, render drawings or +equations, download images, or accept/reject suggestions. Proposed deletions +remain inline. Image URIs are included only when returned and may expire. +Use raw `gws docs documents get` for other views or partial field masks. + +JSON is the default and retains the entire normalized tree. YAML uses the +existing serializer. Table and CSV use the existing formatter's first +nonempty-array summary (typically the outline, otherwise tabs); table cells +can be truncated. Use JSON for complete downstream processing. + +Usage and limitations also live in the command's help, the source consumed by +`gws generate-skills`. Generated skill files are maintained by the repository's +Generate Skills workflow.