diff --git a/src/docx_handler.rs b/src/docx_handler.rs index 63e3184..b5ccde5 100644 --- a/src/docx_handler.rs +++ b/src/docx_handler.rs @@ -68,6 +68,14 @@ pub struct DocxHandler { in_memory_ops: std::collections::HashMap>, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum RangeId { + Paragraph { index: usize }, + Heading { index: usize }, + TableCell { table_index: usize, row: usize, col: usize }, +} + impl DocxHandler { pub fn new() -> Result { let base = std::env::var_os("DOCX_MCP_TEMP").map(PathBuf::from).unwrap_or_else(|| std::env::temp_dir()); @@ -609,6 +617,129 @@ impl DocxHandler { })) } + /// Outline with stable indices for headings (range_ids) + pub fn get_outline(&self, doc_id: &str) -> Result { + let ops = self.in_memory_ops.get(doc_id) + .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; + let mut outline = Vec::new(); + let mut heading_idx = 0usize; + for op in ops.iter() { + if let DocxOp::Heading { text, style } = op { + let level = style.chars().last().and_then(|c| c.to_digit(10)).map(|d| d as usize).unwrap_or(1); + outline.push(serde_json::json!({ + "text": text, + "level": level, + "range_id": RangeId::Heading { index: heading_idx } + })); + heading_idx += 1; + } + } + Ok(serde_json::json!({"outline": outline})) + } + + /// Simple selector to ranges. Supported selectors: + /// - heading:'Text' + /// - paragraph[INDEX] + /// - table[T].cell[R,C] + pub fn get_ranges(&self, doc_id: &str, selector: &str) -> Result> { + let ops = self.in_memory_ops.get(doc_id) + .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; + let mut results = Vec::new(); + if let Some(rest) = selector.strip_prefix("heading:") { + let needle = rest.trim().trim_matches('\'').trim_matches('"'); + let mut idx = 0usize; + for op in ops.iter() { + if let DocxOp::Heading { text, .. } = op { + if text == needle { results.push(RangeId::Heading { index: idx }); } + idx += 1; + } + } + return Ok(results); + } + if let Some(start) = selector.strip_prefix("paragraph[") { + if let Some(endpos) = start.find(']') { + if let Ok(pi) = start[..endpos].parse::() { + results.push(RangeId::Paragraph { index: pi }); + return Ok(results); + } + } + } + if let Some(start) = selector.strip_prefix("table[") { + if let Some(endt) = start.find(']') { + let t_str = &start[..endt]; + if let Some(cell_part) = start[endt+1..].strip_prefix(".cell[") { + if let Some(endc) = cell_part.find(']') { + let coords = &cell_part[..endc]; + let mut it = coords.split(','); + if let (Ok(ti), Some(rs), Some(cs)) = ( + t_str.parse::(), + it.next(), it.next() + ) { + if let (Ok(r), Ok(c)) = (rs.trim().parse::(), cs.trim().parse::()) { + results.push(RangeId::TableCell { table_index: ti, row: r, col: c }); + return Ok(results); + } + } + } + } + } + } + Ok(results) + } + + /// Replace text in a given range id (paragraph or heading). For TableCell use set_table_cell_text + pub fn replace_range_text(&mut self, doc_id: &str, range: &RangeId, new_text: &str) -> Result<()> { + self.ensure_modifiable(doc_id)?; + let ops = self.in_memory_ops.get_mut(doc_id) + .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; + match range { + RangeId::Paragraph { index } => { + let mut para_idx = 0usize; + for op in ops.iter_mut() { + if let DocxOp::Paragraph { text, .. } = op { + if ¶_idx == index { *text = new_text.to_string(); break; } + para_idx += 1; + } + } + } + RangeId::Heading { index } => { + let mut h_idx = 0usize; + for op in ops.iter_mut() { + if let DocxOp::Heading { text, .. } = op { + if &h_idx == index { *text = new_text.to_string(); break; } + h_idx += 1; + } + } + } + RangeId::TableCell { .. } => anyhow::bail!("Use set_table_cell_text for table cells"), + } + self.write_docx(doc_id)?; + Ok(()) + } + + /// Set table cell text by table index and coordinates + pub fn set_table_cell_text(&mut self, doc_id: &str, table_index: usize, row: usize, col: usize, text: &str) -> Result<()> { + self.ensure_modifiable(doc_id)?; + let ops = self.in_memory_ops.get_mut(doc_id) + .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; + let mut ti = 0usize; + for op in ops.iter_mut() { + if let DocxOp::Table { data } = op { + if ti == table_index { + if row < data.rows.len() && col < data.rows[row].len() { + data.rows[row][col] = text.to_string(); + self.write_docx(doc_id)?; + return Ok(()); + } else { + anyhow::bail!("Cell out of bounds"); + } + } + ti += 1; + } + } + anyhow::bail!("Table not found") + } + pub fn extract_text(&self, doc_id: &str) -> Result { let _metadata = self.documents.get(doc_id) .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; diff --git a/src/docx_tools.rs b/src/docx_tools.rs index d516b8e..2cdc868 100644 --- a/src/docx_tools.rs +++ b/src/docx_tools.rs @@ -734,6 +734,46 @@ impl DocxToolsProvider { }), annotations: None, }, + Tool { + name: "get_outline".to_string(), + description: Some("Return heading outline with range_ids".to_string()), + input_schema: json!({ + "type": "object", + "properties": {"document_id": {"type": "string"}}, + "required": ["document_id"] + }), + annotations: None, + }, + Tool { + name: "get_ranges".to_string(), + description: Some("Resolve a selector to range_ids (heading:'Text', paragraph[i], table[t].cell[r,c])".to_string()), + input_schema: json!({ + "type": "object", + "properties": {"document_id": {"type": "string"}, "selector": {"type": "string"}}, + "required": ["document_id", "selector"] + }), + annotations: None, + }, + Tool { + name: "replace_range_text".to_string(), + description: Some("Replace text in a paragraph/heading by range_id".to_string()), + input_schema: json!({ + "type": "object", + "properties": {"document_id": {"type": "string"}, "range_id": {"type": "object"}, "text": {"type": "string"}}, + "required": ["document_id", "range_id", "text"] + }), + annotations: None, + }, + Tool { + name: "set_table_cell_text".to_string(), + description: Some("Set text in a table cell by indices".to_string()), + input_schema: json!({ + "type": "object", + "properties": {"document_id": {"type": "string"}, "table_index": {"type": "integer"}, "row": {"type": "integer"}, "col": {"type": "integer"}, "text": {"type": "string"}}, + "required": ["document_id", "table_index", "row", "col", "text"] + }), + annotations: None, + }, Tool { name: "get_document_properties".to_string(), description: Some("Get document properties (title, subject, author, timestamps)".to_string()), @@ -1452,6 +1492,51 @@ impl DocxToolsProvider { Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None } } }, + "get_outline" => { + let doc_id = arguments["document_id"].as_str().unwrap_or(""); + let handler = self.handler.read().unwrap(); + match handler.get_outline(doc_id) { + Ok(outline) => ToolOutcome::Metadata { metadata: outline }, + Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None }, + } + }, + "get_ranges" => { + let doc_id = arguments["document_id"].as_str().unwrap_or(""); + let selector = arguments["selector"].as_str().unwrap_or(""); + let handler = self.handler.read().unwrap(); + match handler.get_ranges(doc_id, selector) { + Ok(ranges) => ToolOutcome::Metadata { metadata: serde_json::json!({"ranges": ranges}) }, + Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None }, + } + }, + "replace_range_text" => { + let doc_id = arguments["document_id"].as_str().unwrap_or(""); + let range_id = arguments["range_id"].clone(); + let text = arguments["text"].as_str().unwrap_or(""); + let range: crate::docx_handler::RangeId = match serde_json::from_value(range_id) { + Ok(v) => v, + Err(e) => { + return CallToolResponse { content: vec![ToolResponseContent::Text(TextContent { content_type: "application/json".into(), text: serde_json::json!({"success": false, "code": ErrorCode::ValidationError, "error": format!("invalid range_id: {}", e)}).to_string(), annotations: None })], is_error: Some(true), meta: None }; + } + }; + let mut handler = self.handler.write().unwrap(); + match handler.replace_range_text(doc_id, &range, text) { + Ok(_) => ToolOutcome::Ok { message: Some("Range text replaced".into()) }, + Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, + } + }, + "set_table_cell_text" => { + let doc_id = arguments["document_id"].as_str().unwrap_or(""); + let ti = arguments["table_index"].as_u64().unwrap_or(0) as usize; + let r = arguments["row"].as_u64().unwrap_or(0) as usize; + let c = arguments["col"].as_u64().unwrap_or(0) as usize; + let text = arguments["text"].as_str().unwrap_or(""); + let mut handler = self.handler.write().unwrap(); + match handler.set_table_cell_text(doc_id, ti, r, c, text) { + Ok(_) => ToolOutcome::Ok { message: Some("Table cell updated".into()) }, + Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, + } + }, "analyze_formatting" => { let doc_id = arguments["document_id"].as_str().unwrap_or("");