mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat: Add Google Suite & Telegram WASM tools (#9)
* Add Google Calendar and Gmail WASM tools, and /add-tool skill Scaffold two new WASM tools that share a single Google OAuth token: - google-calendar: list/get/create/update/delete calendar events - gmail: list/search/get/send/draft/reply/trash emails Both tools use the sandboxed WIT interface with strict HTTP allowlists, credential injection, and rate limiting. OAuth config requests only the minimum scopes needed (calendar.events, gmail.modify, gmail.compose). Also adds the /add-tool skill for scaffolding future WASM or built-in tools with all boilerplate wired up. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Document WASM vs MCP server decision guide in CLAUDE.md Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add Google Drive WASM tool with full file and sharing management Supports 12 actions: list/get/download/upload/update files, create folders, delete/trash, share/list/remove permissions, and list shared drives. Works with both personal and organizational drives via the corpora parameter. Uses shared google_oauth_token for auth. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add Google Sheets, Docs, and Slides WASM tools Three new Google Workspace tools sharing google_oauth_token: - Sheets: create spreadsheets, read/write/append values, manage sheets, format cells - Docs: create/read/edit documents, text formatting, paragraphs, tables, lists - Slides: create/edit presentations, shapes, images, text formatting, thumbnails, templates Also adds tools-src/TOOLS.md tracking implementation status. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add Telegram WASM tool with direct MTProto over HTTPS Replace TDLight Docker dependency with pure-Rust grammers crates for direct encrypted MTProto communication to Telegram's web transport endpoints. No middleware, no Docker needed. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Gitignore Cargo.lock files in WASM tools Library crates should not commit lock files. Consolidate per-tool .gitignore into a single one at wasm-tools/ level. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Flatten tools-src/wasm-tools/ into tools-src/ All tools are WASM, the extra nesting added no value. Moves all tool crates up one level, updates WIT paths and documentation references. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Fix Slack tool: add OAuth auth, URL encoding, pin wit-bindgen - Add OAuth 2.0 auth section to Slack capabilities with proper scopes and manual fallback instructions - URL-encode query parameters in GET requests to prevent injection - Remove dead SlackApiError struct - Pin wit-bindgen to =0.36 across all WASM tools for Rust 1.86 compat - Update add-tool template with pinned version Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e6725eb6d9
commit
a35db4d32d
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "google-docs-tool"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Google Docs integration tool for IronClaw (WASM component)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "=0.36"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{
|
||||
"host": "docs.googleapis.com",
|
||||
"path_prefix": "/v1/documents",
|
||||
"methods": ["GET", "POST"]
|
||||
}
|
||||
],
|
||||
"credentials": {
|
||||
"google_oauth_token": {
|
||||
"secret_name": "google_oauth_token",
|
||||
"location": { "type": "bearer" },
|
||||
"host_patterns": ["docs.googleapis.com"]
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 60,
|
||||
"requests_per_hour": 500
|
||||
},
|
||||
"timeout_secs": 30
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["google_oauth_token"]
|
||||
},
|
||||
"auth": {
|
||||
"secret_name": "google_oauth_token",
|
||||
"display_name": "Google",
|
||||
"oauth": {
|
||||
"authorization_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"client_id_env": "GOOGLE_OAUTH_CLIENT_ID",
|
||||
"client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET",
|
||||
"scopes": [
|
||||
"https://www.googleapis.com/auth/documents"
|
||||
],
|
||||
"use_pkce": false,
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
}
|
||||
},
|
||||
"env_var": "GOOGLE_OAUTH_TOKEN"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
//! Google Docs API v1 implementation.
|
||||
//!
|
||||
//! All API calls go through the host's HTTP capability, which handles
|
||||
//! credential injection and rate limiting. The WASM tool never sees
|
||||
//! the actual OAuth token.
|
||||
|
||||
use crate::near::agent::host;
|
||||
use crate::types::*;
|
||||
|
||||
const DOCS_API_BASE: &str = "https://docs.googleapis.com/v1/documents";
|
||||
|
||||
/// Make a Google Docs API call.
|
||||
fn api_call(method: &str, path: &str, body: Option<&str>) -> Result<String, String> {
|
||||
let url = if path.is_empty() {
|
||||
DOCS_API_BASE.to_string()
|
||||
} else {
|
||||
format!("{}/{}", DOCS_API_BASE, path)
|
||||
};
|
||||
|
||||
let headers = if body.is_some() {
|
||||
r#"{"Content-Type": "application/json"}"#
|
||||
} else {
|
||||
"{}"
|
||||
};
|
||||
|
||||
let body_bytes = body.map(|b| b.as_bytes().to_vec());
|
||||
|
||||
host::log(
|
||||
host::LogLevel::Debug,
|
||||
&format!("Google Docs API: {} {}", method, url),
|
||||
);
|
||||
|
||||
let response = host::http_request(method, &url, headers, body_bytes.as_deref())?;
|
||||
|
||||
if response.status < 200 || response.status >= 300 {
|
||||
let body_text = String::from_utf8_lossy(&response.body);
|
||||
return Err(format!(
|
||||
"Google Docs API returned status {}: {}",
|
||||
response.status, body_text
|
||||
));
|
||||
}
|
||||
|
||||
if response.body.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e))
|
||||
}
|
||||
|
||||
/// Send a batchUpdate to the document and return the parsed response.
|
||||
fn batch_update_raw(
|
||||
document_id: &str,
|
||||
requests: Vec<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let path = format!("{}:batchUpdate", url_encode(document_id));
|
||||
|
||||
let body = serde_json::json!({ "requests": requests });
|
||||
let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?;
|
||||
|
||||
let response = api_call("POST", &path, Some(&body_str))?;
|
||||
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))
|
||||
}
|
||||
|
||||
/// Extract revision ID from a batchUpdate response.
|
||||
fn extract_revision_id(parsed: &serde_json::Value) -> String {
|
||||
parsed["writeControl"]["requiredRevisionId"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Create a new document.
|
||||
pub fn create_document(title: &str) -> Result<CreateDocumentResult, String> {
|
||||
let body = serde_json::json!({ "title": title });
|
||||
let body_str = serde_json::to_string(&body).map_err(|e| e.to_string())?;
|
||||
|
||||
let response = api_call("POST", "", Some(&body_str))?;
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
Ok(CreateDocumentResult {
|
||||
document_id: parsed["documentId"].as_str().unwrap_or("").to_string(),
|
||||
title: parsed["title"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get document metadata.
|
||||
pub fn get_document(document_id: &str) -> Result<DocumentMetadata, String> {
|
||||
let path = url_encode(document_id);
|
||||
|
||||
let response = api_call("GET", &path, None)?;
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
// Calculate body length from the last element's endIndex
|
||||
let body_length = parsed["body"]["content"]
|
||||
.as_array()
|
||||
.and_then(|arr| arr.last())
|
||||
.and_then(|el| el["endIndex"].as_i64())
|
||||
.unwrap_or(1);
|
||||
|
||||
// Extract named ranges
|
||||
let mut named_ranges = Vec::new();
|
||||
if let Some(nr_map) = parsed["namedRanges"].as_object() {
|
||||
for (_name, nr_group) in nr_map {
|
||||
if let Some(ranges) = nr_group["namedRanges"].as_array() {
|
||||
for nr in ranges {
|
||||
let name = nr["name"].as_str().unwrap_or("").to_string();
|
||||
let id = nr["namedRangeId"].as_str().unwrap_or("").to_string();
|
||||
if let Some(range_list) = nr["ranges"].as_array() {
|
||||
for range in range_list {
|
||||
named_ranges.push(DocumentNamedRange {
|
||||
name: name.clone(),
|
||||
named_range_id: id.clone(),
|
||||
start_index: range["startIndex"].as_i64().unwrap_or(0),
|
||||
end_index: range["endIndex"].as_i64().unwrap_or(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DocumentMetadata {
|
||||
document_id: parsed["documentId"].as_str().unwrap_or("").to_string(),
|
||||
title: parsed["title"].as_str().unwrap_or("").to_string(),
|
||||
revision_id: parsed["revisionId"].as_str().unwrap_or("").to_string(),
|
||||
body_length,
|
||||
named_ranges,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the document body as plain text by walking the structural elements.
|
||||
pub fn read_content(document_id: &str) -> Result<ReadContentResult, String> {
|
||||
let path = url_encode(document_id);
|
||||
|
||||
let response = api_call("GET", &path, None)?;
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
let mut text = String::new();
|
||||
if let Some(content) = parsed["body"]["content"].as_array() {
|
||||
extract_text_from_elements(content, &mut text);
|
||||
}
|
||||
|
||||
Ok(ReadContentResult {
|
||||
document_id: parsed["documentId"].as_str().unwrap_or("").to_string(),
|
||||
title: parsed["title"].as_str().unwrap_or("").to_string(),
|
||||
content: text,
|
||||
})
|
||||
}
|
||||
|
||||
/// Recursively extract plain text from structural elements.
|
||||
fn extract_text_from_elements(elements: &[serde_json::Value], out: &mut String) {
|
||||
for el in elements {
|
||||
// Paragraph
|
||||
if let Some(para) = el.get("paragraph") {
|
||||
if let Some(para_elements) = para["elements"].as_array() {
|
||||
for pe in para_elements {
|
||||
if let Some(text_run) = pe.get("textRun") {
|
||||
if let Some(content) = text_run["content"].as_str() {
|
||||
out.push_str(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Table: recurse into cells
|
||||
if let Some(table) = el.get("table") {
|
||||
if let Some(rows) = table["tableRows"].as_array() {
|
||||
for row in rows {
|
||||
if let Some(cells) = row["tableCells"].as_array() {
|
||||
for cell in cells {
|
||||
if let Some(cell_content) = cell["content"].as_array() {
|
||||
extract_text_from_elements(cell_content, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert text at a position.
|
||||
pub fn insert_text(
|
||||
document_id: &str,
|
||||
text: &str,
|
||||
index: i64,
|
||||
segment_id: &str,
|
||||
) -> Result<UpdateResult, String> {
|
||||
let request = if index < 0 {
|
||||
// Append at end of segment
|
||||
let mut loc = serde_json::json!({});
|
||||
if !segment_id.is_empty() {
|
||||
loc["segmentId"] = serde_json::Value::String(segment_id.to_string());
|
||||
}
|
||||
serde_json::json!({
|
||||
"insertText": {
|
||||
"text": text,
|
||||
"endOfSegmentLocation": loc,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
let mut loc = serde_json::json!({ "index": index });
|
||||
if !segment_id.is_empty() {
|
||||
loc["segmentId"] = serde_json::Value::String(segment_id.to_string());
|
||||
}
|
||||
serde_json::json!({
|
||||
"insertText": {
|
||||
"text": text,
|
||||
"location": loc,
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let parsed = batch_update_raw(document_id, vec![request])?;
|
||||
|
||||
Ok(UpdateResult {
|
||||
document_id: parsed["documentId"].as_str().unwrap_or("").to_string(),
|
||||
revision_id: extract_revision_id(&parsed),
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete content in a range.
|
||||
pub fn delete_content(
|
||||
document_id: &str,
|
||||
start_index: i64,
|
||||
end_index: i64,
|
||||
segment_id: &str,
|
||||
) -> Result<UpdateResult, String> {
|
||||
let mut range = serde_json::json!({
|
||||
"startIndex": start_index,
|
||||
"endIndex": end_index,
|
||||
});
|
||||
if !segment_id.is_empty() {
|
||||
range["segmentId"] = serde_json::Value::String(segment_id.to_string());
|
||||
}
|
||||
|
||||
let request = serde_json::json!({
|
||||
"deleteContentRange": { "range": range }
|
||||
});
|
||||
|
||||
let parsed = batch_update_raw(document_id, vec![request])?;
|
||||
|
||||
Ok(UpdateResult {
|
||||
document_id: parsed["documentId"].as_str().unwrap_or("").to_string(),
|
||||
revision_id: extract_revision_id(&parsed),
|
||||
})
|
||||
}
|
||||
|
||||
/// Find and replace all occurrences of text.
|
||||
pub fn replace_text(
|
||||
document_id: &str,
|
||||
find: &str,
|
||||
replace: &str,
|
||||
match_case: bool,
|
||||
) -> Result<ReplaceResult, String> {
|
||||
let request = serde_json::json!({
|
||||
"replaceAllText": {
|
||||
"containsText": {
|
||||
"text": find,
|
||||
"matchCase": match_case,
|
||||
},
|
||||
"replaceText": replace,
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = batch_update_raw(document_id, vec![request])?;
|
||||
|
||||
let occurrences = parsed["replies"][0]["replaceAllText"]["occurrencesChanged"]
|
||||
.as_i64()
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(ReplaceResult {
|
||||
document_id: parsed["documentId"].as_str().unwrap_or("").to_string(),
|
||||
revision_id: extract_revision_id(&parsed),
|
||||
occurrences_changed: occurrences,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a hex color like "#FF0000" into Docs API color format.
|
||||
fn parse_hex_color(hex: &str) -> Option<serde_json::Value> {
|
||||
let hex = hex.strip_prefix('#').unwrap_or(hex);
|
||||
if hex.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
Some(serde_json::json!({
|
||||
"color": {
|
||||
"rgbColor": {
|
||||
"red": r as f64 / 255.0,
|
||||
"green": g as f64 / 255.0,
|
||||
"blue": b as f64 / 255.0,
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Parameters for text formatting.
|
||||
pub struct FormatTextOptions<'a> {
|
||||
pub document_id: &'a str,
|
||||
pub start_index: i64,
|
||||
pub end_index: i64,
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
pub underline: Option<bool>,
|
||||
pub strikethrough: Option<bool>,
|
||||
pub font_size: Option<f64>,
|
||||
pub font_family: Option<&'a str>,
|
||||
pub foreground_color: Option<&'a str>,
|
||||
pub background_color: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Format text in a range.
|
||||
pub fn format_text(opts: FormatTextOptions<'_>) -> Result<UpdateResult, String> {
|
||||
let mut style = serde_json::json!({});
|
||||
let mut fields = Vec::new();
|
||||
|
||||
if let Some(b) = opts.bold {
|
||||
style["bold"] = serde_json::Value::Bool(b);
|
||||
fields.push("bold");
|
||||
}
|
||||
if let Some(i) = opts.italic {
|
||||
style["italic"] = serde_json::Value::Bool(i);
|
||||
fields.push("italic");
|
||||
}
|
||||
if let Some(u) = opts.underline {
|
||||
style["underline"] = serde_json::Value::Bool(u);
|
||||
fields.push("underline");
|
||||
}
|
||||
if let Some(s) = opts.strikethrough {
|
||||
style["strikethrough"] = serde_json::Value::Bool(s);
|
||||
fields.push("strikethrough");
|
||||
}
|
||||
if let Some(size) = opts.font_size {
|
||||
style["fontSize"] = serde_json::json!({ "magnitude": size, "unit": "PT" });
|
||||
fields.push("fontSize");
|
||||
}
|
||||
if let Some(family) = opts.font_family {
|
||||
style["weightedFontFamily"] = serde_json::json!({ "fontFamily": family });
|
||||
fields.push("weightedFontFamily");
|
||||
}
|
||||
if let Some(color) = opts.foreground_color {
|
||||
if let Some(c) = parse_hex_color(color) {
|
||||
style["foregroundColor"] = c;
|
||||
fields.push("foregroundColor");
|
||||
}
|
||||
}
|
||||
if let Some(color) = opts.background_color {
|
||||
if let Some(c) = parse_hex_color(color) {
|
||||
style["backgroundColor"] = c;
|
||||
fields.push("backgroundColor");
|
||||
}
|
||||
}
|
||||
|
||||
if fields.is_empty() {
|
||||
return Err("No formatting options specified".to_string());
|
||||
}
|
||||
|
||||
let request = serde_json::json!({
|
||||
"updateTextStyle": {
|
||||
"range": {
|
||||
"startIndex": opts.start_index,
|
||||
"endIndex": opts.end_index,
|
||||
},
|
||||
"textStyle": style,
|
||||
"fields": fields.join(","),
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = batch_update_raw(opts.document_id, vec![request])?;
|
||||
|
||||
Ok(UpdateResult {
|
||||
document_id: parsed["documentId"].as_str().unwrap_or("").to_string(),
|
||||
revision_id: extract_revision_id(&parsed),
|
||||
})
|
||||
}
|
||||
|
||||
/// Format paragraph style.
|
||||
pub fn format_paragraph(
|
||||
document_id: &str,
|
||||
start_index: i64,
|
||||
end_index: i64,
|
||||
named_style: Option<&str>,
|
||||
alignment: Option<&str>,
|
||||
line_spacing: Option<f64>,
|
||||
) -> Result<UpdateResult, String> {
|
||||
let mut para_style = serde_json::json!({});
|
||||
let mut fields = Vec::new();
|
||||
|
||||
if let Some(style) = named_style {
|
||||
para_style["namedStyleType"] = serde_json::Value::String(style.to_string());
|
||||
fields.push("namedStyleType");
|
||||
}
|
||||
if let Some(align) = alignment {
|
||||
para_style["alignment"] = serde_json::Value::String(align.to_string());
|
||||
fields.push("alignment");
|
||||
}
|
||||
if let Some(spacing) = line_spacing {
|
||||
para_style["lineSpacing"] = serde_json::json!(spacing);
|
||||
fields.push("lineSpacing");
|
||||
}
|
||||
|
||||
if fields.is_empty() {
|
||||
return Err("No paragraph style options specified".to_string());
|
||||
}
|
||||
|
||||
let request = serde_json::json!({
|
||||
"updateParagraphStyle": {
|
||||
"range": {
|
||||
"startIndex": start_index,
|
||||
"endIndex": end_index,
|
||||
},
|
||||
"paragraphStyle": para_style,
|
||||
"fields": fields.join(","),
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = batch_update_raw(document_id, vec![request])?;
|
||||
|
||||
Ok(UpdateResult {
|
||||
document_id: parsed["documentId"].as_str().unwrap_or("").to_string(),
|
||||
revision_id: extract_revision_id(&parsed),
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert a table at a position.
|
||||
pub fn insert_table(
|
||||
document_id: &str,
|
||||
rows: i64,
|
||||
columns: i64,
|
||||
index: i64,
|
||||
) -> Result<UpdateResult, String> {
|
||||
let request = serde_json::json!({
|
||||
"insertTable": {
|
||||
"rows": rows,
|
||||
"columns": columns,
|
||||
"location": { "index": index },
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = batch_update_raw(document_id, vec![request])?;
|
||||
|
||||
Ok(UpdateResult {
|
||||
document_id: parsed["documentId"].as_str().unwrap_or("").to_string(),
|
||||
revision_id: extract_revision_id(&parsed),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a bulleted or numbered list from paragraphs in a range.
|
||||
pub fn create_list(
|
||||
document_id: &str,
|
||||
start_index: i64,
|
||||
end_index: i64,
|
||||
bullet_preset: &str,
|
||||
) -> Result<UpdateResult, String> {
|
||||
let request = serde_json::json!({
|
||||
"createParagraphBullets": {
|
||||
"range": {
|
||||
"startIndex": start_index,
|
||||
"endIndex": end_index,
|
||||
},
|
||||
"bulletPreset": bullet_preset,
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = batch_update_raw(document_id, vec![request])?;
|
||||
|
||||
Ok(UpdateResult {
|
||||
document_id: parsed["documentId"].as_str().unwrap_or("").to_string(),
|
||||
revision_id: extract_revision_id(&parsed),
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a raw batch update with arbitrary requests.
|
||||
pub fn batch_update(
|
||||
document_id: &str,
|
||||
requests: Vec<serde_json::Value>,
|
||||
) -> Result<BatchUpdateResult, String> {
|
||||
let parsed = batch_update_raw(document_id, requests)?;
|
||||
|
||||
let replies = parsed["replies"]
|
||||
.as_array()
|
||||
.map(|arr| arr.to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(BatchUpdateResult {
|
||||
document_id: parsed["documentId"].as_str().unwrap_or("").to_string(),
|
||||
revision_id: extract_revision_id(&parsed),
|
||||
replies,
|
||||
})
|
||||
}
|
||||
|
||||
/// Minimal percent-encoding for URL path segments.
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut encoded = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
encoded.push('%');
|
||||
encoded.push(char::from(HEX[(b >> 4) as usize]));
|
||||
encoded.push(char::from(HEX[(b & 0x0F) as usize]));
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
const HEX: [u8; 16] = *b"0123456789ABCDEF";
|
||||
@@ -0,0 +1,481 @@
|
||||
//! Google Docs WASM Tool for IronClaw.
|
||||
//!
|
||||
//! Provides Google Docs integration for creating, reading, editing,
|
||||
//! and formatting documents. Use Google Drive tool to search for
|
||||
//! existing documents by name.
|
||||
//!
|
||||
//! # Capabilities Required
|
||||
//!
|
||||
//! - HTTP: `docs.googleapis.com/v1/documents*`
|
||||
//! - Secrets: `google_oauth_token` (shared OAuth 2.0 token, injected automatically)
|
||||
//!
|
||||
//! # Supported Actions
|
||||
//!
|
||||
//! - `create_document`: Create a new blank document
|
||||
//! - `get_document`: Get document metadata (title, length, named ranges)
|
||||
//! - `read_content`: Read entire document body as plain text
|
||||
//! - `insert_text`: Insert text at a position (or append at end)
|
||||
//! - `delete_content`: Delete text in a range
|
||||
//! - `replace_text`: Find and replace all occurrences
|
||||
//! - `format_text`: Format text (bold, italic, font, color, size)
|
||||
//! - `format_paragraph`: Set heading level, alignment, spacing
|
||||
//! - `insert_table`: Insert a table at a position
|
||||
//! - `create_list`: Create bulleted/numbered list from paragraphs
|
||||
//! - `batch_update`: Execute multiple raw Docs API operations atomically
|
||||
//!
|
||||
//! # Tips
|
||||
//!
|
||||
//! - Document IDs are the same as Google Drive file IDs. Use google-drive
|
||||
//! tool's list_files to find documents.
|
||||
//! - Indexes are 0-based character offsets. An empty document body starts
|
||||
//! with a newline at index 0, so insert at index 1 to prepend text.
|
||||
//! - Use index -1 to append at the end of the document.
|
||||
//! - When doing multiple edits, process from highest index to lowest
|
||||
//! to avoid index shifting issues.
|
||||
//!
|
||||
//! # Example Usage
|
||||
//!
|
||||
//! ```json
|
||||
//! {"action": "create_document", "title": "Meeting Notes"}
|
||||
//! {"action": "read_content", "document_id": "abc123"}
|
||||
//! {"action": "insert_text", "document_id": "abc123", "text": "Hello World\n", "index": 1}
|
||||
//! {"action": "replace_text", "document_id": "abc123", "find": "Hello", "replace": "Hi"}
|
||||
//! {"action": "format_text", "document_id": "abc123", "start_index": 1, "end_index": 12, "bold": true, "font_size": 18}
|
||||
//! {"action": "format_paragraph", "document_id": "abc123", "start_index": 1, "end_index": 12, "named_style": "HEADING_1"}
|
||||
//! ```
|
||||
|
||||
mod api;
|
||||
mod types;
|
||||
|
||||
use types::GoogleDocsAction;
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "sandboxed-tool",
|
||||
path: "../../wit/tool.wit",
|
||||
});
|
||||
|
||||
struct GoogleDocsTool;
|
||||
|
||||
impl exports::near::agent::tool::Guest for GoogleDocsTool {
|
||||
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
|
||||
match execute_inner(&req.params) {
|
||||
Ok(result) => exports::near::agent::tool::Response {
|
||||
output: Some(result),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => exports::near::agent::tool::Response {
|
||||
output: None,
|
||||
error: Some(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn schema() -> String {
|
||||
r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_document" },
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Document title"
|
||||
}
|
||||
},
|
||||
"required": ["action", "title"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_document" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID (same as Google Drive file ID)"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "read_content" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "insert_text" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text to insert"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Character index to insert at (1 for start of body). Use -1 to append at end.",
|
||||
"default": -1
|
||||
},
|
||||
"segment_id": {
|
||||
"type": "string",
|
||||
"description": "Segment ID (empty string for body, or a header/footer ID)",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "text"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "delete_content" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"start_index": {
|
||||
"type": "integer",
|
||||
"description": "Start index (inclusive)"
|
||||
},
|
||||
"end_index": {
|
||||
"type": "integer",
|
||||
"description": "End index (exclusive)"
|
||||
},
|
||||
"segment_id": {
|
||||
"type": "string",
|
||||
"description": "Segment ID (empty for body)",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "start_index", "end_index"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "replace_text" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"find": {
|
||||
"type": "string",
|
||||
"description": "Text to search for"
|
||||
},
|
||||
"replace": {
|
||||
"type": "string",
|
||||
"description": "Replacement text"
|
||||
},
|
||||
"match_case": {
|
||||
"type": "boolean",
|
||||
"description": "Case-sensitive match (default: true)",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "find", "replace"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "format_text" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"start_index": {
|
||||
"type": "integer",
|
||||
"description": "Start index (inclusive)"
|
||||
},
|
||||
"end_index": {
|
||||
"type": "integer",
|
||||
"description": "End index (exclusive)"
|
||||
},
|
||||
"bold": {
|
||||
"type": "boolean",
|
||||
"description": "Make text bold"
|
||||
},
|
||||
"italic": {
|
||||
"type": "boolean",
|
||||
"description": "Make text italic"
|
||||
},
|
||||
"underline": {
|
||||
"type": "boolean",
|
||||
"description": "Underline text"
|
||||
},
|
||||
"strikethrough": {
|
||||
"type": "boolean",
|
||||
"description": "Strikethrough text"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number",
|
||||
"description": "Font size in points (e.g., 12, 14, 18)"
|
||||
},
|
||||
"font_family": {
|
||||
"type": "string",
|
||||
"description": "Font family (e.g., 'Arial', 'Times New Roman', 'Courier New')"
|
||||
},
|
||||
"foreground_color": {
|
||||
"type": "string",
|
||||
"description": "Text color as hex (e.g., '#FF0000' for red)"
|
||||
},
|
||||
"background_color": {
|
||||
"type": "string",
|
||||
"description": "Text background/highlight color as hex"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "start_index", "end_index"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "format_paragraph" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"start_index": {
|
||||
"type": "integer",
|
||||
"description": "Start index (inclusive)"
|
||||
},
|
||||
"end_index": {
|
||||
"type": "integer",
|
||||
"description": "End index (exclusive)"
|
||||
},
|
||||
"named_style": {
|
||||
"type": "string",
|
||||
"enum": ["NORMAL_TEXT", "TITLE", "SUBTITLE", "HEADING_1", "HEADING_2", "HEADING_3", "HEADING_4", "HEADING_5", "HEADING_6"],
|
||||
"description": "Paragraph style (heading level)"
|
||||
},
|
||||
"alignment": {
|
||||
"type": "string",
|
||||
"enum": ["START", "CENTER", "END", "JUSTIFIED"],
|
||||
"description": "Text alignment"
|
||||
},
|
||||
"line_spacing": {
|
||||
"type": "number",
|
||||
"description": "Line spacing as percentage (e.g., 100 for single, 150 for 1.5x, 200 for double)"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "start_index", "end_index"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "insert_table" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"rows": {
|
||||
"type": "integer",
|
||||
"description": "Number of rows"
|
||||
},
|
||||
"columns": {
|
||||
"type": "integer",
|
||||
"description": "Number of columns"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Character index to insert the table at"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "rows", "columns", "index"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_list" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"start_index": {
|
||||
"type": "integer",
|
||||
"description": "Start index (inclusive)"
|
||||
},
|
||||
"end_index": {
|
||||
"type": "integer",
|
||||
"description": "End index (exclusive)"
|
||||
},
|
||||
"bullet_preset": {
|
||||
"type": "string",
|
||||
"enum": ["BULLET_DISC_CIRCLE_SQUARE", "BULLET_CHECKBOX", "BULLET_ARROW_DIAMOND_DISC", "NUMBERED_DECIMAL_ALPHA_ROMAN", "NUMBERED_DECIMAL_NESTED", "NUMBERED_UPPERALPHA_ALPHA_ROMAN"],
|
||||
"description": "Bullet style preset (default: BULLET_DISC_CIRCLE_SQUARE)",
|
||||
"default": "BULLET_DISC_CIRCLE_SQUARE"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "start_index", "end_index"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "batch_update" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"requests": {
|
||||
"type": "array",
|
||||
"items": { "type": "object" },
|
||||
"description": "Array of raw Docs API batchUpdate request objects"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "requests"]
|
||||
}
|
||||
]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn description() -> String {
|
||||
"Google Docs integration for creating, reading, editing, and formatting documents. \
|
||||
Supports text operations (insert, delete, find-replace), text formatting (bold, italic, \
|
||||
font, color, size), paragraph styling (headings, alignment, spacing), tables, and \
|
||||
bulleted/numbered lists. Also provides a batch_update action for complex multi-step \
|
||||
edits executed atomically. Document IDs are the same as Google Drive file IDs, so use \
|
||||
the google-drive tool to search for existing documents. Requires a Google OAuth token \
|
||||
with the documents scope."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_inner(params: &str) -> Result<String, String> {
|
||||
if !crate::near::agent::host::secret_exists("google_oauth_token") {
|
||||
return Err(
|
||||
"Google OAuth token not configured. Run `ironclaw tool auth google-docs` to set up \
|
||||
OAuth, or set the GOOGLE_OAUTH_TOKEN environment variable."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let action: GoogleDocsAction =
|
||||
serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?;
|
||||
|
||||
crate::near::agent::host::log(
|
||||
crate::near::agent::host::LogLevel::Info,
|
||||
&format!("Executing Google Docs action: {:?}", action),
|
||||
);
|
||||
|
||||
let result = match action {
|
||||
GoogleDocsAction::CreateDocument { title } => {
|
||||
let result = api::create_document(&title)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GoogleDocsAction::GetDocument { document_id } => {
|
||||
let result = api::get_document(&document_id)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GoogleDocsAction::ReadContent { document_id } => {
|
||||
let result = api::read_content(&document_id)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GoogleDocsAction::InsertText {
|
||||
document_id,
|
||||
text,
|
||||
index,
|
||||
segment_id,
|
||||
} => {
|
||||
let result = api::insert_text(&document_id, &text, index, &segment_id)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GoogleDocsAction::DeleteContent {
|
||||
document_id,
|
||||
start_index,
|
||||
end_index,
|
||||
segment_id,
|
||||
} => {
|
||||
let result = api::delete_content(&document_id, start_index, end_index, &segment_id)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GoogleDocsAction::ReplaceText {
|
||||
document_id,
|
||||
find,
|
||||
replace,
|
||||
match_case,
|
||||
} => {
|
||||
let result = api::replace_text(&document_id, &find, &replace, match_case)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GoogleDocsAction::FormatText {
|
||||
document_id,
|
||||
start_index,
|
||||
end_index,
|
||||
bold,
|
||||
italic,
|
||||
underline,
|
||||
strikethrough,
|
||||
font_size,
|
||||
font_family,
|
||||
foreground_color,
|
||||
background_color,
|
||||
} => {
|
||||
let result = api::format_text(api::FormatTextOptions {
|
||||
document_id: &document_id,
|
||||
start_index,
|
||||
end_index,
|
||||
bold,
|
||||
italic,
|
||||
underline,
|
||||
strikethrough,
|
||||
font_size,
|
||||
font_family: font_family.as_deref(),
|
||||
foreground_color: foreground_color.as_deref(),
|
||||
background_color: background_color.as_deref(),
|
||||
})?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GoogleDocsAction::FormatParagraph {
|
||||
document_id,
|
||||
start_index,
|
||||
end_index,
|
||||
named_style,
|
||||
alignment,
|
||||
line_spacing,
|
||||
} => {
|
||||
let result = api::format_paragraph(
|
||||
&document_id,
|
||||
start_index,
|
||||
end_index,
|
||||
named_style.as_deref(),
|
||||
alignment.as_deref(),
|
||||
line_spacing,
|
||||
)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GoogleDocsAction::InsertTable {
|
||||
document_id,
|
||||
rows,
|
||||
columns,
|
||||
index,
|
||||
} => {
|
||||
let result = api::insert_table(&document_id, rows, columns, index)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GoogleDocsAction::CreateList {
|
||||
document_id,
|
||||
start_index,
|
||||
end_index,
|
||||
bullet_preset,
|
||||
} => {
|
||||
let result = api::create_list(&document_id, start_index, end_index, &bullet_preset)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GoogleDocsAction::BatchUpdate {
|
||||
document_id,
|
||||
requests,
|
||||
} => {
|
||||
let result = api::batch_update(&document_id, requests)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
export!(GoogleDocsTool);
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Types for Google Docs API requests and responses.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Input parameters for the Google Docs tool.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum GoogleDocsAction {
|
||||
/// Create a new document.
|
||||
CreateDocument {
|
||||
/// Document title.
|
||||
title: String,
|
||||
},
|
||||
|
||||
/// Get document metadata and structure (title, body text, named ranges).
|
||||
GetDocument {
|
||||
/// The document ID (same as Google Drive file ID).
|
||||
document_id: String,
|
||||
},
|
||||
|
||||
/// Read the document body as plain text.
|
||||
ReadContent {
|
||||
/// The document ID.
|
||||
document_id: String,
|
||||
},
|
||||
|
||||
/// Insert text at a position.
|
||||
InsertText {
|
||||
/// The document ID.
|
||||
document_id: String,
|
||||
/// Text to insert.
|
||||
text: String,
|
||||
/// Character index to insert at (1-based, since 0 is before the body).
|
||||
/// Use -1 to append at end.
|
||||
#[serde(default = "default_insert_index")]
|
||||
index: i64,
|
||||
/// Segment ID ("" for body, or a header/footer ID).
|
||||
#[serde(default)]
|
||||
segment_id: String,
|
||||
},
|
||||
|
||||
/// Delete content in a range.
|
||||
DeleteContent {
|
||||
/// The document ID.
|
||||
document_id: String,
|
||||
/// Start index (inclusive).
|
||||
start_index: i64,
|
||||
/// End index (exclusive).
|
||||
end_index: i64,
|
||||
/// Segment ID ("" for body).
|
||||
#[serde(default)]
|
||||
segment_id: String,
|
||||
},
|
||||
|
||||
/// Find and replace all occurrences of text.
|
||||
ReplaceText {
|
||||
/// The document ID.
|
||||
document_id: String,
|
||||
/// Text to search for.
|
||||
find: String,
|
||||
/// Replacement text.
|
||||
replace: String,
|
||||
/// Case-sensitive match (default: true).
|
||||
#[serde(default = "default_true")]
|
||||
match_case: bool,
|
||||
},
|
||||
|
||||
/// Format text in a range (bold, italic, font size, color, etc.).
|
||||
FormatText {
|
||||
/// The document ID.
|
||||
document_id: String,
|
||||
/// Start index (inclusive).
|
||||
start_index: i64,
|
||||
/// End index (exclusive).
|
||||
end_index: i64,
|
||||
/// Make text bold.
|
||||
#[serde(default)]
|
||||
bold: Option<bool>,
|
||||
/// Make text italic.
|
||||
#[serde(default)]
|
||||
italic: Option<bool>,
|
||||
/// Underline text.
|
||||
#[serde(default)]
|
||||
underline: Option<bool>,
|
||||
/// Strikethrough text.
|
||||
#[serde(default)]
|
||||
strikethrough: Option<bool>,
|
||||
/// Font size in points.
|
||||
#[serde(default)]
|
||||
font_size: Option<f64>,
|
||||
/// Font family name (e.g., "Arial", "Times New Roman").
|
||||
#[serde(default)]
|
||||
font_family: Option<String>,
|
||||
/// Text color as hex (e.g., "#FF0000").
|
||||
#[serde(default)]
|
||||
foreground_color: Option<String>,
|
||||
/// Text background color as hex.
|
||||
#[serde(default)]
|
||||
background_color: Option<String>,
|
||||
},
|
||||
|
||||
/// Set paragraph style (heading level, alignment, spacing).
|
||||
FormatParagraph {
|
||||
/// The document ID.
|
||||
document_id: String,
|
||||
/// Start index (inclusive).
|
||||
start_index: i64,
|
||||
/// End index (exclusive).
|
||||
end_index: i64,
|
||||
/// Named style: "NORMAL_TEXT", "TITLE", "SUBTITLE", "HEADING_1" through "HEADING_6".
|
||||
#[serde(default)]
|
||||
named_style: Option<String>,
|
||||
/// Alignment: "START", "CENTER", "END", "JUSTIFIED".
|
||||
#[serde(default)]
|
||||
alignment: Option<String>,
|
||||
/// Line spacing as percentage (e.g., 115 for 1.15x).
|
||||
#[serde(default)]
|
||||
line_spacing: Option<f64>,
|
||||
},
|
||||
|
||||
/// Insert a table at a position.
|
||||
InsertTable {
|
||||
/// The document ID.
|
||||
document_id: String,
|
||||
/// Number of rows.
|
||||
rows: i64,
|
||||
/// Number of columns.
|
||||
columns: i64,
|
||||
/// Character index to insert at.
|
||||
index: i64,
|
||||
},
|
||||
|
||||
/// Create a bulleted or numbered list from a range of paragraphs.
|
||||
CreateList {
|
||||
/// The document ID.
|
||||
document_id: String,
|
||||
/// Start index (inclusive).
|
||||
start_index: i64,
|
||||
/// End index (exclusive).
|
||||
end_index: i64,
|
||||
/// Bullet preset. Bulleted: "BULLET_DISC_CIRCLE_SQUARE" (default).
|
||||
/// Numbered: "NUMBERED_DECIMAL_ALPHA_ROMAN".
|
||||
#[serde(default = "default_bullet_preset")]
|
||||
bullet_preset: String,
|
||||
},
|
||||
|
||||
/// Execute multiple operations in a single atomic batch.
|
||||
/// Each operation is an object with one key (the request type name)
|
||||
/// and a value matching the Docs API batchUpdate request format.
|
||||
BatchUpdate {
|
||||
/// The document ID.
|
||||
document_id: String,
|
||||
/// Array of raw request objects as per Google Docs API.
|
||||
requests: Vec<serde_json::Value>,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_insert_index() -> i64 {
|
||||
-1
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_bullet_preset() -> String {
|
||||
"BULLET_DISC_CIRCLE_SQUARE".to_string()
|
||||
}
|
||||
|
||||
/// Result from create_document.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateDocumentResult {
|
||||
pub document_id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Result from get_document.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DocumentMetadata {
|
||||
pub document_id: String,
|
||||
pub title: String,
|
||||
pub revision_id: String,
|
||||
pub body_length: i64,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub named_ranges: Vec<DocumentNamedRange>,
|
||||
}
|
||||
|
||||
/// Named range within a document.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DocumentNamedRange {
|
||||
pub name: String,
|
||||
pub named_range_id: String,
|
||||
pub start_index: i64,
|
||||
pub end_index: i64,
|
||||
}
|
||||
|
||||
/// Result from read_content.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReadContentResult {
|
||||
pub document_id: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Result from insert_text, delete_content, replace_text.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UpdateResult {
|
||||
pub document_id: String,
|
||||
pub revision_id: String,
|
||||
}
|
||||
|
||||
/// Result from replace_text with occurrence count.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReplaceResult {
|
||||
pub document_id: String,
|
||||
pub revision_id: String,
|
||||
pub occurrences_changed: i64,
|
||||
}
|
||||
|
||||
/// Result from batch_update.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BatchUpdateResult {
|
||||
pub document_id: String,
|
||||
pub revision_id: String,
|
||||
pub replies: Vec<serde_json::Value>,
|
||||
}
|
||||
Reference in New Issue
Block a user