mcp-gee-sweet 0.7.0.dev48__tar.gz → 0.7.0.dev49__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/CLAUDE.md +8 -1
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/PKG-INFO +1 -1
- mcp_gee_sweet-0.7.0.dev49/src/mcp_gee_sweet/tools/docs/__init__.py +11 -0
- mcp_gee_sweet-0.7.0.dev49/src/mcp_gee_sweet/tools/docs/content.py +640 -0
- mcp_gee_sweet-0.7.0.dev49/src/mcp_gee_sweet/tools/docs/layout.py +214 -0
- mcp_gee_sweet-0.7.0.dev49/src/mcp_gee_sweet/tools/docs/style.py +666 -0
- mcp_gee_sweet-0.7.0.dev49/src/mcp_gee_sweet/tools/docs/tables.py +324 -0
- mcp_gee_sweet-0.7.0.dev49/tests/test_docs_content.py +604 -0
- mcp_gee_sweet-0.7.0.dev49/tests/test_docs_core.py +777 -0
- mcp_gee_sweet-0.7.0.dev49/tests/test_docs_layout.py +269 -0
- mcp_gee_sweet-0.7.0.dev49/tests/test_docs_style.py +566 -0
- mcp_gee_sweet-0.7.0.dev49/tests/test_docs_tables.py +433 -0
- mcp_gee_sweet-0.7.0.dev48/src/mcp_gee_sweet/tools/docs/__init__.py +0 -1816
- mcp_gee_sweet-0.7.0.dev48/tests/test_docs.py +0 -2502
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.claude/commands/prep-for-pr.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.dockerignore +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/dependabot.yml +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/workflows/ci.yml +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/workflows/docs.yml +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/workflows/publish-dev.yml +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.github/workflows/release.yml +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.gitignore +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.pre-commit-config.yaml +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/.python-version +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/CHANGELOG.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/CONTRIBUTING.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/Dockerfile +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/LICENSE +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/Makefile +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/README.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/SECURITY.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/TODO.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docker-compose.yml +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/auth.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/client-setup.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/configuration.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/decision-chart-theming.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/decision-composite-tools.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/decision-docs-formatting.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/decision-fork.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/decision-publishing.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/decision-testing.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/decisions/index.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/design/docs-ast-pipeline.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/design/effectiveformat-spike.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/design/index.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/design/markdown-support.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/design/phase3-theme-system.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/design.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/index.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/known-limitations.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/notes-read.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/README.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/fixtures/tc-d195-create-doc.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/fixtures/tc-d196-create-doc.html +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/fixtures/tc-d226-heading-table.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/fixtures.template.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/operations.yaml +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/playwright_oauth.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/.gitkeep +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-02.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-14.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-17.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-18.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-21-drive-discovery.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-21-sheets-delete-clear.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/results/2026-06-28.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/retro-v0.8.0.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/run.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/runs/README.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/runs/v0.8.0.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/setup.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/calendar.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/docs.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/drive.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/infra.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/sheets_charts.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/sheets_mgmt.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/sheets_read.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa/tests/sheets_write.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/qa-checklist.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/roadmap.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/docs/tools.md +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/mkdocs.yml +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/pyproject.toml +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/scripts/oauth_setup.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/.env.template +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/__init__.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/auth.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/cache.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/server.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/__init__.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/cache.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/calendar.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/docs/ast.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/docs/emitter.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/docs/html_parser.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/drive/__init__.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/drive/activity.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/drive/files.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/drive/sharing.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/drive/transfer.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/sheets/__init__.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/sheets/data.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/sheets/helpers.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/src/mcp_gee_sweet/tools/sheets/structure.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/__init__.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/drive/__init__.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/drive/test_activity.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/drive/test_files.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/drive/test_sharing.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/drive/test_transfer.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/sheets/__init__.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/sheets/test_data.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/sheets/test_helpers.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/sheets/test_structure.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/test_auth.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/test_cache.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/test_calendar.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/tests/test_server.py +0 -0
- {mcp_gee_sweet-0.7.0.dev48 → mcp_gee_sweet-0.7.0.dev49}/uv.lock +0 -0
|
@@ -38,7 +38,14 @@ Logic is split across `src/mcp_gee_sweet/`: `server.py` (MCP setup, tool decorat
|
|
|
38
38
|
- `tools/drive/sharing.py` — permissions (`share_spreadsheet`, `share_file`, `list_permissions`, `update_permission`, `remove_permission`)
|
|
39
39
|
- `tools/drive/transfer.py` — upload/download/sync/export/revisions
|
|
40
40
|
- `tools/drive/activity.py` — `list_file_activity` (Drive Activity API v2; requires `drive.activity.readonly` scope)
|
|
41
|
-
- `tools/docs/` — Google Docs package
|
|
41
|
+
- `tools/docs/` — Google Docs package; `__init__.py` is a thin dispatcher that delegates to four submodules:
|
|
42
|
+
- `docs/content.py` — helpers (`_html_to_text`, `_md_to_html`, `_to_doc_requests`, `_html_to_doc_requests`) and tools (`create_doc`, `get_doc_content`, `write_doc_content`, `create_doc_from_file`, `insert_doc_text`, `delete_doc_range`, `get_doc_structure`, `insert_inline_image`)
|
|
43
|
+
- `docs/tables.py` — tools (`insert_doc_table`, `insert_table_row`, `delete_table_row`, `insert_table_column`, `delete_table_column`)
|
|
44
|
+
- `docs/style.py` — helpers (`_NAMED_STYLE_TYPES`, `_read_body_styles`, `_read_named_styles`, `_build_named_style_requests`) and tools (`style_doc_range`, `style_doc_table_cells`, `get_doc_theme`, `get_doc_named_styles`, `apply_theme`)
|
|
45
|
+
- `docs/layout.py` — tools (`create_header`, `create_footer`)
|
|
46
|
+
- `docs/ast.py` — dataclass schema
|
|
47
|
+
- `docs/html_parser.py` — HTML→AST
|
|
48
|
+
- `docs/emitter.py` — AST→Docs API requests + multi-phase table fill including nested table and cell styling support
|
|
42
49
|
- `tools/cache.py` — `refresh_cache`
|
|
43
50
|
- `tools/calendar.py` — Calendar API tools
|
|
44
51
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-gee-sweet
|
|
3
|
-
Version: 0.7.0.
|
|
3
|
+
Version: 0.7.0.dev49
|
|
4
4
|
Summary: MCP server for Google Workspace — Sheets, Drive, Docs, and Calendar. 60+ tools for AI clients.
|
|
5
5
|
Project-URL: Homepage, https://github.com/khuisman/mcp-gee-sweet
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/khuisman/mcp-gee-sweet/issues
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .content import register as _register_content
|
|
2
|
+
from .layout import register as _register_layout
|
|
3
|
+
from .style import register as _register_style
|
|
4
|
+
from .tables import register as _register_tables
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def register(tool):
|
|
8
|
+
_register_content(tool)
|
|
9
|
+
_register_tables(tool)
|
|
10
|
+
_register_style(tool)
|
|
11
|
+
_register_layout(tool)
|
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import html as html_module
|
|
2
|
+
import logging
|
|
3
|
+
from html.parser import HTMLParser
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import markdown as _md
|
|
8
|
+
from googleapiclient.errors import HttpError
|
|
9
|
+
from mcp.server.fastmcp import Context
|
|
10
|
+
from mcp.types import ToolAnnotations
|
|
11
|
+
|
|
12
|
+
from ..drive import _SA_QUOTA_ERROR
|
|
13
|
+
from .ast import Table
|
|
14
|
+
from .emitter import ast_to_requests, fill_tables
|
|
15
|
+
from .html_parser import html_to_ast
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _html_to_text(html_content: str) -> str:
|
|
21
|
+
"""Convert HTML to plain text, preserving block-level line breaks."""
|
|
22
|
+
_BLOCK = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "div", "tr", "blockquote"}
|
|
23
|
+
|
|
24
|
+
class _Extractor(HTMLParser):
|
|
25
|
+
def __init__(self):
|
|
26
|
+
super().__init__()
|
|
27
|
+
self.parts = []
|
|
28
|
+
|
|
29
|
+
def handle_starttag(self, tag, attrs):
|
|
30
|
+
if tag == "br" or (tag in _BLOCK and self.parts and not self.parts[-1].endswith("\n")):
|
|
31
|
+
self.parts.append("\n")
|
|
32
|
+
|
|
33
|
+
def handle_endtag(self, tag):
|
|
34
|
+
if tag in _BLOCK:
|
|
35
|
+
self.parts.append("\n")
|
|
36
|
+
|
|
37
|
+
def handle_data(self, data):
|
|
38
|
+
self.parts.append(data)
|
|
39
|
+
|
|
40
|
+
def handle_entityref(self, name):
|
|
41
|
+
self.parts.append(html_module.unescape(f"&{name};"))
|
|
42
|
+
|
|
43
|
+
def handle_charref(self, name):
|
|
44
|
+
self.parts.append(html_module.unescape(f"&#{name};"))
|
|
45
|
+
|
|
46
|
+
extractor = _Extractor()
|
|
47
|
+
extractor.feed(html_content)
|
|
48
|
+
return "".join(extractor.parts).strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _md_to_html(md_text: str) -> str:
|
|
52
|
+
"""Convert Markdown to HTML using the Python markdown library (tables, fenced_code, sane_lists extensions)."""
|
|
53
|
+
return _md.markdown(md_text, extensions=["tables", "fenced_code", "sane_lists"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _to_doc_requests(
|
|
57
|
+
content: str, content_format: str = "html", start_index: int = 1
|
|
58
|
+
) -> tuple[list[dict], list[Table]]:
|
|
59
|
+
"""Convert HTML or Markdown to Docs API batchUpdate requests via the AST pipeline.
|
|
60
|
+
|
|
61
|
+
Returns (requests, tables) where tables is a list of Table AST nodes. Table cells
|
|
62
|
+
are NOT filled here — call fill_tables() after executing the returned requests.
|
|
63
|
+
"""
|
|
64
|
+
if content_format == "markdown":
|
|
65
|
+
content = _md_to_html(content)
|
|
66
|
+
nodes = html_to_ast(content)
|
|
67
|
+
return ast_to_requests(nodes, start_index)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _html_to_doc_requests(
|
|
71
|
+
html_content: str, start_index: int = 1
|
|
72
|
+
) -> tuple[list[dict], list[Table]]:
|
|
73
|
+
return _to_doc_requests(html_content, "html", start_index)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def register(tool):
|
|
77
|
+
@tool(annotations=ToolAnnotations(title="Create Document", destructiveHint=True))
|
|
78
|
+
def create_doc(
|
|
79
|
+
title: str,
|
|
80
|
+
content: str | None = None,
|
|
81
|
+
folder_id: str | None = None,
|
|
82
|
+
content_format: str = "html",
|
|
83
|
+
ctx: Context = None,
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
"""
|
|
86
|
+
Create a new Google Doc, optionally with initial content.
|
|
87
|
+
|
|
88
|
+
Content is interpreted as HTML by default. Pass content_format='markdown' to supply
|
|
89
|
+
Markdown instead (headings, bold, italic, lists, links, tables, fenced code blocks,
|
|
90
|
+
and task list items are all supported). Tables are appended after all paragraph
|
|
91
|
+
content. Nested tables are not supported.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
title: The title of the new document
|
|
95
|
+
content: Optional content for the document body
|
|
96
|
+
folder_id: Optional Google Drive folder ID where the document should be created.
|
|
97
|
+
If not provided, creates in the root of My Drive.
|
|
98
|
+
content_format: 'html' (default) or 'markdown'
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Information about the newly created document including its ID and web link
|
|
102
|
+
|
|
103
|
+
Note:
|
|
104
|
+
Requires OAuth or ADC auth. Service accounts cannot create files in personal
|
|
105
|
+
Drive (no storage quota). Works on Shared Drives regardless of auth method.
|
|
106
|
+
Workaround for service accounts: create the file manually in Drive, then use
|
|
107
|
+
write_doc_content to populate it. Check server://auth-status for your current
|
|
108
|
+
auth method.
|
|
109
|
+
"""
|
|
110
|
+
lc = ctx.request_context.lifespan_context
|
|
111
|
+
drive_service = lc.drive_service
|
|
112
|
+
docs_service = lc.docs_service
|
|
113
|
+
target_folder_id = folder_id or lc.folder_id
|
|
114
|
+
|
|
115
|
+
file_body: dict[str, Any] = {
|
|
116
|
+
"name": title,
|
|
117
|
+
"mimeType": "application/vnd.google-apps.document",
|
|
118
|
+
}
|
|
119
|
+
if target_folder_id:
|
|
120
|
+
file_body["parents"] = [target_folder_id]
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
doc = (
|
|
124
|
+
drive_service.files()
|
|
125
|
+
.create(
|
|
126
|
+
supportsAllDrives=True,
|
|
127
|
+
body=file_body,
|
|
128
|
+
fields="id, name, parents, webViewLink",
|
|
129
|
+
)
|
|
130
|
+
.execute()
|
|
131
|
+
)
|
|
132
|
+
except HttpError as e:
|
|
133
|
+
if e.resp.status == 403 and b"storageQuotaExceeded" in (e.content or b""):
|
|
134
|
+
return {"error": _SA_QUOTA_ERROR}
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
doc_id = doc.get("id")
|
|
138
|
+
parents = doc.get("parents")
|
|
139
|
+
logger.debug(
|
|
140
|
+
"Doc created with ID: %s%s",
|
|
141
|
+
doc_id,
|
|
142
|
+
f" in folder {target_folder_id}" if target_folder_id else " in root",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if content:
|
|
146
|
+
content_requests, tables = _to_doc_requests(content, content_format, start_index=1)
|
|
147
|
+
if content_requests:
|
|
148
|
+
docs_service.documents().batchUpdate(
|
|
149
|
+
documentId=doc_id, body={"requests": content_requests}
|
|
150
|
+
).execute()
|
|
151
|
+
fill_tables(docs_service, doc_id, tables)
|
|
152
|
+
|
|
153
|
+
if target_folder_id:
|
|
154
|
+
lc.drive_folder_cache.mark_dirty(target_folder_id)
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"docId": doc_id,
|
|
158
|
+
"title": doc.get("name", title),
|
|
159
|
+
"folder": parents[0] if parents else "root",
|
|
160
|
+
"web_link": doc.get("webViewLink"),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@tool(annotations=ToolAnnotations(title="Create Document from File", destructiveHint=True))
|
|
164
|
+
def create_doc_from_file(
|
|
165
|
+
local_path: str,
|
|
166
|
+
title: str | None = None,
|
|
167
|
+
folder_id: str | None = None,
|
|
168
|
+
ctx: Context = None,
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
"""
|
|
171
|
+
Create a Google Doc from a local .md or .html file.
|
|
172
|
+
|
|
173
|
+
The file format is inferred from the extension: .md files are parsed as
|
|
174
|
+
Markdown, .html / .htm files as HTML. The document title defaults to the
|
|
175
|
+
filename without extension if not supplied.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
local_path: Absolute or relative path to the local file.
|
|
179
|
+
title: Document title. Defaults to the filename stem.
|
|
180
|
+
folder_id: Optional Google Drive folder ID. Defaults to server default folder.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Information about the newly created document including its ID and web link.
|
|
184
|
+
|
|
185
|
+
Note:
|
|
186
|
+
Requires OAuth or ADC auth. Service accounts cannot create files in personal
|
|
187
|
+
Drive (no storage quota). Check server://auth-status for your current auth method.
|
|
188
|
+
"""
|
|
189
|
+
path = Path(local_path)
|
|
190
|
+
if not path.exists():
|
|
191
|
+
return {"error": f"File not found: {local_path}"}
|
|
192
|
+
|
|
193
|
+
ext = path.suffix.lower()
|
|
194
|
+
if ext == ".md":
|
|
195
|
+
content_format = "markdown"
|
|
196
|
+
elif ext in (".html", ".htm"):
|
|
197
|
+
content_format = "html"
|
|
198
|
+
else:
|
|
199
|
+
return {"error": f"Unsupported file extension '{ext}'. Use .md or .html/.htm"}
|
|
200
|
+
|
|
201
|
+
content = path.read_text(encoding="utf-8")
|
|
202
|
+
doc_title = title or path.stem
|
|
203
|
+
|
|
204
|
+
lc = ctx.request_context.lifespan_context
|
|
205
|
+
drive_service = lc.drive_service
|
|
206
|
+
docs_service = lc.docs_service
|
|
207
|
+
target_folder_id = folder_id or lc.folder_id
|
|
208
|
+
|
|
209
|
+
file_body: dict[str, Any] = {
|
|
210
|
+
"name": doc_title,
|
|
211
|
+
"mimeType": "application/vnd.google-apps.document",
|
|
212
|
+
}
|
|
213
|
+
if target_folder_id:
|
|
214
|
+
file_body["parents"] = [target_folder_id]
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
doc = (
|
|
218
|
+
drive_service.files()
|
|
219
|
+
.create(
|
|
220
|
+
supportsAllDrives=True,
|
|
221
|
+
body=file_body,
|
|
222
|
+
fields="id, name, parents, webViewLink",
|
|
223
|
+
)
|
|
224
|
+
.execute()
|
|
225
|
+
)
|
|
226
|
+
except HttpError as e:
|
|
227
|
+
if e.resp.status == 403 and b"storageQuotaExceeded" in (e.content or b""):
|
|
228
|
+
return {"error": _SA_QUOTA_ERROR}
|
|
229
|
+
raise
|
|
230
|
+
|
|
231
|
+
doc_id = doc.get("id")
|
|
232
|
+
parents = doc.get("parents")
|
|
233
|
+
logger.debug("Doc created from file %s with ID: %s", local_path, doc_id)
|
|
234
|
+
|
|
235
|
+
if content:
|
|
236
|
+
content_requests, tables = _to_doc_requests(content, content_format, start_index=1)
|
|
237
|
+
if content_requests:
|
|
238
|
+
docs_service.documents().batchUpdate(
|
|
239
|
+
documentId=doc_id, body={"requests": content_requests}
|
|
240
|
+
).execute()
|
|
241
|
+
fill_tables(docs_service, doc_id, tables)
|
|
242
|
+
|
|
243
|
+
if target_folder_id:
|
|
244
|
+
lc.drive_folder_cache.mark_dirty(target_folder_id)
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"docId": doc_id,
|
|
248
|
+
"title": doc.get("name", doc_title),
|
|
249
|
+
"folder": parents[0] if parents else "root",
|
|
250
|
+
"web_link": doc.get("webViewLink"),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@tool(annotations=ToolAnnotations(title="Get Document Content", readOnlyHint=True))
|
|
254
|
+
def get_doc_content(file_id: str, ctx: Context = None) -> dict[str, Any]:
|
|
255
|
+
"""
|
|
256
|
+
Get the plain text content of a Google Doc.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
file_id: The Google Drive file ID of the document.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Dictionary with the document's text content and metadata. Results are
|
|
263
|
+
cached; call refresh_cache(doc_id=file_id) to invalidate, or
|
|
264
|
+
refresh_cache() to clear all caches.
|
|
265
|
+
"""
|
|
266
|
+
lc = ctx.request_context.lifespan_context
|
|
267
|
+
drive_service = lc.drive_service
|
|
268
|
+
doc_cache = lc.doc_cache
|
|
269
|
+
|
|
270
|
+
cached = doc_cache.get(file_id)
|
|
271
|
+
if cached is not None:
|
|
272
|
+
return cached
|
|
273
|
+
|
|
274
|
+
metadata = (
|
|
275
|
+
drive_service.files()
|
|
276
|
+
.get(
|
|
277
|
+
fileId=file_id, fields="id, name, modifiedTime, webViewLink", supportsAllDrives=True
|
|
278
|
+
)
|
|
279
|
+
.execute()
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
content = drive_service.files().export(fileId=file_id, mimeType="text/plain").execute()
|
|
283
|
+
|
|
284
|
+
result = {
|
|
285
|
+
"id": metadata["id"],
|
|
286
|
+
"name": metadata["name"],
|
|
287
|
+
"modified_time": metadata.get("modifiedTime"),
|
|
288
|
+
"web_link": metadata.get("webViewLink"),
|
|
289
|
+
"content": content.decode("utf-8") if isinstance(content, bytes) else content,
|
|
290
|
+
}
|
|
291
|
+
doc_cache.store(file_id, result)
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
@tool(annotations=ToolAnnotations(title="Write Document Content", destructiveHint=True))
|
|
295
|
+
def write_doc_content(
|
|
296
|
+
doc_id: str,
|
|
297
|
+
content: str,
|
|
298
|
+
content_format: str = "html",
|
|
299
|
+
ctx: Context = None,
|
|
300
|
+
) -> dict[str, Any]:
|
|
301
|
+
"""
|
|
302
|
+
Replace the full content of an existing Google Doc.
|
|
303
|
+
|
|
304
|
+
Content is interpreted as HTML by default. Pass content_format='markdown' to supply
|
|
305
|
+
Markdown instead. Headings, paragraphs, lists, links, tables, fenced code blocks,
|
|
306
|
+
and task list items are all supported. Tables are appended after all
|
|
307
|
+
paragraph content. Use this to populate a doc created manually in Drive (bypassing
|
|
308
|
+
service account storage quota limits).
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
doc_id: The Google Doc file ID.
|
|
312
|
+
content: Content to write into the document body.
|
|
313
|
+
content_format: 'html' (default) or 'markdown'
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Confirmation with the document ID and web link.
|
|
317
|
+
"""
|
|
318
|
+
lc = ctx.request_context.lifespan_context
|
|
319
|
+
docs_service = lc.docs_service
|
|
320
|
+
drive_service = lc.drive_service
|
|
321
|
+
|
|
322
|
+
doc = docs_service.documents().get(documentId=doc_id).execute()
|
|
323
|
+
body_content = doc.get("body", {}).get("content", [])
|
|
324
|
+
end_index = body_content[-1].get("endIndex", 2) if body_content else 2
|
|
325
|
+
|
|
326
|
+
clear_requests = []
|
|
327
|
+
if end_index > 2:
|
|
328
|
+
clear_requests.append(
|
|
329
|
+
{"deleteContentRange": {"range": {"startIndex": 1, "endIndex": end_index - 1}}}
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
content_requests, tables = _to_doc_requests(content, content_format, start_index=1)
|
|
333
|
+
all_requests = clear_requests + content_requests
|
|
334
|
+
if all_requests:
|
|
335
|
+
docs_service.documents().batchUpdate(
|
|
336
|
+
documentId=doc_id, body={"requests": all_requests}
|
|
337
|
+
).execute()
|
|
338
|
+
fill_tables(docs_service, doc_id, tables)
|
|
339
|
+
|
|
340
|
+
metadata = (
|
|
341
|
+
drive_service.files()
|
|
342
|
+
.get(fileId=doc_id, fields="webViewLink", supportsAllDrives=True)
|
|
343
|
+
.execute()
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
lc.doc_cache.mark_dirty(doc_id)
|
|
347
|
+
logger.debug("Wrote content to doc %s", doc_id)
|
|
348
|
+
return {"docId": doc_id, "web_link": metadata.get("webViewLink")}
|
|
349
|
+
|
|
350
|
+
@tool(annotations=ToolAnnotations(title="Get Document Structure", readOnlyHint=True))
|
|
351
|
+
def get_doc_structure(doc_id: str, ctx: Context = None) -> dict[str, Any]:
|
|
352
|
+
"""
|
|
353
|
+
Return the full structural map of a Google Doc body with element indices.
|
|
354
|
+
|
|
355
|
+
Every batchUpdate operation (insert, delete, style, table) requires knowing
|
|
356
|
+
the startIndex and endIndex of the target element. This tool exposes those
|
|
357
|
+
indices alongside paragraph styles, text run formatting, and table cell positions.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
doc_id: The Google Doc file ID.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Dictionary with docId, title, and an elements list. Each element has:
|
|
364
|
+
- type: "paragraph" | "table" | "sectionBreak" | "tableOfContents"
|
|
365
|
+
- startIndex, endIndex
|
|
366
|
+
Paragraphs also include namedStyleType, text, and a runs list (each run
|
|
367
|
+
has text, bold, italic, underline, strikethrough, font_size, link_url).
|
|
368
|
+
Tables include rows, columns, and a cells list (each cell has row, col,
|
|
369
|
+
startIndex, endIndex, paragraphStartIndex, text).
|
|
370
|
+
"""
|
|
371
|
+
lc = ctx.request_context.lifespan_context
|
|
372
|
+
try:
|
|
373
|
+
doc = lc.docs_service.documents().get(documentId=doc_id).execute()
|
|
374
|
+
except Exception as e:
|
|
375
|
+
return {"error": str(e)}
|
|
376
|
+
|
|
377
|
+
elements = []
|
|
378
|
+
for elem in doc.get("body", {}).get("content", []):
|
|
379
|
+
if "paragraph" in elem:
|
|
380
|
+
para = elem["paragraph"]
|
|
381
|
+
named_style = para.get("paragraphStyle", {}).get("namedStyleType", "NORMAL_TEXT")
|
|
382
|
+
runs = []
|
|
383
|
+
for pe in para.get("elements", []):
|
|
384
|
+
tr = pe.get("textRun")
|
|
385
|
+
if not tr:
|
|
386
|
+
continue
|
|
387
|
+
ts = tr.get("textStyle", {})
|
|
388
|
+
runs.append(
|
|
389
|
+
{
|
|
390
|
+
"text": tr.get("content", ""),
|
|
391
|
+
"bold": ts.get("bold"),
|
|
392
|
+
"italic": ts.get("italic"),
|
|
393
|
+
"underline": ts.get("underline"),
|
|
394
|
+
"strikethrough": ts.get("strikethrough"),
|
|
395
|
+
"font_size": ts["fontSize"].get("magnitude")
|
|
396
|
+
if "fontSize" in ts
|
|
397
|
+
else None,
|
|
398
|
+
"link_url": ts["link"].get("url") if "link" in ts else None,
|
|
399
|
+
}
|
|
400
|
+
)
|
|
401
|
+
elements.append(
|
|
402
|
+
{
|
|
403
|
+
"type": "paragraph",
|
|
404
|
+
"startIndex": elem.get("startIndex", 0),
|
|
405
|
+
"endIndex": elem.get("endIndex", 0),
|
|
406
|
+
"namedStyleType": named_style,
|
|
407
|
+
"text": "".join(r["text"] for r in runs),
|
|
408
|
+
"runs": runs,
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
elif "table" in elem:
|
|
412
|
+
table = elem["table"]
|
|
413
|
+
cells = []
|
|
414
|
+
for r, row in enumerate(table.get("tableRows", [])):
|
|
415
|
+
for c, cell in enumerate(row.get("tableCells", [])):
|
|
416
|
+
content = cell.get("content", [])
|
|
417
|
+
para_start = content[0].get("startIndex") if content else None
|
|
418
|
+
cell_text = "".join(
|
|
419
|
+
pe.get("textRun", {}).get("content", "")
|
|
420
|
+
for ce in content
|
|
421
|
+
if "paragraph" in ce
|
|
422
|
+
for pe in ce["paragraph"].get("elements", [])
|
|
423
|
+
)
|
|
424
|
+
cells.append(
|
|
425
|
+
{
|
|
426
|
+
"row": r,
|
|
427
|
+
"col": c,
|
|
428
|
+
"startIndex": cell.get("startIndex"),
|
|
429
|
+
"endIndex": cell.get("endIndex"),
|
|
430
|
+
"paragraphStartIndex": para_start,
|
|
431
|
+
"text": cell_text.strip(),
|
|
432
|
+
}
|
|
433
|
+
)
|
|
434
|
+
elements.append(
|
|
435
|
+
{
|
|
436
|
+
"type": "table",
|
|
437
|
+
"startIndex": elem.get("startIndex", 0),
|
|
438
|
+
"endIndex": elem.get("endIndex", 0),
|
|
439
|
+
"rows": table.get("rows", 0),
|
|
440
|
+
"columns": table.get("columns", 0),
|
|
441
|
+
"cells": cells,
|
|
442
|
+
}
|
|
443
|
+
)
|
|
444
|
+
elif "sectionBreak" in elem:
|
|
445
|
+
elements.append(
|
|
446
|
+
{
|
|
447
|
+
"type": "sectionBreak",
|
|
448
|
+
"startIndex": elem.get("startIndex", 0),
|
|
449
|
+
"endIndex": elem.get("endIndex", 0),
|
|
450
|
+
}
|
|
451
|
+
)
|
|
452
|
+
elif "tableOfContents" in elem:
|
|
453
|
+
elements.append(
|
|
454
|
+
{
|
|
455
|
+
"type": "tableOfContents",
|
|
456
|
+
"startIndex": elem.get("startIndex", 0),
|
|
457
|
+
"endIndex": elem.get("endIndex", 0),
|
|
458
|
+
}
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
logger.debug("get_doc_structure: %d elements in doc %s", len(elements), doc_id)
|
|
462
|
+
return {
|
|
463
|
+
"docId": doc.get("documentId"),
|
|
464
|
+
"title": doc.get("title"),
|
|
465
|
+
"elements": elements,
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
@tool(annotations=ToolAnnotations(title="Insert Document Text", destructiveHint=True))
|
|
469
|
+
def insert_doc_text(
|
|
470
|
+
doc_id: str,
|
|
471
|
+
insertions: list[dict],
|
|
472
|
+
ctx: Context = None,
|
|
473
|
+
) -> dict[str, Any]:
|
|
474
|
+
"""
|
|
475
|
+
Insert text at one or more positions in a Google Doc.
|
|
476
|
+
|
|
477
|
+
All indices are interpreted as positions in the document before any of
|
|
478
|
+
the insertions in this call. Insertions are applied high-index-first so
|
|
479
|
+
earlier indices are not shifted by later ones.
|
|
480
|
+
|
|
481
|
+
Use get_doc_structure to obtain the correct target indices.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
doc_id: The Google Doc file ID.
|
|
485
|
+
insertions: List of insertion dicts. Required keys:
|
|
486
|
+
index (int): document position to insert at.
|
|
487
|
+
text (str): text to insert. "\\n" creates a new paragraph.
|
|
488
|
+
Optional keys:
|
|
489
|
+
segment_id (str): segmentId of the header or footer to insert into
|
|
490
|
+
(the headerId / footerId returned by create_header / create_footer).
|
|
491
|
+
Omit to insert into the document body.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Confirmation with docId and count of insertions applied.
|
|
495
|
+
"""
|
|
496
|
+
lc = ctx.request_context.lifespan_context
|
|
497
|
+
if not insertions:
|
|
498
|
+
return {"error": "insertions list is empty"}
|
|
499
|
+
try:
|
|
500
|
+
sorted_ops = sorted(insertions, key=lambda x: x["index"], reverse=True)
|
|
501
|
+
requests = []
|
|
502
|
+
for op in sorted_ops:
|
|
503
|
+
location: dict[str, Any] = {"index": op["index"]}
|
|
504
|
+
if "segment_id" in op:
|
|
505
|
+
location["segmentId"] = op["segment_id"]
|
|
506
|
+
requests.append({"insertText": {"location": location, "text": op["text"]}})
|
|
507
|
+
lc.docs_service.documents().batchUpdate(
|
|
508
|
+
documentId=doc_id, body={"requests": requests}
|
|
509
|
+
).execute()
|
|
510
|
+
except Exception as e:
|
|
511
|
+
return {"error": str(e)}
|
|
512
|
+
lc.doc_cache.mark_dirty(doc_id)
|
|
513
|
+
logger.debug("insert_doc_text: %d insertions in doc %s", len(requests), doc_id)
|
|
514
|
+
return {"docId": doc_id, "insertions": len(requests)}
|
|
515
|
+
|
|
516
|
+
@tool(annotations=ToolAnnotations(title="Delete Document Range", destructiveHint=True))
|
|
517
|
+
def delete_doc_range(
|
|
518
|
+
doc_id: str,
|
|
519
|
+
deletions: list[dict],
|
|
520
|
+
ctx: Context = None,
|
|
521
|
+
) -> dict[str, Any]:
|
|
522
|
+
"""
|
|
523
|
+
Delete one or more content ranges from a Google Doc.
|
|
524
|
+
|
|
525
|
+
All indices are interpreted as positions in the document before any of
|
|
526
|
+
the deletions in this call. Deletions are applied high-index-first so
|
|
527
|
+
earlier indices are not shifted by later ones.
|
|
528
|
+
|
|
529
|
+
Use get_doc_structure to obtain the correct start/end indices.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
doc_id: The Google Doc file ID.
|
|
533
|
+
deletions: List of {"start_index": int, "end_index": int}.
|
|
534
|
+
The range [start_index, end_index) is deleted (end_index is exclusive).
|
|
535
|
+
Do not include the document's final newline (last endIndex - 1).
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
Confirmation with docId and count of deletions applied.
|
|
539
|
+
"""
|
|
540
|
+
lc = ctx.request_context.lifespan_context
|
|
541
|
+
if not deletions:
|
|
542
|
+
return {"error": "deletions list is empty"}
|
|
543
|
+
try:
|
|
544
|
+
sorted_ops = sorted(deletions, key=lambda x: x["start_index"], reverse=True)
|
|
545
|
+
requests = [
|
|
546
|
+
{
|
|
547
|
+
"deleteContentRange": {
|
|
548
|
+
"range": {
|
|
549
|
+
"startIndex": op["start_index"],
|
|
550
|
+
"endIndex": op["end_index"],
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
for op in sorted_ops
|
|
555
|
+
]
|
|
556
|
+
lc.docs_service.documents().batchUpdate(
|
|
557
|
+
documentId=doc_id, body={"requests": requests}
|
|
558
|
+
).execute()
|
|
559
|
+
except Exception as e:
|
|
560
|
+
return {"error": str(e)}
|
|
561
|
+
lc.doc_cache.mark_dirty(doc_id)
|
|
562
|
+
logger.debug("delete_doc_range: %d deletions in doc %s", len(requests), doc_id)
|
|
563
|
+
return {"docId": doc_id, "deletions": len(requests)}
|
|
564
|
+
|
|
565
|
+
@tool(annotations=ToolAnnotations(title="Insert Inline Image", destructiveHint=True))
|
|
566
|
+
def insert_inline_image(
|
|
567
|
+
doc_id: str,
|
|
568
|
+
index: int,
|
|
569
|
+
uri: str | None = None,
|
|
570
|
+
drive_file_id: str | None = None,
|
|
571
|
+
width: float | None = None,
|
|
572
|
+
height: float | None = None,
|
|
573
|
+
ctx: Context = None,
|
|
574
|
+
) -> dict[str, Any]:
|
|
575
|
+
"""
|
|
576
|
+
Insert an inline image at a specific index in a Google Doc.
|
|
577
|
+
|
|
578
|
+
Provide either uri (a publicly accessible HTTPS image URL) or drive_file_id
|
|
579
|
+
(a Drive file ID for an image stored in Drive). The image is inserted at the
|
|
580
|
+
given document index.
|
|
581
|
+
|
|
582
|
+
Use get_doc_structure to find a suitable insertion index.
|
|
583
|
+
|
|
584
|
+
Args:
|
|
585
|
+
doc_id: The Google Doc file ID.
|
|
586
|
+
index: Document body index where the image should be inserted.
|
|
587
|
+
uri: A publicly accessible image URI (HTTPS). Mutually exclusive with drive_file_id.
|
|
588
|
+
drive_file_id: A Google Drive file ID for an image stored in Drive.
|
|
589
|
+
The file must be accessible by the authenticated user.
|
|
590
|
+
Mutually exclusive with uri.
|
|
591
|
+
width: Optional image width in points.
|
|
592
|
+
height: Optional image height in points.
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
Confirmation with docId and the insertion index.
|
|
596
|
+
"""
|
|
597
|
+
if not uri and not drive_file_id:
|
|
598
|
+
return {"error": "Provide either uri or drive_file_id"}
|
|
599
|
+
if uri and drive_file_id:
|
|
600
|
+
return {"error": "Provide only one of uri or drive_file_id, not both"}
|
|
601
|
+
|
|
602
|
+
lc = ctx.request_context.lifespan_context
|
|
603
|
+
|
|
604
|
+
if drive_file_id:
|
|
605
|
+
try:
|
|
606
|
+
metadata = (
|
|
607
|
+
lc.drive_service.files()
|
|
608
|
+
.get(fileId=drive_file_id, fields="webContentLink", supportsAllDrives=True)
|
|
609
|
+
.execute()
|
|
610
|
+
)
|
|
611
|
+
uri = metadata.get("webContentLink")
|
|
612
|
+
if not uri:
|
|
613
|
+
return {"error": f"Could not get download link for Drive file {drive_file_id}"}
|
|
614
|
+
except Exception as e:
|
|
615
|
+
return {"error": f"Failed to get Drive file metadata: {e}"}
|
|
616
|
+
|
|
617
|
+
image_request: dict[str, Any] = {
|
|
618
|
+
"insertInlineImage": {
|
|
619
|
+
"location": {"index": index},
|
|
620
|
+
"uri": uri,
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if width is not None or height is not None:
|
|
624
|
+
object_size: dict[str, Any] = {}
|
|
625
|
+
if width is not None:
|
|
626
|
+
object_size["width"] = {"magnitude": width, "unit": "PT"}
|
|
627
|
+
if height is not None:
|
|
628
|
+
object_size["height"] = {"magnitude": height, "unit": "PT"}
|
|
629
|
+
image_request["insertInlineImage"]["objectSize"] = object_size
|
|
630
|
+
|
|
631
|
+
try:
|
|
632
|
+
lc.docs_service.documents().batchUpdate(
|
|
633
|
+
documentId=doc_id, body={"requests": [image_request]}
|
|
634
|
+
).execute()
|
|
635
|
+
except Exception as e:
|
|
636
|
+
return {"error": str(e)}
|
|
637
|
+
|
|
638
|
+
lc.doc_cache.mark_dirty(doc_id)
|
|
639
|
+
logger.debug("insert_inline_image: at index %d in doc %s", index, doc_id)
|
|
640
|
+
return {"docId": doc_id, "index": index}
|