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.
Files changed (258) hide show
  1. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/PKG-INFO +1 -1
  2. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/doc-to-markdown.md +3 -2
  3. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/image-conversion.md +33 -0
  4. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/markdown-support.md +1 -0
  5. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/docs_content.md +58 -0
  6. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/roadmap.md +4 -3
  7. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/ast.py +2 -2
  8. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/content.py +34 -7
  9. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/emitter.py +66 -18
  10. {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
  11. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_content.py +173 -0
  12. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_core.py +153 -0
  13. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/uv.lock +22 -22
  14. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/cleanup-worktrees.md +0 -0
  15. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/merge-pr.md +0 -0
  16. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/notify-kai.md +0 -0
  17. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/notify-partner.md +0 -0
  18. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/orchestrator.md +0 -0
  19. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/prep-for-pr.md +0 -0
  20. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/qa-kickoff.md +0 -0
  21. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/release.md +0 -0
  22. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/retro.md +0 -0
  23. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/roadmap-status.md +0 -0
  24. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/start-worktree.md +0 -0
  25. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/team-member.md +0 -0
  26. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/commands/verify-pr.md +0 -0
  27. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/settings.json +0 -0
  28. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/amy.md +0 -0
  29. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/aziz.md +0 -0
  30. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/bob.md +0 -0
  31. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/dev.md +0 -0
  32. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/joy.md +0 -0
  33. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/kai.md +0 -0
  34. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.claude/team-roles/qa.md +0 -0
  35. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.dockerignore +0 -0
  36. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  37. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  38. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  39. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/dependabot.yml +0 -0
  40. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/workflows/ci.yml +0 -0
  41. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/workflows/docs.yml +0 -0
  42. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/workflows/publish-dev.yml +0 -0
  43. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.github/workflows/release.yml +0 -0
  44. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.gitignore +0 -0
  45. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.pre-commit-config.yaml +0 -0
  46. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/.python-version +0 -0
  47. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/CHANGELOG.md +0 -0
  48. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/CLAUDE.md +0 -0
  49. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/CODE_OF_CONDUCT.md +0 -0
  50. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/CONTRIBUTING.md +0 -0
  51. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/Dockerfile +0 -0
  52. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/LICENSE +0 -0
  53. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/Makefile +0 -0
  54. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/README.md +0 -0
  55. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/SECURITY.md +0 -0
  56. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docker-compose.yml +0 -0
  57. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/assets/logo.png +0 -0
  58. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/auth.md +0 -0
  59. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/client-setup.md +0 -0
  60. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/configuration.md +0 -0
  61. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-async-tool-execution.md +0 -0
  62. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-auth-status-limitation-categories.md +0 -0
  63. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-cache-invalidation.md +0 -0
  64. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-chart-theming.md +0 -0
  65. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-comments-first-class.md +0 -0
  66. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-composite-tools.md +0 -0
  67. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-condition-type-docstring-generation.md +0 -0
  68. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-converted-doc-change-detection.md +0 -0
  69. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-dev-team.md +0 -0
  70. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-docs-formatting.md +0 -0
  71. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-fork.md +0 -0
  72. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-gmail-domain.md +0 -0
  73. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-grid-data-size-cap.md +0 -0
  74. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-local-fs-test-harness.md +0 -0
  75. {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
  76. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-pillow-image-dependency.md +0 -0
  77. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-prompt-qa-role.md +0 -0
  78. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-publishing.md +0 -0
  79. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-release-docs-roles.md +0 -0
  80. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-repositioning.md +0 -0
  81. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-response-size-cap-generalization.md +0 -0
  82. {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
  83. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-review-finding-ticket-bar.md +0 -0
  84. {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
  85. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-sync-folder-recursion.md +0 -0
  86. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-testing.md +0 -0
  87. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/decision-tool-error-visibility.md +0 -0
  88. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/index.md +0 -0
  89. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/decisions/role-file-history.md +0 -0
  90. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/blockquote-representation.md +0 -0
  91. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/borderless-table-columns.md +0 -0
  92. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/doc-revision-export.md +0 -0
  93. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/docs-ast-pipeline.md +0 -0
  94. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/effectiveformat-spike.md +0 -0
  95. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/heading-anchor-resolution.md +0 -0
  96. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/index.md +0 -0
  97. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/local-file-access-policy.md +0 -0
  98. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/module-history.md +0 -0
  99. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/oauth-reauthorize-from-tool-call.md +0 -0
  100. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/phase3-theme-system.md +0 -0
  101. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design/sse-consent-off-event-loop.md +0 -0
  102. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/design.md +0 -0
  103. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/index.md +0 -0
  104. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/known-limitations.md +0 -0
  105. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/notes-read.md +0 -0
  106. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/README.md +0 -0
  107. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/qa-fixture-pixel.png +0 -0
  108. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-d195-create-doc.md +0 -0
  109. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-d196-create-doc.html +0 -0
  110. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-d213-dollar-escape.md +0 -0
  111. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-d226-heading-table.md +0 -0
  112. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc102-image-conversion.html +0 -0
  113. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc103-image-conversion.md +0 -0
  114. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc104-nested-lists.html +0 -0
  115. {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
  116. {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
  117. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc135-paragraph-boundary.md +0 -0
  118. {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
  119. {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
  120. {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
  121. {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
  122. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures/tc-doc142-heading-anchors.md +0 -0
  123. {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
  124. {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
  125. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/fixtures.template.md +0 -0
  126. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/gmail-test-plan.md +0 -0
  127. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/operations.yaml +0 -0
  128. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/playwright_oauth.md +0 -0
  129. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/.gitkeep +0 -0
  130. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-06-02.md +0 -0
  131. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-06-14.md +0 -0
  132. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-06-17.md +0 -0
  133. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-06-18.md +0 -0
  134. {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
  135. {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
  136. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-06-28.md +0 -0
  137. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-07-04-docs.md +0 -0
  138. {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
  139. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-07-04-infra.md +0 -0
  140. {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
  141. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-07-04.md +0 -0
  142. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/results/2026-09-04.md +0 -0
  143. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/retro-v0.8.0.md +0 -0
  144. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/retro-v0.9.0.md +0 -0
  145. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/run.md +0 -0
  146. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/runs/README.md +0 -0
  147. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/runs/v0.8.0.md +0 -0
  148. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/runs/v0.8.1.md +0 -0
  149. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/runs/v0.9.0.md +0 -0
  150. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/setup.md +0 -0
  151. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/calendar.md +0 -0
  152. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/docs_layout.md +0 -0
  153. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/docs_style.md +0 -0
  154. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/docs_tables.md +0 -0
  155. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/drive_activity.md +0 -0
  156. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/drive_files.md +0 -0
  157. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/drive_sharing.md +0 -0
  158. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/drive_transfer.md +0 -0
  159. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/gmail.md +0 -0
  160. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/infra.md +0 -0
  161. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/sheets_charts.md +0 -0
  162. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/sheets_mgmt.md +0 -0
  163. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/sheets_read.md +0 -0
  164. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/qa/tests/sheets_write.md +0 -0
  165. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/style-guide.md +0 -0
  166. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/docs/tools.md +0 -0
  167. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/mkdocs.yml +0 -0
  168. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/pyproject.toml +0 -0
  169. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/pyrightconfig.json +0 -0
  170. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/check_supports_all_drives.py +0 -0
  171. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/gen_tool_docs.py +0 -0
  172. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/lane_context_hook.py +0 -0
  173. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/oauth_setup.py +0 -0
  174. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/qa_cancel_insert_local_images.py +0 -0
  175. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/qa_gmail_fixtures.py +0 -0
  176. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/setup_team.sh +0 -0
  177. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/scripts/write_team_mcp_configs.sh +0 -0
  178. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/server.json +0 -0
  179. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/.env.template +0 -0
  180. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/__init__.py +0 -0
  181. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/auth.py +0 -0
  182. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/cache.py +0 -0
  183. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/http_transport.py +0 -0
  184. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/reauth.py +0 -0
  185. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/server.py +0 -0
  186. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/sse_shutdown.py +0 -0
  187. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/__init__.py +0 -0
  188. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/atomic_write.py +0 -0
  189. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/cache.py +0 -0
  190. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/calendar.py +0 -0
  191. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/concurrency.py +0 -0
  192. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/__init__.py +0 -0
  193. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/anchors.py +0 -0
  194. {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
  195. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/comments.py +0 -0
  196. {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
  197. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/editing.py +0 -0
  198. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/images.py +0 -0
  199. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/indices.py +0 -0
  200. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/layout.py +0 -0
  201. {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
  202. {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
  203. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/style.py +0 -0
  204. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/docs/tables.py +0 -0
  205. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/drive/__init__.py +0 -0
  206. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/drive/activity.py +0 -0
  207. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/drive/files.py +0 -0
  208. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/drive/sharing.py +0 -0
  209. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/drive/transfer.py +0 -0
  210. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/gmail.py +0 -0
  211. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/pagination.py +0 -0
  212. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/response_limits.py +0 -0
  213. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/sheets/__init__.py +0 -0
  214. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/sheets/data.py +0 -0
  215. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/sheets/helpers.py +0 -0
  216. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/src/mcp_gee_sweet/tools/sheets/structure.py +0 -0
  217. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/__init__.py +0 -0
  218. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/conftest.py +0 -0
  219. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/drive/__init__.py +0 -0
  220. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/drive/test_activity.py +0 -0
  221. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/drive/test_files.py +0 -0
  222. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/drive/test_sharing.py +0 -0
  223. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/drive/test_transfer.py +0 -0
  224. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/fakes.py +0 -0
  225. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/integration/__init__.py +0 -0
  226. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/integration/conftest.py +0 -0
  227. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/integration/test_local_fs_harness.py +0 -0
  228. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/sheets/__init__.py +0 -0
  229. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/sheets/test_data.py +0 -0
  230. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/sheets/test_helpers.py +0 -0
  231. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/sheets/test_structure.py +0 -0
  232. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_atomic_write.py +0 -0
  233. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_auth.py +0 -0
  234. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_cache.py +0 -0
  235. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_calendar.py +0 -0
  236. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_check_supports_all_drives.py +0 -0
  237. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_concurrency.py +0 -0
  238. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_anchors.py +0 -0
  239. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_comments.py +0 -0
  240. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_editing.py +0 -0
  241. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_images.py +0 -0
  242. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_indices.py +0 -0
  243. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_layout.py +0 -0
  244. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_markdown_export.py +0 -0
  245. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_named_ranges.py +0 -0
  246. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_revision_export.py +0 -0
  247. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_style.py +0 -0
  248. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_docs_tables.py +0 -0
  249. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_gen_tool_docs.py +0 -0
  250. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_gmail.py +0 -0
  251. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_lane_context_hook.py +0 -0
  252. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_qa_gmail_fixtures.py +0 -0
  253. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_reauth.py +0 -0
  254. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_response_limits.py +0 -0
  255. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_server.py +0 -0
  256. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_server_json.py +0 -0
  257. {mcp_gee_sweet-0.9.2.dev315 → mcp_gee_sweet-0.9.2.dev321}/tests/test_sse_shutdown.py +0 -0
  258. {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.dev315
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 `[![alt](src)](url)`, in
131
- ordinary text, a code paragraph, and a table cell alike. The write side doesn't set
132
- `Image.link_url` yet (#977).
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 `[![alt](src)](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
+ | `[![alt](src)](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\&amp;T`, `see \<https://x.io>`, and `a < b and AT&T` unchanged. markdown-it-py `commonmark` rendered `a &lt;b&gt; text`, `AT&amp;amp;T`, `see &lt;https://x.io&gt;`, and `a &lt; b and AT&amp;T`, with no `<b>` and no `<a>`. Round 1 got `a <b> text` and `AT&amp;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 `[![alt](src)](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 [![Logo](https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png)](https://example.com/one) and [linked text](https://example.com/t)![Plain](https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png)
3732
+
3733
+ Jump: [![A](https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png)](#overview) and [![B](https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png)](#missing)
3734
+
3735
+ | a | b |
3736
+ |---|---|
3737
+ | 1 | 2 |
3738
+
3739
+ [![C](https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png)](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: `[![](<contentUri>)](https://example.com/one)`
3760
+ - Step 2: `[linked text](https://example.com/t)` is followed by an unlinked `![](<contentUri>)`, 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 `[![](<contentUri>)](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 `[![](<contentUri>)](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 `[![](<contentUri>)](https://example.com/top)`
3766
+ - Step 6: the second paragraph is `x `, `[![](<contentUri>)](https://example.com/first)`, `[![](<contentUri>)](https://example.com/second)`, an unlinked `![](<contentUri>)`, 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 `[![](<contentUri>)](https://example.com/bare)`. The second is `[tail](https://example.com/block)`, then `[![](<contentUri>)](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 `[![](<contentUri>)](https://example.com/one)`. `[linked text](https://example.com/t)` was followed by an unlinked `![](<contentUri>)`. `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 `[![](<contentUri>)](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, `[![first](…)](https://example.com/first)[![second](…)](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 `[![](<contentUri>)](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 `[![](<contentUri>)](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 `[![](<contentUri>)](https://example.com/bare)`, and the second was `[tail](https://example.com/block)`, then `[![](<contentUri>)](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
- - [ ] Write side drops image links: `[![alt](src)](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 ([#977](https://github.com/khuisman/mcp-gee-sweet/issues/977))
227
- - [ ] `_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, after #977 ([#987](https://github.com/khuisman/mcp-gee-sweet/issues/987))
228
- - [ ] Comment quoted text comes back HTML-escaped (`Caf&#233;`) 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: `[![alt](src)](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&#233;`) 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
 
@@ -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
- # Read side only (doc_to_ast.py): the image element's own textStyle.link.
53
- # The write side doesn't set it yet.
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
 
@@ -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(requests: list[dict]) -> int:
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 any(key in requests[start - 1] for key in _POSITIONAL_INSERT_KEYS):
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`), recording the failure on its image's outcome
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))
@@ -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(_PendingTable(node, start_index + utf16_offset, cumulative_tabs))
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 (final_position, request_dict).
297
- positional_inserts: list[tuple[int, dict]] = []
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, _image_insert_request(item, uri, 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
- "insertTable": {
460
- "rows": num_rows,
461
- "columns": num_cols,
462
- "location": {"index": position},
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
- for _, insert_request in sorted(positional_inserts, key=lambda item: item[0], reverse=True):
475
- requests.append(insert_request)
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 _image_insert_request(image: Image, uri: str, position: int) -> dict:
514
- """Build an insertInlineImage request for an already-resolved image at `position`."""
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
- return request
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 "![alt](src)" has no syntax for them, so they're None there."""
144
+ markdown "![alt](src)" has no syntax for them, so they're None there.
145
+ link_url is the enclosing <a href>, if any (markdown "[![alt](src)](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 tag at all.
690
- if (
691
- tag == "img"
692
- and self._block_tag is None
693
- and self._table_depth == 0
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(_make_image(attr_dict, src))
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" and self._link_url:
915
- self._link_url.pop()
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