mcp-gee-sweet 0.9.2.dev315__tar.gz → 0.9.2.dev321__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.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/PKG-INFO +1 -1
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/doc-to-markdown.md +3 -2
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/image-conversion.md +33 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/markdown-support.md +1 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/docs_content.md +58 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/roadmap.md +4 -3
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/ast.py +2 -2
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/content.py +34 -7
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/emitter.py +66 -18
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/html_parser.py +51 -13
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_content.py +173 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_core.py +153 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/uv.lock +22 -22
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/cleanup-worktrees.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/merge-pr.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/notify-kai.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/notify-partner.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/orchestrator.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/prep-for-pr.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/qa-kickoff.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/release.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/retro.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/roadmap-status.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/start-worktree.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/team-member.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/verify-pr.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/settings.json +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/amy.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/aziz.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/bob.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/dev.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/joy.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/kai.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/qa.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.dockerignore +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/dependabot.yml +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/workflows/ci.yml +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/workflows/docs.yml +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/workflows/publish-dev.yml +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/workflows/release.yml +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.gitignore +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.pre-commit-config.yaml +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.python-version +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/CHANGELOG.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/CLAUDE.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/CODE_OF_CONDUCT.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/CONTRIBUTING.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/Dockerfile +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/LICENSE +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/Makefile +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/README.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/SECURITY.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docker-compose.yml +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/assets/logo.png +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/auth.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/client-setup.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/configuration.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-async-tool-execution.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-auth-status-limitation-categories.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-cache-invalidation.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-chart-theming.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-comments-first-class.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-composite-tools.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-condition-type-docstring-generation.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-converted-doc-change-detection.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-dev-team.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-docs-formatting.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-fork.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-gmail-domain.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-grid-data-size-cap.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-local-fs-test-harness.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-mcp-v2-resource-context-access.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-pillow-image-dependency.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-prompt-qa-role.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-publishing.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-release-docs-roles.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-repositioning.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-response-size-cap-generalization.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-response-size-cap-reevaluation-519.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-review-finding-ticket-bar.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-supports-all-drives-lint-check.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-sync-folder-recursion.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-testing.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-tool-error-visibility.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/index.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/role-file-history.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/blockquote-representation.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/borderless-table-columns.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/doc-revision-export.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/docs-ast-pipeline.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/effectiveformat-spike.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/heading-anchor-resolution.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/index.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/local-file-access-policy.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/module-history.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/oauth-reauthorize-from-tool-call.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/phase3-theme-system.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/sse-consent-off-event-loop.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/index.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/known-limitations.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/notes-read.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/README.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/qa-fixture-pixel.png +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-d195-create-doc.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-d196-create-doc.html +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-d213-dollar-escape.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-d226-heading-table.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc102-image-conversion.html +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc103-image-conversion.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc104-nested-lists.html +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc105-nested-lists-gfm.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc106-nested-lists-4space.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc135-paragraph-boundary.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc136-interrupted-block-boundary.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc137-inline-hr-no-block-ancestor.html +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc138-nbsp-spacer-paragraph.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc141-mismatched-list-tags.html +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc142-heading-anchors.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc144-anchor-in-table-cell.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc145-anchor-on-first-element.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures.template.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/gmail-test-plan.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/operations.yaml +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/playwright_oauth.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/.gitkeep +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-06-02.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-06-14.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-06-17.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-06-18.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-06-21-drive-discovery.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-06-21-sheets-delete-clear.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-06-28.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-07-04-docs.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-07-04-drive-smoke.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-07-04-infra.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-07-04-sheets_read.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-07-04.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-09-04.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/retro-v0.8.0.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/retro-v0.9.0.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/run.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/runs/README.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/runs/v0.8.0.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/runs/v0.8.1.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/runs/v0.9.0.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/setup.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/calendar.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/docs_layout.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/docs_style.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/docs_tables.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/drive_activity.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/drive_files.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/drive_sharing.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/drive_transfer.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/gmail.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/infra.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/sheets_charts.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/sheets_mgmt.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/sheets_read.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/sheets_write.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/style-guide.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/tools.md +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/mkdocs.yml +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/pyproject.toml +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/pyrightconfig.json +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/check_supports_all_drives.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/gen_tool_docs.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/lane_context_hook.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/oauth_setup.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/qa_cancel_insert_local_images.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/qa_gmail_fixtures.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/setup_team.sh +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/write_team_mcp_configs.sh +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/server.json +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/.env.template +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/__init__.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/auth.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/cache.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/http_transport.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/reauth.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/server.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/sse_shutdown.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/__init__.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/atomic_write.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/cache.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/calendar.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/concurrency.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/__init__.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/anchors.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/ast_to_markdown.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/comments.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/doc_to_ast.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/editing.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/images.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/indices.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/layout.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/markdown_export.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/named_ranges.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/style.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/tables.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/drive/__init__.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/drive/activity.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/drive/files.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/drive/sharing.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/drive/transfer.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/gmail.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/pagination.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/response_limits.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/sheets/__init__.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/sheets/data.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/sheets/helpers.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/sheets/structure.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/__init__.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/conftest.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/drive/__init__.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/drive/test_activity.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/drive/test_files.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/drive/test_sharing.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/drive/test_transfer.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/fakes.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/integration/__init__.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/integration/conftest.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/integration/test_local_fs_harness.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/sheets/__init__.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/sheets/test_data.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/sheets/test_helpers.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/sheets/test_structure.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_atomic_write.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_auth.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_cache.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_calendar.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_check_supports_all_drives.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_concurrency.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_anchors.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_comments.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_editing.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_images.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_indices.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_layout.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_markdown_export.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_named_ranges.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_revision_export.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_style.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_tables.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_gen_tool_docs.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_gmail.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_lane_context_hook.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_qa_gmail_fixtures.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_reauth.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_response_limits.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_server.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_server_json.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_sse_shutdown.py +0 -0
- {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_team_mcp_configs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mcp-gee-sweet
|
|
3
|
-
Version: 0.9.2.
|
|
3
|
+
Version: 0.9.2.dev321
|
|
4
4
|
Summary: MCP server for Google Workspace — Sheets, Drive, Docs (HTML or Markdown), and Calendar.
|
|
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
|
|
@@ -128,8 +128,9 @@ Three gaps were kept **as documented limitations, not implementation shortcuts**
|
|
|
128
128
|
- **Linked images** (#951): Docs puts an image's link on its `inlineObjectElement`'s own
|
|
129
129
|
`textStyle.link`, the same as a text run (confirmed live). `doc_to_ast` reads it into
|
|
130
130
|
`Image.link_url`, and every image renders through `_render_image` as `[](url)`, in
|
|
131
|
-
ordinary text, a code paragraph, and a table cell alike. The write side
|
|
132
|
-
|
|
131
|
+
ordinary text, a code paragraph, and a table cell alike. The write side sets it too (#977;
|
|
132
|
+
see [image-conversion.md](image-conversion.md#linked-images-977)), so a linked image
|
|
133
|
+
round-trips.
|
|
133
134
|
- **Same-document links** (PR #976 QA round 1): Docs reports a link to a heading or bookmark by
|
|
134
135
|
ID with no `url` (confirmed live for a heading: `{"headingId": "h.x"}`, on a text run and an
|
|
135
136
|
image alike). `doc_to_ast._link_url`, shared by both, rebuilds it as
|
|
@@ -103,6 +103,39 @@ need K retries. Resending the whole document each time would be K+1 full-size ro
|
|
|
103
103
|
Confirmed live that the split produces a document structurally identical to the old
|
|
104
104
|
whole-list retry (text, bullets, table, and the surviving image all in place).
|
|
105
105
|
|
|
106
|
+
## Linked images (#977)
|
|
107
|
+
|
|
108
|
+
An `<img>` inside `<a href>` (markdown `[](url)`) keeps its link: `html_parser.py`
|
|
109
|
+
copies the open `<a>`'s href onto `Image.link_url`. That includes raw HTML with no enclosing
|
|
110
|
+
block: a top-level `<img>` gets an implicit paragraph even when it's wrapped in inline tags (unlike
|
|
111
|
+
bare text or `<hr>` in that position), and reads its link from `_bare_link_url`, a stack separate
|
|
112
|
+
from `_link_url` so text handling and an unclosed top-level `<a>` are unaffected. Each open `<a>`
|
|
113
|
+
records which of the two stacks it pushed onto, or that it pushed nothing (`_anchor_pushes`), and
|
|
114
|
+
its `</a>` pops only that: an href-less `<a name>` nested in a linked anchor used to pop the outer
|
|
115
|
+
link (#994, PR #992 QA round 2). Docs stores an image's link on its
|
|
116
|
+
`inlineObjectElement`'s own `textStyle.link`, so `emitter.py` follows the image's
|
|
117
|
+
`insertInlineImage` with an `updateTextStyle` over its one-character range
|
|
118
|
+
`[position, position + 1)`. The link has to come right after its own insert, inside the
|
|
119
|
+
descending-position tail: at that point nothing before `position` has shifted yet. An inserted
|
|
120
|
+
image never inherits a neighboring run's link (confirmed live, both directions), so an unlinked
|
|
121
|
+
image gets no clearing request.
|
|
122
|
+
|
|
123
|
+
Inserts that share a position, such as adjacent images with nothing between them, each land in
|
|
124
|
+
front of whatever is already there, so the later one in the document has to be inserted first.
|
|
125
|
+
The descending pass sorts on `(position, node order, item order)`, all descending; sorting by
|
|
126
|
+
position alone kept source order within a tie and wrote adjacent images reversed (PR #992 QA
|
|
127
|
+
round 1). A table and the image right after it share a position the same way, and still come out
|
|
128
|
+
table first.
|
|
129
|
+
|
|
130
|
+
The retry treats the pair as one unit. `ast_to_requests(image_links=...)` records each
|
|
131
|
+
`id(insert request) -> link request` as it builds them, and `_apply_doc_content` uses that record
|
|
132
|
+
rather than matching request shapes. `_drop_failed_image` pops the link request with its failed
|
|
133
|
+
insert, since a stranded link would land on whatever character now sits at that position.
|
|
134
|
+
`_positional_insert_tail_start` counts a recorded link request as part of the tail, so the K≥2
|
|
135
|
+
split still applies. A `#slug` image link goes
|
|
136
|
+
through the heading-anchor pass like a text link: `_walk_headings_and_anchor_runs` reads the
|
|
137
|
+
`inlineObjectElement`'s link too, so it's resolved or stripped.
|
|
138
|
+
|
|
106
139
|
## Known, deliberate gap: table-cell images
|
|
107
140
|
|
|
108
141
|
`html_parser.py`'s img-handling only fires when a block context is open at the body level
|
|
@@ -173,6 +173,7 @@ another registered tool), passing `content_format` through to `_to_doc_requests`
|
|
|
173
173
|
| `~~strikethrough~~` | `<del>` | `Run(strikethrough=True)` | strikethrough |
|
|
174
174
|
| `[text](url)` | `<a href=...>` | `Run(link_url=...)` | hyperlink |
|
|
175
175
|
| bare `https://url` | `<a href=...>` (autolink extension) | `Run(link_url=...)` | hyperlink — disable via `autolink_urls=False` |
|
|
176
|
+
| `[](url)` | `<a href=...><img ...></a>` | `Image(link_url=...)` | linked inline image (#977) |
|
|
176
177
|
| `- item` | `<ul><li>` | `BulletItem(ordered=False)` | disc bullet list |
|
|
177
178
|
| `1. item` | `<ol><li>` | `BulletItem(ordered=True)` | numbered list |
|
|
178
179
|
| `- [x] item` | `<input type="checkbox" checked>` | `BulletItem(checked=True)` | bullet with a `☑`/`☐` glyph prefix |
|
|
@@ -3716,6 +3716,64 @@ Not covered live: a soft break (Shift+Enter) inside an inline code run in a para
|
|
|
3716
3716
|
|
|
3717
3717
|
**Result (2026-10-10) ✅ PASS — Kit, PR #981 round 2 (`b2bfafe`).** Ran on a scratch doc (created with `create_doc` from the step 1 HTML, trashed afterwards) instead of `{DOC_ID}`. Step 2 used one `find_in_doc(regex=True)` call for all three queries: matches at 4–11, 15–20 and 26–39. After step 3, `get_doc_structure` showed each of the first three paragraphs as two runs split at the `<`/`&` (`a <` | `b> text`, `AT&` | `amp;T`, `see <` | `https://x.io>`). `get_doc_as_markdown` returned `a \<b> text`, `AT\&T`, `see \<https://x.io>`, and `a < b and AT&T` unchanged. markdown-it-py `commonmark` rendered `a <b> text`, `AT&amp;T`, `see <https://x.io>`, and `a < b and AT&T`, with no `<b>` and no `<a>`. Round 1 got `a <b> text` and `AT&T` unescaped from the same kind of split, on pre-fix code (`307e47c`).
|
|
3718
3718
|
|
|
3719
|
+
|
|
3720
|
+
### TC-DOC216: A linked image written as Markdown or HTML keeps its link and round-trips (#977) ⚠️ requires-oauth
|
|
3721
|
+
**Background:** the write side built a linked image with no link, so `[](url)` and `<a href><img></a>` imported as a plain image and didn't round-trip with TC-DOC209's export. The image now gets its link through an `updateTextStyle` over its one-character range, right after its insert. That request has to stay paired with the image when an unreachable image is dropped and retried, and a `#slug` image link goes through the heading-anchor pass.
|
|
3722
|
+
|
|
3723
|
+
**Setup:** none. Each step uses a scratch doc.
|
|
3724
|
+
|
|
3725
|
+
**Prompt**
|
|
3726
|
+
**Playwright: required** (each linked image is clickable to its URL in the rendered doc; the unlinked one isn't)
|
|
3727
|
+
1. `create_doc(title="TC-DOC216 md", folder_id={FOLDER_ID}, content_format='markdown', content=...)` with:
|
|
3728
|
+
```markdown
|
|
3729
|
+
# Overview
|
|
3730
|
+
|
|
3731
|
+
Before [](https://example.com/one) and [linked text](https://example.com/t)
|
|
3732
|
+
|
|
3733
|
+
Jump: [](#overview) and [](#missing)
|
|
3734
|
+
|
|
3735
|
+
| a | b |
|
|
3736
|
+
|---|---|
|
|
3737
|
+
| 1 | 2 |
|
|
3738
|
+
|
|
3739
|
+
[](https://example.com/after-table)
|
|
3740
|
+
```
|
|
3741
|
+
2. `get_doc_as_markdown(doc_id=<step 1 docId>)`
|
|
3742
|
+
3. `create_doc(title="TC-DOC216 retry", folder_id={FOLDER_ID}, content_format='html', content=...)` with:
|
|
3743
|
+
```html
|
|
3744
|
+
<p><a href="https://example.com/b1"><img src="https://example.invalid/nope.png?1"></a></p><p>x <a href="https://example.com/good"><img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png"></a> y</p><p><a href="https://example.com/b2"><img src="https://example.invalid/nope.png?2"></a></p>
|
|
3745
|
+
```
|
|
3746
|
+
4. `get_doc_as_markdown(doc_id=<step 3 docId>)`
|
|
3747
|
+
5. `create_doc(title="TC-DOC216 adjacent", folder_id={FOLDER_ID}, content_format='html', content=...)` with (raw HTML, the first image wrapped in a top-level `<a>` with no `<p>`):
|
|
3748
|
+
```html
|
|
3749
|
+
<a href="https://example.com/top"><img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" width="100"></a><p>x <a href="https://example.com/first"><img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" width="40"></a><a href="https://example.com/second"><img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" width="80"></a><img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" width="120"> y</p>
|
|
3750
|
+
```
|
|
3751
|
+
6. `get_doc_as_markdown(doc_id=<step 5 docId>)`
|
|
3752
|
+
7. `create_doc(title="TC-DOC216 nested anchor", folder_id={FOLDER_ID}, content_format='html', content=...)` with an href-less `<a name>` nested inside each linked anchor (#994):
|
|
3753
|
+
```html
|
|
3754
|
+
<a href="https://example.com/bare"><a name="n1"></a><img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png"></a><p><a href="https://example.com/block"><a name="n2"></a>tail<img src="https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png"></a> after</p>
|
|
3755
|
+
```
|
|
3756
|
+
8. `get_doc_as_markdown(doc_id=<step 7 docId>)`
|
|
3757
|
+
|
|
3758
|
+
**Checks**
|
|
3759
|
+
- Step 2: `[](https://example.com/one)`
|
|
3760
|
+
- Step 2: `[linked text](https://example.com/t)` is followed by an unlinked ``, not one inside the link
|
|
3761
|
+
- Step 2: image `A` links to `https://docs.google.com/document/d/<docId>/edit?tab=t.0#heading=<headingId>`, the `Overview` heading. Image `B` is unlinked, its `#missing` link stripped
|
|
3762
|
+
- Step 2: the image after the table is `[](https://example.com/after-table)`
|
|
3763
|
+
- Step 3: `images` reports an `error` for both `example.invalid` sources and none for the Google logo
|
|
3764
|
+
- Step 4: the only image is `[](https://example.com/good)`, between `x` and `y`. No text character carries `b1`'s or `b2`'s link
|
|
3765
|
+
- Step 6: the first paragraph is the top-level image alone, as `[](https://example.com/top)`
|
|
3766
|
+
- Step 6: the second paragraph is `x `, `[](https://example.com/first)`, `[](https://example.com/second)`, an unlinked ``, then ` y`, in that order. Playwright: in the step 5 doc's editor the three inline images get wider left to right (40, 80, 120px source widths), which confirms the order independently of the links
|
|
3767
|
+
- Step 8: the first paragraph is `[](https://example.com/bare)`. The second is `[tail](https://example.com/block)`, then `[](https://example.com/block)`, then an unlinked ` after`: the nested `<a name>` doesn't end the outer link
|
|
3768
|
+
|
|
3769
|
+
**Cleanup:** `delete_file` all four scratch docs
|
|
3770
|
+
|
|
3771
|
+
**Result (2026-10-11) ✅ PASS — Sky, PR #992 round 1 (`6aa4dac`).** Step 2: `Logo` came back as `[](https://example.com/one)`. `[linked text](https://example.com/t)` was followed by an unlinked ``. `A` linked to `.../edit?tab=t.0#heading=h.…` (the `Overview` heading), and `B` was unlinked. The image after the table kept `https://example.com/after-table`. Step 3: `images` reported an `error` for both `example.invalid` sources and none for the logo. Step 4: the only image was `[](https://example.com/good)` between `x` and `y`, and no text carried `b1`'s or `b2`'s link. Playwright, clicking each image in the step 1 doc's editor: `Logo` and the after-table image each showed a link chip for `https://example.com/…`, and `A`'s chip showed `Overview`. The unlinked image and `B` showed no link chip. The `/mobilebasic` HTML view isn't a substitute: it renders the text link but never wraps an image in `<a>`. A separate probe, `[](https://example.com/first)[](https://example.com/second)`, exported as `second` then `first`: adjacent images are written in reverse order. Each kept its own link.
|
|
3772
|
+
|
|
3773
|
+
**Result (2026-10-11) ✅ PASS — Sky, PR #992 round 2 (`3a506f4`).** Re-ran step 3 (the retry, now pairing each image with its link by identity) and the new step 5. Step 4: the only image was `[](https://example.com/good)` between `x` and `y`, and no text carried `b1`'s or `b2`'s link. Step 6: the first paragraph was the top-level image alone, as `[](https://example.com/top)`. The second paragraph was `x `, `…(https://example.com/first)`, `…(https://example.com/second)`, an unlinked image, then ` y`. Playwright: in the step 5 doc's editor the three inline images got wider left to right, and the top image's link chip showed `https://example.com/…`. Steps 1–2 weren't re-run: the fix commit doesn't change that path beyond the sort, which step 5 covers.
|
|
3774
|
+
|
|
3775
|
+
**Result (2026-10-11) ✅ PASS — Sky, PR #992 round 3 (`9271dfe`).** Ran the new step 7. Step 8: the first paragraph was `[](https://example.com/bare)`, and the second was `[tail](https://example.com/block)`, then `[](https://example.com/block)`, then an unlinked ` after`. The fix commit only touches `html_parser.py`'s anchor handling, so steps 1–6 weren't re-run.
|
|
3776
|
+
|
|
3719
3777
|
---
|
|
3720
3778
|
|
|
3721
3779
|
## Orphan fileId on inline-image sharing failure (#649)
|
|
@@ -223,9 +223,8 @@ Same shape as v0.8.1 — stabilize on defects that surfaced since v0.9.0 shipped
|
|
|
223
223
|
- [x] `get_doc_as_markdown(local_path=…)` writes the JSON result envelope, not the markdown: `markdown_export.py` routes through the generic JSON-to-disk helper — lane B, after #938 (same read-side area) (PR #982) ([#958](https://github.com/khuisman/mcp-gee-sweet/issues/958))
|
|
224
224
|
- [x] `add_chart`: a cell-to-row (`B2:5`) or row-only (`1:5`) range silently charts one column — `_per_column_ranges` defaults `endColumnIndex` to start+1. Found reviewing #931 — lane A, after #917 (PR #983) ([#954](https://github.com/khuisman/mcp-gee-sweet/issues/954))
|
|
225
225
|
- [x] `_parse_a1_notation` hardening: one compiled grammar, index bounds, validate before the sheet-ID fetch. #931 review follow-ups, no behavior change for valid input — lane A, after #954 (same A1 area); its QA also runs #953's live checks for #931 (folded in) (PR #986) ([#955](https://github.com/khuisman/mcp-gee-sweet/issues/955))
|
|
226
|
-
- [
|
|
227
|
-
- [
|
|
228
|
-
- [ ] Comment quoted text comes back HTML-escaped (`Café`) from `add_doc_comment`, `list_doc_comments`, and `get_doc_as_markdown(include_comments=True)`: `comments.py` passes Drive's escaped `quotedFileContent.value` through unchanged. Found reviewing #958 — lane B, after #959 (defect, next pick) ([#984](https://github.com/khuisman/mcp-gee-sweet/issues/984))
|
|
226
|
+
- [x] Write side drops image links: `[](url)` (or `<a href><img></a>`) imports through `create_doc`/`create_doc_from_file`/`write_doc_content` without its URL, so it doesn't round-trip with #951's export. Found in PR #976 — lane A, after #955 (PR #992) ([#977](https://github.com/khuisman/mcp-gee-sweet/issues/977))
|
|
227
|
+
- [x] Comment quoted text comes back HTML-escaped (`Café`) from `add_doc_comment`, `list_doc_comments`, and `get_doc_as_markdown(include_comments=True)`: `comments.py` passes Drive's escaped `quotedFileContent.value` through unchanged. Found reviewing #958 — lane B, after #959 (defect, next pick) (PR #991) ([#984](https://github.com/khuisman/mcp-gee-sweet/issues/984))
|
|
229
228
|
- [ ] `write_capped_result_to_disk` writes `dir/` as a file when the directory doesn't exist: a trailing separator should mean "directory" (create it, append `default_filename`). Shared by every `local_path` writer. Found in PR #988 — lane B, after #984 (defect) ([#990](https://github.com/khuisman/mcp-gee-sweet/issues/990))
|
|
230
229
|
|
|
231
230
|
**Sheets hardening** _(triaged out of `backlog` 2026-09-11 — see the "Backlog triage" note below)_
|
|
@@ -380,6 +379,8 @@ Follows the design in [`docs/decisions/decision-comments-first-class.md`](decisi
|
|
|
380
379
|
|
|
381
380
|
- [ ] `sync_folder(export_format=...)`: converted Sheets/Slides probably have #814's `modifiedTime` lag and restamp race too. Suspected, not yet observed ([#818](https://github.com/khuisman/mcp-gee-sweet/issues/818))
|
|
382
381
|
- [ ] Add a tool to set (and read back) Google Docs table column widths — `tableColumnProperties` isn't reachable through any tool today ([#809](https://github.com/khuisman/mcp-gee-sweet/issues/809))
|
|
382
|
+
- [ ] `_parse_a1_notation` rejects `A:B5`, which the Sheets values API accepts as `A5:B` — decided 2026-10-11: keep rejecting (a destructive GridRange tool acting on "row 5 onward" is worse than an error); document in the docstring that this is deliberately stricter than Sheets. Found in PR #986 — lane A; moved from v0.9.2 2026-10-11 (docstring only, current behavior is the safe one) ([#987](https://github.com/khuisman/mcp-gee-sweet/issues/987))
|
|
383
|
+
- [x] `html_parser`: the `</a>` of an href-less anchor nested in a linked one pops the outer link, so text or an image after it is written unlinked. Found in PR #992's QA, which sent back the same bug in its new `_bare_link_url` stack; may close with PR #992 — lane A; moved from v0.9.2 2026-10-11 (pre-existing, invalid nested HTML only) (PR #992) ([#994](https://github.com/khuisman/mcp-gee-sweet/issues/994))
|
|
383
384
|
|
|
384
385
|
**Code structure & typing (Joy)** _(slotted into v0.9.2 (now v0.9.3) 2026-09-09 so Joy's architecture initiative carries release deadlines instead of staying open-ended)_
|
|
385
386
|
|
{mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/ast.py
RENAMED
|
@@ -49,8 +49,8 @@ class Image:
|
|
|
49
49
|
alt: str | None = None
|
|
50
50
|
width: float | None = None # pt
|
|
51
51
|
height: float | None = None # pt
|
|
52
|
-
#
|
|
53
|
-
#
|
|
52
|
+
# The image element's own textStyle.link: read by doc_to_ast.py, and written
|
|
53
|
+
# by emitter.py as an updateTextStyle over the image's one-character range (#977).
|
|
54
54
|
link_url: str | None = None
|
|
55
55
|
|
|
56
56
|
|
{mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/content.py
RENAMED
|
@@ -162,12 +162,20 @@ def _failed_insert_image_message(error: HttpError) -> str:
|
|
|
162
162
|
_POSITIONAL_INSERT_KEYS = ("insertTable", "insertInlineImage")
|
|
163
163
|
|
|
164
164
|
|
|
165
|
-
def _positional_insert_tail_start(
|
|
165
|
+
def _positional_insert_tail_start(
|
|
166
|
+
requests: list[dict], image_links: dict[int, dict] | None = None
|
|
167
|
+
) -> int:
|
|
166
168
|
"""Index where ast_to_requests's trailing stage-3 pass (the descending-position
|
|
167
169
|
insertTable + insertInlineImage requests — see its docstring) begins: the start
|
|
168
|
-
of the maximal suffix made up only of those two request kinds
|
|
170
|
+
of the maximal suffix made up only of those two request kinds, plus each linked
|
|
171
|
+
image's own link request (#977). `image_links` is the id(insert) -> link
|
|
172
|
+
request map ast_to_requests filled; a link request counts by identity."""
|
|
173
|
+
link_ids = {id(link) for link in (image_links or {}).values()}
|
|
169
174
|
start = len(requests)
|
|
170
|
-
while start > 0 and
|
|
175
|
+
while start > 0 and (
|
|
176
|
+
id(requests[start - 1]) in link_ids
|
|
177
|
+
or any(key in requests[start - 1] for key in _POSITIONAL_INSERT_KEYS)
|
|
178
|
+
):
|
|
171
179
|
start -= 1
|
|
172
180
|
return start
|
|
173
181
|
|
|
@@ -553,8 +561,9 @@ async def _apply_doc_content(
|
|
|
553
561
|
# (#789): revoking after a failed edit is harmless, since a doc that did embed
|
|
554
562
|
# an image holds its own copy and one that didn't never needed the share.
|
|
555
563
|
try:
|
|
564
|
+
image_links: dict[int, dict] = {}
|
|
556
565
|
content_requests, tables = ast_to_requests(
|
|
557
|
-
nodes, start_index=1, image_uris=placeholder_uris
|
|
566
|
+
nodes, start_index=1, image_uris=placeholder_uris, image_links=image_links
|
|
558
567
|
)
|
|
559
568
|
|
|
560
569
|
# Swap each request's placeholder URI for the real one, recording which
|
|
@@ -626,13 +635,21 @@ async def _apply_doc_content(
|
|
|
626
635
|
|
|
627
636
|
def _drop_failed_image(requests: list[dict], e: HttpError) -> bool:
|
|
628
637
|
"""Pop the insertInlineImage request `e` names from `requests` (the
|
|
629
|
-
batch that raised `e`),
|
|
638
|
+
batch that raised `e`), plus its link request if the image was
|
|
639
|
+
linked (#977), recording the failure on its image's outcome
|
|
630
640
|
entry. False if `e` isn't an image-fetch failure, so the caller
|
|
631
641
|
re-raises."""
|
|
632
642
|
bad_index = _failed_insert_image_request_index(e)
|
|
633
643
|
if bad_index is None or bad_index >= len(requests):
|
|
634
644
|
return False
|
|
635
645
|
bad_request = requests.pop(bad_index)
|
|
646
|
+
# Left in place, the link would land on whatever character now
|
|
647
|
+
# sits at the image's position. Matched by identity: the list
|
|
648
|
+
# holds the same dict objects ast_to_requests recorded.
|
|
649
|
+
link = image_links.get(id(bad_request))
|
|
650
|
+
if link is not None:
|
|
651
|
+
link_index = next(i for i, r in enumerate(requests) if r is link)
|
|
652
|
+
requests.pop(link_index)
|
|
636
653
|
img_id = request_id_to_img_id.get(id(bad_request))
|
|
637
654
|
entry = entry_by_id.get(img_id) if img_id is not None else None
|
|
638
655
|
if entry is not None:
|
|
@@ -660,7 +677,7 @@ async def _apply_doc_content(
|
|
|
660
677
|
image_failures += 1
|
|
661
678
|
if image_failures != 2:
|
|
662
679
|
continue
|
|
663
|
-
tail_start = _positional_insert_tail_start(remaining)
|
|
680
|
+
tail_start = _positional_insert_tail_start(remaining, image_links)
|
|
664
681
|
# Fail-safe: split only if every image request really is in the
|
|
665
682
|
# tail. If ast_to_requests ever emits something after its
|
|
666
683
|
# positional-insert pass, the boundary scan comes up short —
|
|
@@ -728,7 +745,7 @@ def _walk_headings_and_anchor_runs(
|
|
|
728
745
|
) -> tuple[list[tuple[str, str]], list[tuple[str, int, int]]]:
|
|
729
746
|
"""Recursively walk document body content, including table cells,
|
|
730
747
|
collecting (heading_text, heading_id) pairs and (anchor_url, start, end)
|
|
731
|
-
runs for any run whose link_url starts with '#'.
|
|
748
|
+
runs for any run or inline image whose link_url starts with '#'.
|
|
732
749
|
|
|
733
750
|
Mirrors _collect_doc_paragraphs's two correctness properties: it recurses
|
|
734
751
|
into table cells (a heading or an anchor link living inside a table cell
|
|
@@ -767,6 +784,16 @@ def _walk_headings_and_anchor_runs(
|
|
|
767
784
|
end = pe.get("endIndex")
|
|
768
785
|
if end is not None:
|
|
769
786
|
offset = end
|
|
787
|
+
# A linked image carries its link on the inlineObjectElement's
|
|
788
|
+
# own textStyle (#977).
|
|
789
|
+
link_url = (
|
|
790
|
+
pe.get("inlineObjectElement", {})
|
|
791
|
+
.get("textStyle", {})
|
|
792
|
+
.get("link", {})
|
|
793
|
+
.get("url", "")
|
|
794
|
+
)
|
|
795
|
+
if link_url.startswith("#") and end is not None:
|
|
796
|
+
anchor_runs.append((link_url, run_start, end))
|
|
770
797
|
|
|
771
798
|
if is_heading and heading_id:
|
|
772
799
|
headings.append(("".join(text_parts).strip(), heading_id))
|
{mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/emitter.py
RENAMED
|
@@ -23,6 +23,7 @@ class _PendingTable:
|
|
|
23
23
|
node: Table
|
|
24
24
|
position: int # doc index where this table starts, before tab-consumption adjustment
|
|
25
25
|
tab_offset: int # cumulative synthetic bullet-tabs consumed ahead of it by insertTable time
|
|
26
|
+
order: int # index in the top-level node list; breaks same-position ties (see below)
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
@dataclass
|
|
@@ -46,6 +47,7 @@ class _SegmentMeta:
|
|
|
46
47
|
skip_len: int
|
|
47
48
|
tabs_through: int
|
|
48
49
|
runs: list[Run | Image]
|
|
50
|
+
order: int # index in the top-level node list, as _PendingTable.order
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
def _strip_bullet_leading_tabs(runs: list[Run | Image]) -> list[Run | Image]:
|
|
@@ -119,6 +121,8 @@ def ast_to_requests(
|
|
|
119
121
|
nodes: list[DocNode],
|
|
120
122
|
start_index: int = 1,
|
|
121
123
|
image_uris: dict[int, str] | None = None,
|
|
124
|
+
*,
|
|
125
|
+
image_links: dict[int, dict] | None = None,
|
|
122
126
|
) -> tuple[list[dict], list[Table]]:
|
|
123
127
|
"""Convert AST nodes to phase-1 batchUpdate requests and a list of Table nodes.
|
|
124
128
|
|
|
@@ -149,6 +153,11 @@ def ast_to_requests(
|
|
|
149
153
|
insertInlineImage has no notion of "insert later"; the caller is expected to have
|
|
150
154
|
already recorded why that image failed via extract_images's own resolution pass.
|
|
151
155
|
|
|
156
|
+
image_links: if given, filled with id(insertInlineImage request) -> the
|
|
157
|
+
updateTextStyle request that links that image (#977), so a caller that drops a
|
|
158
|
+
failed image request can drop its link request too, by identity rather than by
|
|
159
|
+
matching request shapes.
|
|
160
|
+
|
|
152
161
|
Returns (requests, tables) where tables is the list of Table AST nodes in document
|
|
153
162
|
order.
|
|
154
163
|
"""
|
|
@@ -166,7 +175,7 @@ def ast_to_requests(
|
|
|
166
175
|
pending_tables: list[_PendingTable] = []
|
|
167
176
|
cumulative_tabs = 0
|
|
168
177
|
|
|
169
|
-
for node in nodes:
|
|
178
|
+
for order, node in enumerate(nodes):
|
|
170
179
|
if isinstance(node, Table):
|
|
171
180
|
num_rows = len(node.rows)
|
|
172
181
|
num_cols = max((sum(c.colspan for c in row.cells) for row in node.rows), default=0)
|
|
@@ -176,7 +185,9 @@ def ast_to_requests(
|
|
|
176
185
|
# tables list positionally against the tables actually present in the live
|
|
177
186
|
# doc, and a table with no insertTable request never shows up there.
|
|
178
187
|
continue
|
|
179
|
-
pending_tables.append(
|
|
188
|
+
pending_tables.append(
|
|
189
|
+
_PendingTable(node, start_index + utf16_offset, cumulative_tabs, order)
|
|
190
|
+
)
|
|
180
191
|
else:
|
|
181
192
|
doc_start = start_index + utf16_offset
|
|
182
193
|
# Leading tab characters encode a bullet's nesting depth for
|
|
@@ -278,6 +289,7 @@ def ast_to_requests(
|
|
|
278
289
|
skip_len=utf16_len(tabs) + utf16_len(prefix),
|
|
279
290
|
tabs_through=cumulative_tabs,
|
|
280
291
|
runs=offset_runs,
|
|
292
|
+
order=order,
|
|
281
293
|
)
|
|
282
294
|
)
|
|
283
295
|
|
|
@@ -293,8 +305,10 @@ def ast_to_requests(
|
|
|
293
305
|
# Combined descending-position insertion pass (tables + images, #333): both shift
|
|
294
306
|
# every later, not-yet-processed position, so they must be interleaved by their true
|
|
295
307
|
# final position rather than processed as two separate blocks — see ast_to_requests's
|
|
296
|
-
# docstring. Each entry is (
|
|
297
|
-
|
|
308
|
+
# docstring. Each entry is (sort_key, requests): an insert request, followed for a
|
|
309
|
+
# linked image by the updateTextStyle that links it (#977). sort_key is
|
|
310
|
+
# (final_position, node order, item order) — see the sort at the end.
|
|
311
|
+
positional_inserts: list[tuple[tuple[int, int, int], list[dict]]] = []
|
|
298
312
|
|
|
299
313
|
if full_text:
|
|
300
314
|
requests.append({"insertText": {"location": {"index": start_index}, "text": full_text}})
|
|
@@ -348,13 +362,16 @@ def ast_to_requests(
|
|
|
348
362
|
# many tab characters were stripped but never actually inserted (the
|
|
349
363
|
# regression PR #768's QA round 2 caught in finding 1's own first version).
|
|
350
364
|
offset = seg.skip_len
|
|
351
|
-
for item in seg.runs:
|
|
365
|
+
for item_order, item in enumerate(seg.runs, start=1):
|
|
352
366
|
if isinstance(item, Image):
|
|
353
367
|
uri = image_uris.get(id(item))
|
|
354
368
|
if uri is not None:
|
|
355
369
|
image_position = doc_start + offset - seg.tabs_through
|
|
370
|
+
image_requests = _image_insert_requests(item, uri, image_position)
|
|
371
|
+
if image_links is not None and len(image_requests) == 2:
|
|
372
|
+
image_links[id(image_requests[0])] = image_requests[1]
|
|
356
373
|
positional_inserts.append(
|
|
357
|
-
(image_position,
|
|
374
|
+
((image_position, seg.order, item_order), image_requests)
|
|
358
375
|
)
|
|
359
376
|
continue
|
|
360
377
|
run_len = utf16_len(item.text)
|
|
@@ -454,14 +471,16 @@ def ast_to_requests(
|
|
|
454
471
|
position = pending.position - pending.tab_offset
|
|
455
472
|
positional_inserts.append(
|
|
456
473
|
(
|
|
457
|
-
position,
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
"
|
|
461
|
-
|
|
462
|
-
|
|
474
|
+
(position, pending.order, 0),
|
|
475
|
+
[
|
|
476
|
+
{
|
|
477
|
+
"insertTable": {
|
|
478
|
+
"rows": num_rows,
|
|
479
|
+
"columns": num_cols,
|
|
480
|
+
"location": {"index": position},
|
|
481
|
+
}
|
|
463
482
|
}
|
|
464
|
-
|
|
483
|
+
],
|
|
465
484
|
)
|
|
466
485
|
)
|
|
467
486
|
|
|
@@ -471,8 +490,14 @@ def ast_to_requests(
|
|
|
471
490
|
# and processing this way means an earlier, not-yet-processed insertion's position is
|
|
472
491
|
# never invalidated by a later one's own content (#333 generalizes this from the
|
|
473
492
|
# table-only version of the same pattern).
|
|
474
|
-
|
|
475
|
-
|
|
493
|
+
#
|
|
494
|
+
# Inserts that share a position (adjacent images with no text between them, or a
|
|
495
|
+
# table and the image right after it) are each inserted *in front of* whatever is
|
|
496
|
+
# already at that position, so the later one in the document must go first. The
|
|
497
|
+
# node/item order in the key does that; sorting by position alone kept source order
|
|
498
|
+
# within a tie and wrote adjacent images reversed (PR #992 QA round 1).
|
|
499
|
+
for _, insert_requests in sorted(positional_inserts, key=lambda item: item[0], reverse=True):
|
|
500
|
+
requests.extend(insert_requests)
|
|
476
501
|
|
|
477
502
|
return requests, [pending.node for pending in pending_tables]
|
|
478
503
|
|
|
@@ -510,8 +535,18 @@ def _blockquote_style_request(rng: dict, depth: int) -> dict:
|
|
|
510
535
|
}
|
|
511
536
|
|
|
512
537
|
|
|
513
|
-
def
|
|
514
|
-
"""Build
|
|
538
|
+
def _image_insert_requests(image: Image, uri: str, position: int) -> list[dict]:
|
|
539
|
+
"""Build the insertInlineImage request for an already-resolved image at `position`,
|
|
540
|
+
followed, for a linked image, by the updateTextStyle that links it (#977).
|
|
541
|
+
|
|
542
|
+
Docs stores an image's link on its inlineObjectElement's own textStyle, so the link
|
|
543
|
+
goes over the image's one-character range [position, position + 1). It must run
|
|
544
|
+
right after this insert: the descending-position pass guarantees nothing before
|
|
545
|
+
`position` has shifted yet at that point. An image never inherits a neighboring
|
|
546
|
+
run's link on insert (confirmed live), so an unlinked image needs no clearing
|
|
547
|
+
request. content.py's retry drops the pair together when the insert fails
|
|
548
|
+
(_drop_failed_image, through ast_to_requests's image_links); otherwise the link
|
|
549
|
+
would land on whatever character sits at `position`."""
|
|
515
550
|
request: dict = {"insertInlineImage": {"uri": uri, "location": {"index": position}}}
|
|
516
551
|
if image.width is not None or image.height is not None:
|
|
517
552
|
object_size: dict = {}
|
|
@@ -520,7 +555,20 @@ def _image_insert_request(image: Image, uri: str, position: int) -> dict:
|
|
|
520
555
|
if image.height is not None:
|
|
521
556
|
object_size["height"] = {"magnitude": image.height, "unit": "PT"}
|
|
522
557
|
request["insertInlineImage"]["objectSize"] = object_size
|
|
523
|
-
|
|
558
|
+
if image.link_url is None:
|
|
559
|
+
return [request]
|
|
560
|
+
return [request, _image_link_request(image.link_url, position)]
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _image_link_request(link_url: str, position: int) -> dict:
|
|
564
|
+
"""updateTextStyle linking the image just inserted at `position` (#977)."""
|
|
565
|
+
return {
|
|
566
|
+
"updateTextStyle": {
|
|
567
|
+
"range": {"startIndex": position, "endIndex": position + 1},
|
|
568
|
+
"textStyle": {"link": {"url": link_url}},
|
|
569
|
+
"fields": "link",
|
|
570
|
+
}
|
|
571
|
+
}
|
|
524
572
|
|
|
525
573
|
|
|
526
574
|
def _run_style_requests(run: Run, start: int, end: int) -> list[dict]:
|
|
@@ -138,15 +138,18 @@ def _px_to_pt(value: str) -> float | None:
|
|
|
138
138
|
return None
|
|
139
139
|
|
|
140
140
|
|
|
141
|
-
def _make_image(attr_dict: dict, src: str) -> Image:
|
|
141
|
+
def _make_image(attr_dict: dict, src: str, link_url: str | None = None) -> Image:
|
|
142
142
|
"""Build an Image node from an <img> tag's attributes. width/height are
|
|
143
143
|
only ever present on raw HTML <img> tags (content_format="html") — plain
|
|
144
|
-
markdown "" has no syntax for them, so they're None there.
|
|
144
|
+
markdown "" has no syntax for them, so they're None there.
|
|
145
|
+
link_url is the enclosing <a href>, if any (markdown "[](url)",
|
|
146
|
+
#977)."""
|
|
145
147
|
return Image(
|
|
146
148
|
src=src,
|
|
147
149
|
alt=attr_dict.get("alt") or None,
|
|
148
150
|
width=_px_to_pt(attr_dict.get("width") or ""),
|
|
149
151
|
height=_px_to_pt(attr_dict.get("height") or ""),
|
|
152
|
+
link_url=link_url,
|
|
150
153
|
)
|
|
151
154
|
|
|
152
155
|
|
|
@@ -209,6 +212,17 @@ class _AstParser(HTMLParser):
|
|
|
209
212
|
self._strike_depth = 0
|
|
210
213
|
self._code_depth = 0 # inline <code> nesting; ignored inside <pre>
|
|
211
214
|
self._link_url: list[str] = [] # stack of href values
|
|
215
|
+
# href values of <a> tags opened with no block or table cell around them.
|
|
216
|
+
# Read only by the bare top-level <img> branch (#977), so a top-level
|
|
217
|
+
# "<a href><img></a>" keeps its link; kept apart from _link_url so text
|
|
218
|
+
# handling, and an unclosed top-level <a>, are unaffected.
|
|
219
|
+
self._bare_link_url: list[str] = []
|
|
220
|
+
# One entry per open <a>: which of the two stacks above its start tag
|
|
221
|
+
# pushed an href onto ("block" or "bare"), or None if it pushed nothing
|
|
222
|
+
# (no href, e.g. <a name>). </a> pops only what its own start tag pushed,
|
|
223
|
+
# so an href-less anchor nested in a linked one can't pop the outer
|
|
224
|
+
# link (#994).
|
|
225
|
+
self._anchor_pushes: list[str | None] = []
|
|
212
226
|
|
|
213
227
|
# --- current run buffer ---
|
|
214
228
|
self._run_buf: list[str] = []
|
|
@@ -324,6 +338,10 @@ class _AstParser(HTMLParser):
|
|
|
324
338
|
self._strike_depth = 0
|
|
325
339
|
self._code_depth = 0
|
|
326
340
|
self._link_url = []
|
|
341
|
+
# The cleared hrefs' anchors no longer own an entry to pop.
|
|
342
|
+
self._anchor_pushes = [
|
|
343
|
+
None if pushed == "block" else pushed for pushed in self._anchor_pushes
|
|
344
|
+
]
|
|
327
345
|
|
|
328
346
|
def _emit_block_node(
|
|
329
347
|
self, tag: str, runs: list[Run | Image], *, preserve_if_empty: bool = False
|
|
@@ -686,18 +704,17 @@ class _AstParser(HTMLParser):
|
|
|
686
704
|
# Same rationale as <hr> just above: markdown's own "![]()" syntax
|
|
687
705
|
# always renders wrapped in a <p>, so this only matters for raw HTML
|
|
688
706
|
# (content_format="html") supplying a top-level <img> with no
|
|
689
|
-
# wrapping
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
and self._tag_depth == 0
|
|
695
|
-
):
|
|
707
|
+
# wrapping block at all. Unlike <hr> and bare text, an <img> wrapped
|
|
708
|
+
# only in inline tags still gets its implicit paragraph: a raw-HTML
|
|
709
|
+
# "<a href><img></a>" (a linked badge, say) is real content, and
|
|
710
|
+
# dropping it loses the image along with its link (PR #992 QA round 1).
|
|
711
|
+
if tag == "img" and self._block_tag is None and self._table_depth == 0:
|
|
696
712
|
src = attr_dict.get("src")
|
|
697
713
|
if src:
|
|
714
|
+
link_url = self._bare_link_url[-1] if self._bare_link_url else None
|
|
698
715
|
self._nodes.append(
|
|
699
716
|
Paragraph(
|
|
700
|
-
runs=[_make_image(attr_dict, src)],
|
|
717
|
+
runs=[_make_image(attr_dict, src, link_url)],
|
|
701
718
|
blockquote_depth=self._blockquote_depth,
|
|
702
719
|
)
|
|
703
720
|
)
|
|
@@ -724,6 +741,7 @@ class _AstParser(HTMLParser):
|
|
|
724
741
|
href = attr_dict.get("href", "")
|
|
725
742
|
if href:
|
|
726
743
|
self._link_url.append(href)
|
|
744
|
+
self._anchor_pushes.append("block" if href else None)
|
|
727
745
|
elif tag == "code" and not self._in_pre:
|
|
728
746
|
self._code_depth += 1
|
|
729
747
|
elif tag == "br":
|
|
@@ -739,7 +757,9 @@ class _AstParser(HTMLParser):
|
|
|
739
757
|
# already gets in a cell.
|
|
740
758
|
src = attr_dict.get("src")
|
|
741
759
|
if src:
|
|
742
|
-
self._pending_runs.append(
|
|
760
|
+
self._pending_runs.append(
|
|
761
|
+
_make_image(attr_dict, src, self._link_url[-1] if self._link_url else None)
|
|
762
|
+
)
|
|
743
763
|
else:
|
|
744
764
|
self._block_had_unsupported_content = True
|
|
745
765
|
elif self._block_tag:
|
|
@@ -749,6 +769,11 @@ class _AstParser(HTMLParser):
|
|
|
749
769
|
# silently dropped rather than contributing a run — see
|
|
750
770
|
# self._block_had_unsupported_content.
|
|
751
771
|
self._block_had_unsupported_content = True
|
|
772
|
+
elif tag == "a":
|
|
773
|
+
href = attr_dict.get("href", "") if self._table_depth == 0 else ""
|
|
774
|
+
if href:
|
|
775
|
+
self._bare_link_url.append(href)
|
|
776
|
+
self._anchor_pushes.append("bare" if href else None)
|
|
752
777
|
|
|
753
778
|
def handle_endtag(self, tag):
|
|
754
779
|
# --- table structure ---
|
|
@@ -911,10 +936,23 @@ class _AstParser(HTMLParser):
|
|
|
911
936
|
self._underline_depth = max(0, self._underline_depth - 1)
|
|
912
937
|
elif tag in _INLINE_STRIKE:
|
|
913
938
|
self._strike_depth = max(0, self._strike_depth - 1)
|
|
914
|
-
elif tag == "a"
|
|
915
|
-
self.
|
|
939
|
+
elif tag == "a":
|
|
940
|
+
self._close_anchor()
|
|
916
941
|
elif tag == "code" and not self._in_pre:
|
|
917
942
|
self._code_depth = max(0, self._code_depth - 1)
|
|
943
|
+
elif tag == "a":
|
|
944
|
+
self._close_anchor()
|
|
945
|
+
|
|
946
|
+
def _close_anchor(self):
|
|
947
|
+
"""Pop whatever href the matching <a> start tag pushed, and nothing
|
|
948
|
+
else (#994)."""
|
|
949
|
+
if not self._anchor_pushes:
|
|
950
|
+
return
|
|
951
|
+
pushed = self._anchor_pushes.pop()
|
|
952
|
+
if pushed == "block" and self._link_url:
|
|
953
|
+
self._link_url.pop()
|
|
954
|
+
elif pushed == "bare" and self._bare_link_url:
|
|
955
|
+
self._bare_link_url.pop()
|
|
918
956
|
|
|
919
957
|
def handle_data(self, data):
|
|
920
958
|
# Table-cell text and open-block text are independent triggers that happen
|