mcp-gee-sweet 0.7.0.dev72__tar.gz → 0.7.0.dev76__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 (140) hide show
  1. mcp_gee_sweet-0.7.0.dev76/.claude/commands/cleanup-worktrees.md +15 -0
  2. mcp_gee_sweet-0.7.0.dev76/.claude/commands/merge-pr.md +13 -0
  3. mcp_gee_sweet-0.7.0.dev76/.claude/commands/start-worktree.md +9 -0
  4. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.github/workflows/ci.yml +1 -1
  5. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.github/workflows/docs.yml +1 -1
  6. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.github/workflows/publish-dev.yml +2 -2
  7. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.github/workflows/release.yml +2 -2
  8. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/PKG-INFO +1 -1
  9. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/design/docs-ast-pipeline.md +45 -7
  10. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/tests/docs.md +81 -0
  11. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/cache.py +89 -60
  12. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/docs/ast.py +1 -2
  13. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/docs/emitter.py +309 -136
  14. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/docs/html_parser.py +25 -7
  15. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/test_cache.py +60 -0
  16. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/test_docs_core.py +36 -13
  17. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/test_docs_style.py +7 -4
  18. mcp_gee_sweet-0.7.0.dev76/tests/test_docs_tables.py +721 -0
  19. mcp_gee_sweet-0.7.0.dev72/tests/test_docs_tables.py +0 -433
  20. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.claude/commands/prep-for-pr.md +0 -0
  21. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.dockerignore +0 -0
  22. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  23. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  24. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  25. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.github/dependabot.yml +0 -0
  26. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.gitignore +0 -0
  27. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.pre-commit-config.yaml +0 -0
  28. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/.python-version +0 -0
  29. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/CHANGELOG.md +0 -0
  30. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/CLAUDE.md +0 -0
  31. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/CODE_OF_CONDUCT.md +0 -0
  32. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/CONTRIBUTING.md +0 -0
  33. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/Dockerfile +0 -0
  34. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/LICENSE +0 -0
  35. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/Makefile +0 -0
  36. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/README.md +0 -0
  37. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/SECURITY.md +0 -0
  38. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docker-compose.yml +0 -0
  39. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/auth.md +0 -0
  40. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/client-setup.md +0 -0
  41. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/configuration.md +0 -0
  42. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/decisions/decision-chart-theming.md +0 -0
  43. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/decisions/decision-composite-tools.md +0 -0
  44. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/decisions/decision-docs-formatting.md +0 -0
  45. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/decisions/decision-fork.md +0 -0
  46. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/decisions/decision-grid-data-size-cap.md +0 -0
  47. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/decisions/decision-publishing.md +0 -0
  48. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/decisions/decision-response-size-cap-generalization.md +0 -0
  49. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/decisions/decision-testing.md +0 -0
  50. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/decisions/index.md +0 -0
  51. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/design/effectiveformat-spike.md +0 -0
  52. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/design/index.md +0 -0
  53. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/design/markdown-support.md +0 -0
  54. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/design/phase3-theme-system.md +0 -0
  55. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/design.md +0 -0
  56. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/index.md +0 -0
  57. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/known-limitations.md +0 -0
  58. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/notes-read.md +0 -0
  59. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/README.md +0 -0
  60. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/fixtures/tc-d195-create-doc.md +0 -0
  61. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/fixtures/tc-d196-create-doc.html +0 -0
  62. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/fixtures/tc-d213-dollar-escape.md +0 -0
  63. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/fixtures/tc-d226-heading-table.md +0 -0
  64. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/fixtures.template.md +0 -0
  65. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/operations.yaml +0 -0
  66. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/playwright_oauth.md +0 -0
  67. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/.gitkeep +0 -0
  68. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/2026-06-02.md +0 -0
  69. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/2026-06-14.md +0 -0
  70. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/2026-06-17.md +0 -0
  71. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/2026-06-18.md +0 -0
  72. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/2026-06-21-drive-discovery.md +0 -0
  73. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/2026-06-21-sheets-delete-clear.md +0 -0
  74. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/2026-06-28.md +0 -0
  75. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/2026-07-04-docs.md +0 -0
  76. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/2026-07-04-drive-smoke.md +0 -0
  77. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/2026-07-04-infra.md +0 -0
  78. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/2026-07-04-sheets_read.md +0 -0
  79. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/results/2026-07-04.md +0 -0
  80. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/retro-v0.8.0.md +0 -0
  81. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/run.md +0 -0
  82. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/runs/README.md +0 -0
  83. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/runs/v0.8.0.md +0 -0
  84. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/runs/v0.8.1.md +0 -0
  85. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/setup.md +0 -0
  86. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/tests/calendar.md +0 -0
  87. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/tests/drive.md +0 -0
  88. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/tests/infra.md +0 -0
  89. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/tests/sheets_charts.md +0 -0
  90. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/tests/sheets_mgmt.md +0 -0
  91. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/tests/sheets_read.md +0 -0
  92. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa/tests/sheets_write.md +0 -0
  93. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/qa-checklist.md +0 -0
  94. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/roadmap.md +0 -0
  95. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/docs/tools.md +0 -0
  96. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/mkdocs.yml +0 -0
  97. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/pyproject.toml +0 -0
  98. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/pyrightconfig.json +0 -0
  99. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/scripts/gen_tool_docs.py +0 -0
  100. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/scripts/oauth_setup.py +0 -0
  101. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/.env.template +0 -0
  102. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/__init__.py +0 -0
  103. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/auth.py +0 -0
  104. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/server.py +0 -0
  105. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/__init__.py +0 -0
  106. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/cache.py +0 -0
  107. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/calendar.py +0 -0
  108. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/docs/__init__.py +0 -0
  109. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/docs/content.py +0 -0
  110. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/docs/layout.py +0 -0
  111. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/docs/style.py +0 -0
  112. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/docs/tables.py +0 -0
  113. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/drive/__init__.py +0 -0
  114. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/drive/activity.py +0 -0
  115. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/drive/files.py +0 -0
  116. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/drive/sharing.py +0 -0
  117. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/drive/transfer.py +0 -0
  118. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/response_limits.py +0 -0
  119. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/sheets/__init__.py +0 -0
  120. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/sheets/data.py +0 -0
  121. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/sheets/helpers.py +0 -0
  122. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/src/mcp_gee_sweet/tools/sheets/structure.py +0 -0
  123. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/__init__.py +0 -0
  124. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/drive/__init__.py +0 -0
  125. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/drive/test_activity.py +0 -0
  126. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/drive/test_files.py +0 -0
  127. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/drive/test_sharing.py +0 -0
  128. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/drive/test_transfer.py +0 -0
  129. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/sheets/__init__.py +0 -0
  130. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/sheets/test_data.py +0 -0
  131. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/sheets/test_helpers.py +0 -0
  132. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/sheets/test_structure.py +0 -0
  133. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/test_auth.py +0 -0
  134. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/test_calendar.py +0 -0
  135. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/test_docs_content.py +0 -0
  136. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/test_docs_layout.py +0 -0
  137. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/test_gen_tool_docs.py +0 -0
  138. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/test_response_limits.py +0 -0
  139. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/tests/test_server.py +0 -0
  140. {mcp_gee_sweet-0.7.0.dev72 → mcp_gee_sweet-0.7.0.dev76}/uv.lock +0 -0
@@ -0,0 +1,15 @@
1
+ Remove worktrees under `.claude/worktrees/` whose branch has a merged PR, so completed tickets don't pile up as stale directories. Safe by default: never deletes anything with uncommitted or unpushed changes — those are reported, not touched.
2
+
3
+ 1. List current worktrees: `git worktree list --porcelain`. Skip the main checkout (the entry with no `.claude/worktrees/` path).
4
+ 2. For each remaining worktree, get its branch name and check whether it has a merged PR:
5
+ ```
6
+ gh pr list --repo khuisman/mcp-gee-sweet --state merged --head <branch> --json number,url,mergedAt
7
+ ```
8
+ 3. For each worktree whose branch has a merged PR, check it's safe to remove:
9
+ - No uncommitted changes: `git -C <path> status --porcelain` must be empty.
10
+ - No unpushed commits: `git -C <path> log @{upstream}.. --oneline` must be empty (if the branch has no upstream, e.g. already deleted on the remote post-merge, treat a clean `status --porcelain` as sufficient).
11
+ - If both checks pass, remove it: `git worktree remove <path>`, then `git branch -d <branch>`.
12
+ - If either check fails, skip it and note what's uncommitted or unpushed — never force-remove.
13
+ 4. Leave alone any worktree whose branch has no merged PR (open PR, no PR yet, or PR closed without merging).
14
+ 5. Run `git worktree prune` to clear any stale administrative entries left behind.
15
+ 6. Report a summary: worktrees removed (with their PR link), worktrees skipped as dirty (with what's blocking removal), and worktrees still open (with PR/issue status).
@@ -0,0 +1,13 @@
1
+ Merge the current branch's PR and clean up its worktree. Uses `--admin` only to bypass the required-review gate (this is a solo-maintainer repo, so a second approver is never coming) — it must never be used to bypass a failing or pending status check.
2
+
3
+ 1. Get the PR for the current branch: `gh pr view --json number,title,url,state,mergeable,statusCheckRollup,reviewDecision`. If there's no open PR for this branch, stop and tell the user.
4
+ 2. Verify it's actually safe to merge:
5
+ - `state` must be `OPEN`.
6
+ - Every check in `statusCheckRollup` (in particular "Lint and test") must show success. If anything is failing or still pending, stop and report which — do not merge, and do not wait/poll.
7
+ - `mergeable` must not be `CONFLICTING`.
8
+ 3. Show the user a summary: PR number, title, URL, check status, and review status (expected to be blocked only on the missing-approval requirement). State plainly that merging will use `--admin` to bypass that review gate. Ask for explicit confirmation before proceeding.
9
+ 4. On confirmation, squash-merge (matching this repo's existing history — commits land on `develop` as a single `title (#number)` commit): `gh pr merge <number> --admin --squash`. The remote branch is auto-deleted on merge (repo setting), so don't pass `--delete-branch`.
10
+ 5. Clean up the local worktree:
11
+ - If this session created the current worktree (i.e. it got here via `/start-worktree` or `/next-issue` earlier in this same session), use `ExitWorktree` with `action: "remove"`.
12
+ - Otherwise (worktree predates this session), fall back to manual cleanup from the main checkout: `git worktree remove <path>`, then `git branch -d <branch>`, then `git worktree prune`.
13
+ 6. Report a summary: the merge commit, the now-merged PR URL, and confirmation the worktree was removed.
@@ -0,0 +1,9 @@
1
+ Create an isolated git worktree for parallel work in this repo, with the same local configuration as the main checkout (credentials, env, and a synced virtualenv), so it can run alongside other sessions without touching their branches or files.
2
+
3
+ 1. Determine the worktree name: use the argument passed to this command if given (e.g. an issue number or short slug, sanitized to letters/digits/dots/underscores/dashes). If no argument was given, ask the user for a short name before proceeding.
4
+ 2. Call `EnterWorktree` with that name. This creates the worktree under `.claude/worktrees/<name>/`, branches it from `origin/develop` (the repo's default branch), and switches the session into it.
5
+ 3. Copy the untracked local config that `git worktree` does not check out, from the original repo root (the directory the session was in before `EnterWorktree`) into the new worktree root: `.env`, `credentials.json`, `service_account.json`, `token.json`, `.claude/settings.local.json`. Skip any file that doesn't exist in the source.
6
+ 4. Run `uv sync` in the new worktree to build its own virtualenv — worktrees don't share `.venv`.
7
+ 5. Confirm the worktree is ready: report its path and current branch to the user.
8
+
9
+ Do not modify the original checkout's files. If the user later asks to leave this worktree, use `ExitWorktree` with `keep` unless they explicitly say to discard the work.
@@ -17,7 +17,7 @@ jobs:
17
17
  - uses: actions/checkout@v7
18
18
 
19
19
  - name: Install uv
20
- uses: astral-sh/setup-uv@v8.2.0
20
+ uses: astral-sh/setup-uv@v8.3.0
21
21
 
22
22
  - name: Set up Python 3.12
23
23
  run: uv python install 3.12
@@ -18,7 +18,7 @@ jobs:
18
18
  fetch-depth: 0 # full history so mike can see existing gh-pages versions
19
19
 
20
20
  - name: Install uv
21
- uses: astral-sh/setup-uv@v8.2.0
21
+ uses: astral-sh/setup-uv@v8.3.0
22
22
 
23
23
  - name: Set up Python 3.12
24
24
  run: uv python install 3.12
@@ -23,7 +23,7 @@ jobs:
23
23
  fetch-depth: 0 # full history so uv-dynamic-versioning can read tags
24
24
 
25
25
  - name: Install uv
26
- uses: astral-sh/setup-uv@v8.2.0
26
+ uses: astral-sh/setup-uv@v8.3.0
27
27
 
28
28
  - name: Set up Python 3.12
29
29
  run: uv python install 3.12
@@ -45,7 +45,7 @@ jobs:
45
45
  fetch-depth: 0 # full history so uv-dynamic-versioning can read tags
46
46
 
47
47
  - name: Install uv
48
- uses: astral-sh/setup-uv@v8.2.0
48
+ uses: astral-sh/setup-uv@v8.3.0
49
49
 
50
50
  - name: Set up Python 3.12
51
51
  run: uv python install 3.12
@@ -15,7 +15,7 @@ jobs:
15
15
  - uses: actions/checkout@v7
16
16
 
17
17
  - name: Install uv
18
- uses: astral-sh/setup-uv@v8.2.0
18
+ uses: astral-sh/setup-uv@v8.3.0
19
19
 
20
20
  - name: Set up Python 3.12
21
21
  run: uv python install 3.12
@@ -36,7 +36,7 @@ jobs:
36
36
  fetch-depth: 0
37
37
 
38
38
  - name: Install uv
39
- uses: astral-sh/setup-uv@v8.2.0
39
+ uses: astral-sh/setup-uv@v8.3.0
40
40
 
41
41
  - name: Set up Python 3.12
42
42
  run: uv python install 3.12
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-gee-sweet
3
- Version: 0.7.0.dev72
3
+ Version: 0.7.0.dev76
4
4
  Summary: MCP server for Google Workspace — Sheets, Drive, Docs, and Calendar. 60+ tools for AI clients.
5
5
  Project-URL: Homepage, https://github.com/khuisman/mcp-gee-sweet
6
6
  Project-URL: Bug Tracker, https://github.com/khuisman/mcp-gee-sweet/issues
@@ -266,13 +266,13 @@ New tests to add:
266
266
  | Column widths | Yes | #66 |
267
267
  | Colspan | Yes | #67 partial |
268
268
  | Rowspan | Yes | `Cell.rowspan` in AST; #91 added `mergeTableCells` in emitter |
269
- | Nested tables | Yes | `Cell.nested_table: Table \| None`; #92 added stages 3+5 in `fill_tables` |
269
+ | Nested tables | Yes | `Cell.nested_table: Table \| None`; #92 added stages 3+5 in `fill_tables`. Superseded 2026-07-06 by `Cell.children: list[Run \| Table]` — see "Nested Table Rewrite" below |
270
270
  | Phase 3 styling fields (colors, fonts) | No | In AST as `None`; emitter ignores them |
271
271
  | `<ol>` ordered bullets | Yes | `BulletItem.ordered` → different bullet preset |
272
272
 
273
273
  ---
274
274
 
275
- ## Nested Table Design (#92)
275
+ ## Nested Table Design (#92, superseded 2026-07-06 by #108/#275 — see below)
276
276
 
277
277
  **Date:** 2026-06-19
278
278
 
@@ -294,9 +294,47 @@ A `<table>` inside a `<td>` is now supported (one level of nesting).
294
294
 
295
295
  **Phase 5 — fill nested cells**: after outer cell text is filled (phase 4), indices shift. A second re-fetch is performed, then `_collect_nested_table_pairs(doc_tables, ast_tables)` traverses cell content to find the nested `table` element and pairs it with the AST `Table`. `_build_fill_requests` reuses the same logic as outer cells.
296
296
 
297
- ### Known limitations (first pass)
297
+ ### Known limitations (first pass, now historical — see rewrite below)
298
298
 
299
- - One level of nesting only (table-in-table-in-table not supported)
300
- - Cells with `nested_table` should not also contain text runs (runs are dropped silently)
301
- - No `colspan`/`rowspan` inside nested tables
302
- - No `col_widths` on nested tables
299
+ - Cells with `nested_table` should not also contain text runs (runs are dropped silently) — **fixed and superseded**, see below
300
+ - One level of nesting only (table-in-table-in-table not supported) **fixed and superseded**, see below
301
+ - No `colspan`/`rowspan` inside nested tables — still true
302
+ - No `col_widths` on nested tables — still true
303
+
304
+ ---
305
+
306
+ ## Nested Table Rewrite: `Cell.children` + recursive fill (#108, #275)
307
+
308
+ **Date:** 2026-07-06
309
+
310
+ The single-`nested_table`-field design above had two real defects, both found while investigating #108:
311
+
312
+ 1. Text sharing a cell with a nested table was silently corrupted. Root cause was in the parser, not the emitter: `_AstParser._run_buf`/`_pending_runs` are shared, unscoped state across nesting levels — opening a nested `<table>` never flushed them, so text typed before the nested table bled into the *nested* table's own first cell instead of staying with the outer cell.
313
+ 2. Even once the parser stopped corrupting it, the single `Cell.runs` field had nowhere to put "text after the table" separately from "text before the table" — `fill_tables` always inserted a cell's text at its first paragraph, i.e. always immediately *before* any nested table, regardless of where the text actually appeared in the source HTML. Filed as follow-up #275 rather than silently expanding #108's scope; then, per explicit direction, fixed in the same pass ("do the fuller version").
314
+
315
+ ### Schema change (`ast.py`)
316
+
317
+ `Cell.runs: list[Run]` + `Cell.nested_table: Table | None` → **`Cell.children: list[Run | Table]`** — one ordered list holding exactly what appeared in the source, in order. This is the `Union[Run, Table]` design #92 originally proposed and explicitly didn't ship; the simpler two-field version turned out to be insufficient once #275 surfaced.
318
+
319
+ ### Parser changes (`html_parser.py`)
320
+
321
+ `_TableBuilder` replaces `_current_nested_table`/`_pending_leading_runs` with a single `_current_children: list[Run | Table]`, plus `append_runs()`/`append_nested_table()` methods that append to it in call order. `_AstParser.handle_starttag`'s `table` branch flushes pending runs onto the *outer* builder via `append_runs()` before pushing a new builder for the nested table (fixing defect 1 above); `handle_endtag`'s `table` branch calls `append_nested_table()` on the parent builder instead of setting a single field. `end_cell` builds `Cell(children=self._current_children + runs, ...)`. This works at arbitrary depth and for arbitrarily many nested tables per cell — no explicit depth/count limit needed.
322
+
323
+ ### Emitter changes (`emitter.py`)
324
+
325
+ Cells with no `Table` in `children` are still filled in one bulk batch by `_build_fill_requests` (unchanged, fast path — most cells). Cells with at least one `Table` child go through a new recursive, round-based algorithm:
326
+
327
+ - **`_fill_children_recursive(docs_service, doc_id, resolve, ast_tables)`** — `resolve(live_doc) -> doc_table dicts` positionally matching `ast_tables`. Tracks a per-cell cursor into `children`. Each round: for every still-pending cell, emit **one** contiguous segment — either a run of text (`insertText` + styles) or one nested table's shell (`insertTable`) — at that cell's next open position, sorted high→low within the round (same pattern as the old phase-3/5 code), then re-fetch before the next round.
328
+ - **`_cell_para_start_for_cursor`**: a cell's `content` alternates paragraph/table/paragraph/table/...; the Nth paragraph (0-indexed) is the one after the Nth already-inserted table, where N = count of `Table` children before `cursor`.
329
+ - **`_text_offset_since_last_table`**: a table's insertion point is its paragraph's `startIndex` *plus* this — the length of any text already inserted into that same paragraph earlier in this round-chain (the paragraph's own `startIndex` doesn't change when text is inserted into it, but the table must land after that text, not at the start).
330
+ - When a table shell is inserted, **`_fill_table_fully`** is called for it: re-fetches, bulk-fills its own plain cells via `_build_fill_requests`, then recurses via `_fill_children_recursive` for any of *its* cells that themselves contain a further nested table. This is what makes nesting depth unbounded — a nested table is filled exactly the same way a top-level one is.
331
+ - After recursing into any nested tables inserted in a round (which run their own `batchUpdate`s and shift subsequent document indices), the outer loop re-fetches again before its own next round — recursion's mutations must be visible before the outer cell's next segment (e.g. trailing text) is positioned.
332
+ - `_ast_cell_to_doc_cell` / `_find_nth_table_in_cell` locate a specific physical doc cell / the Nth nested-table content element inside a cell, reusing the existing `_physical_to_ast_indices`/`_build_phantom_set` colspan/rowspan-aware mapping.
333
+
334
+ `_build_nested_table_inserts` and `_collect_nested_table_pairs` (the old phase-3/5 helpers) are removed — fully superseded by the recursive algorithm.
335
+
336
+ ### Result
337
+
338
+ - Text before, between, and after any number of nested tables in one cell renders in true source order, at any nesting depth.
339
+ - The "one level of nesting only" and "no colspan/rowspan/col_widths inside nested tables" limitations from the original #92 design are otherwise unchanged — this rewrite only touches content ordering, not those structural features.
340
+ - Tested with `tests/test_docs_tables.py`'s `TestFillNestedCellContent`, which drives the real algorithm against a small in-memory Docs-API simulator (`FakeDoc`/`Para`/`TableNode` in that file) that actually applies `insertText`/`insertTable` mutations and re-derives indices — chosen over hand-computed index fixtures because this much re-fetch/recursion logic is easy to get subtly wrong in a way a fixture can't catch (and did, twice, during development).
@@ -939,6 +939,87 @@ These test the HTML→AST→Docs API pipeline introduced in Phase 2 (#87). All u
939
939
 
940
940
  ---
941
941
 
942
+ ### TC-DOC84: Text sharing a cell with a nested table is no longer dropped (issue #108) ⚠️ destructive
943
+ **Prompt**
944
+ **Playwright: required**
945
+ > "Write this HTML to doc {DOC_ID}: `<table><tr><td>Some label <table><tr><td>Inner</td></tr></table></td></tr></table>`"
946
+
947
+ **Checks**
948
+ - Call succeeds with no API error
949
+ - `get_doc_structure` shows the outer table (1 row × 1 col) with a non-empty text run in cell [0,0] (previously this cell's text was silently dropped — bled into the nested table's own cell instead)
950
+ - 🔍 Visual check: "Some label" appears above/before the nested table inside the outer cell, and the nested table's own cell reads "Inner" (not "Some label Inner" merged together)
951
+
952
+ **Cleanup:** write fixture content back
953
+
954
+ **Result (2026-07-06) ✅ PASS** `get_doc_structure` shows outer cell [0,0] `text: "Some label"` (previously empty per TC-DOC48's bug pattern). Playwright screenshot confirms "Some label" renders above the nested table, whose own cell reads exactly "Inner" — no merging.
955
+
956
+ ---
957
+
958
+ ### TC-DOC85: Text after a nested table in the same cell, correctly positioned (issue #275) ⚠️ destructive
959
+ **Prompt**
960
+ **Playwright: required**
961
+ > "Write this HTML to doc {DOC_ID}: `<table><tr><td><table><tr><td>Inner</td></tr></table>After</td></tr></table>`"
962
+
963
+ **Checks**
964
+ - Call succeeds with no API error
965
+ - `get_doc_structure` shows the outer cell's text as "After" — content intact, not merged with "Inner"
966
+ - 🔍 Visual check: "After" renders *below* the nested table, not above it
967
+
968
+ **Cleanup:** write fixture content back
969
+
970
+ **Result (2026-07-07) ✅ PASS** `get_doc_structure` cell [0,0] `text: "After"`. Playwright confirms "After" renders below the nested table ("Inner"). Note: an earlier pass of this test case (2026-07-06) incorrectly expected "After" to render *above* the table — that was the pre-#275-fix limitation (a cell's text always rendered as one block before any nested table, regardless of source order). #275 fixed the emitter to place text on the correct side of each nested table; this test case's expectation and prompt were updated to match.
971
+
972
+ ---
973
+
974
+ ### TC-DOC86: Text before AND after one nested table, both correctly positioned (issue #275) ⚠️ destructive
975
+ **Prompt**
976
+ **Playwright: required**
977
+ > "Write this HTML to doc {DOC_ID}: `<table><tr><td>Before <table><tr><td>Inner</td></tr></table> After</td></tr></table>`"
978
+
979
+ **Checks**
980
+ - Call succeeds with no API error
981
+ - `get_doc_structure` shows outer cell [0,0] text as `"Before \n After"` (the `\n` confirms two distinct paragraphs — before and after the table — not one merged block)
982
+ - 🔍 Visual check: "Before" above the nested table, "Inner" inside it, "After" below it
983
+
984
+ **Cleanup:** write fixture content back
985
+
986
+ **Result (2026-07-07) ✅ PASS** `get_doc_structure` shows `text: "Before \n After"`. Playwright screenshot confirms all three pieces render in the correct order and position.
987
+
988
+ ---
989
+
990
+ ### TC-DOC87: Multiple nested tables in one cell with text between them (issue #275) ⚠️ destructive
991
+ **Prompt**
992
+ **Playwright: required**
993
+ > "Write this HTML to doc {DOC_ID}: `<table><tr><td>A<table><tr><td>1</td></tr></table>B<table><tr><td>2</td></tr></table>C</td></tr></table>`"
994
+
995
+ **Checks**
996
+ - Call succeeds with no API error
997
+ - `get_doc_structure` shows outer cell [0,0] text as `"A\nB\nC"` (three separate paragraphs — one per text segment between/around the two nested tables)
998
+ - 🔍 Visual check: A, then a table containing "1", then B, then a second table containing "2", then C — all in that order
999
+
1000
+ **Cleanup:** write fixture content back
1001
+
1002
+ **Result (2026-07-07) ✅ PASS** `get_doc_structure` shows `text: "A\nB\nC"`. Playwright screenshot confirms both nested tables render in the correct positions with "1" and "2" filled in, and A/B/C text correctly interleaved — a capability that didn't exist before #275 (previously only one nested table per cell was supported at all).
1003
+
1004
+ ---
1005
+
1006
+ ### TC-DOC88: `colspan="0"` clamps to 1 instead of producing a degenerate zero-column cell
1007
+
1008
+ **Background:** Found via code review of PR #276 — `int(attr_dict.get("colspan") or 1)` only covers a *missing* colspan attribute; an explicit `colspan="0"` is a non-empty (truthy) string, so it survives the `or` and parses to the literal integer `0`. A cell that spans zero columns breaks downstream `num_cols` calculations used by the nested-table fill algorithm. Fixed by clamping colspan/rowspan to a minimum of 1 in `html_parser.py`.
1009
+
1010
+ **Prompt**
1011
+ > "Write this HTML to doc {DOC_ID}: `<table><tr><td colspan="0">Wide</td><td>Next</td></tr></table>`"
1012
+
1013
+ **Checks**
1014
+ - Call succeeds with no API error
1015
+ - `get_doc_structure` shows the table with `columns: 2` (not 1 or a broken/merged layout) — cell [0,0] text "Wide", cell [0,1] text "Next"
1016
+
1017
+ **Cleanup:** write fixture content back
1018
+
1019
+ **Result (2026-07-07) ✅ PASS** `get_doc_structure` shows `columns: 2`, cell [0,0] `text: "Wide"`, cell [0,1] `text: "Next"` — `colspan="0"` clamped to 1 and rendered as two normal side-by-side cells.
1020
+
1021
+ ---
1022
+
942
1023
  ### TC-DOC52: `get_doc_theme` scans body paragraph styles
943
1024
  **Note:** `get_doc_theme` reads explicit per-paragraph and per-run styles from the document body. It returns data for AI-generated docs (where styles are set explicitly on runs); for standard docs whose styles are fully inherited from named style defaults it returns an empty dict.
944
1025
 
@@ -28,6 +28,9 @@ def _connect(path: str) -> sqlite3.Connection:
28
28
  conn = sqlite3.connect(path, check_same_thread=False)
29
29
  conn.row_factory = sqlite3.Row
30
30
  conn.execute("PRAGMA journal_mode=WAL")
31
+ # Concurrent Claude sessions share this DB file by default; without a busy_timeout
32
+ # a write from another session hits SQLITE_BUSY immediately instead of waiting.
33
+ conn.execute("PRAGMA busy_timeout=5000")
31
34
  conn.execute(_DDL)
32
35
  conn.commit()
33
36
  return conn
@@ -60,6 +63,33 @@ def _open(db_path: str) -> sqlite3.Connection:
60
63
  return _connect(":memory:")
61
64
 
62
65
 
66
+ def _safe_fetchone(conn: sqlite3.Connection, sql: str, params: tuple) -> sqlite3.Row | None:
67
+ """Run a SELECT and fetchone(), treating any sqlite error as a cache miss.
68
+
69
+ Another session's crash can leave a locked/corrupted WAL behind; a cache read
70
+ should never take down a tool call over it (fail open, same as the API-fetch
71
+ fallback in fetch_sheets()).
72
+ """
73
+ try:
74
+ return conn.execute(sql, params).fetchone()
75
+ except sqlite3.Error as exc:
76
+ logger.warning("Cache read failed (%s), treating as cache miss", exc)
77
+ return None
78
+
79
+
80
+ def _safe_write(conn: sqlite3.Connection, sql: str, params: tuple) -> None:
81
+ """Run a write statement + commit, swallowing sqlite errors (fail-open cache write).
82
+
83
+ A failed cache write must not fail the tool call that already succeeded against
84
+ the underlying Google API — it just means that result won't be cached this time.
85
+ """
86
+ try:
87
+ conn.execute(sql, params)
88
+ conn.commit()
89
+ except sqlite3.Error as exc:
90
+ logger.warning("Cache write failed (%s), continuing without caching", exc)
91
+
92
+
63
93
  @dataclass
64
94
  class SheetInfo:
65
95
  title: str
@@ -75,19 +105,20 @@ class SheetStructureCache:
75
105
  logger.debug("Sheet structure cache opened: %s", db_path)
76
106
 
77
107
  def _get_valid(self, spreadsheet_id: str) -> sqlite3.Row | None:
78
- row = self._conn.execute(
108
+ row = _safe_fetchone(
109
+ self._conn,
79
110
  "SELECT value, fetched_at, dirty FROM cache WHERE namespace=? AND key=?",
80
111
  (self._NS, spreadsheet_id),
81
- ).fetchone()
112
+ )
82
113
  if row is None or row["dirty"]:
83
114
  return None
84
115
  if time.time() - row["fetched_at"] > self._ttl:
85
116
  logger.debug("Cache TTL expired for %s, marking dirty", spreadsheet_id)
86
- self._conn.execute(
117
+ _safe_write(
118
+ self._conn,
87
119
  "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?",
88
120
  (self._NS, spreadsheet_id),
89
121
  )
90
- self._conn.commit()
91
122
  return None
92
123
  return row
93
124
 
@@ -109,33 +140,33 @@ class SheetStructureCache:
109
140
  value: dict = {"sheets": [{"title": s.title, "sheet_id": s.sheet_id} for s in sheets]}
110
141
  if title is not None:
111
142
  value["title"] = title
112
- self._conn.execute(
143
+ _safe_write(
144
+ self._conn,
113
145
  "INSERT OR REPLACE INTO cache (namespace, key, value, fetched_at, dirty)"
114
146
  " VALUES (?,?,?,?,0)",
115
147
  (self._NS, spreadsheet_id, json.dumps(value), time.time()),
116
148
  )
117
- self._conn.commit()
118
149
  logger.debug("Cached %d sheets for %s", len(sheets), spreadsheet_id)
119
150
 
120
151
  def mark_dirty(self, spreadsheet_id: str):
121
- self._conn.execute(
152
+ _safe_write(
153
+ self._conn,
122
154
  "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?",
123
155
  (self._NS, spreadsheet_id),
124
156
  )
125
- self._conn.commit()
126
157
  logger.debug("Marked cache dirty for %s", spreadsheet_id)
127
158
 
128
159
  def mark_all_dirty(self):
129
- self._conn.execute("UPDATE cache SET dirty=1 WHERE namespace=?", (self._NS,))
130
- self._conn.commit()
160
+ _safe_write(self._conn, "UPDATE cache SET dirty=1 WHERE namespace=?", (self._NS,))
131
161
  logger.debug("Invalidated all sheet structure cache entries")
132
162
 
133
163
  def get_stale_sheets(self, spreadsheet_id: str) -> list[SheetInfo] | None:
134
164
  """Returns cached sheet list regardless of dirty/TTL state; None only on complete miss."""
135
- row = self._conn.execute(
165
+ row = _safe_fetchone(
166
+ self._conn,
136
167
  "SELECT value FROM cache WHERE namespace=? AND key=?",
137
168
  (self._NS, spreadsheet_id),
138
- ).fetchone()
169
+ )
139
170
  if row is None:
140
171
  return None
141
172
  return [SheetInfo(**s) for s in json.loads(row["value"])["sheets"]]
@@ -161,17 +192,17 @@ class SheetDataCache:
161
192
  self, spreadsheet_id: str, sheet_id: int, rows_to_fetch: int
162
193
  ) -> sqlite3.Row | None:
163
194
  key = self._key(spreadsheet_id, sheet_id)
164
- row = self._conn.execute(
195
+ row = _safe_fetchone(
196
+ self._conn,
165
197
  "SELECT value, fetched_at, dirty, rows_fetched FROM cache WHERE namespace=? AND key=?",
166
198
  (self._NS, key),
167
- ).fetchone()
199
+ )
168
200
  if row is None or row["dirty"]:
169
201
  return None
170
202
  if time.time() - row["fetched_at"] > self._ttl:
171
- self._conn.execute(
172
- "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?", (self._NS, key)
203
+ _safe_write(
204
+ self._conn, "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?", (self._NS, key)
173
205
  )
174
- self._conn.commit()
175
206
  return None
176
207
  if (row["rows_fetched"] or 0) < rows_to_fetch:
177
208
  return None
@@ -198,7 +229,8 @@ class SheetDataCache:
198
229
  rows_to_fetch: int,
199
230
  ):
200
231
  value = {"headers": headers, "first_rows": first_rows}
201
- self._conn.execute(
232
+ _safe_write(
233
+ self._conn,
202
234
  "INSERT OR REPLACE INTO cache (namespace, key, value, fetched_at, dirty, rows_fetched)"
203
235
  " VALUES (?,?,?,?,0,?)",
204
236
  (
@@ -209,27 +241,26 @@ class SheetDataCache:
209
241
  rows_to_fetch,
210
242
  ),
211
243
  )
212
- self._conn.commit()
213
244
  logger.debug("Cached sheet data for %s/%s", spreadsheet_id, sheet_id)
214
245
 
215
246
  def mark_dirty(self, spreadsheet_id: str, sheet_id: int | None = None):
216
247
  if sheet_id is not None:
217
- self._conn.execute(
248
+ _safe_write(
249
+ self._conn,
218
250
  "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?",
219
251
  (self._NS, self._key(spreadsheet_id, sheet_id)),
220
252
  )
221
253
  logger.debug("Marked sheet data dirty for %s/%s", spreadsheet_id, sheet_id)
222
254
  else:
223
- self._conn.execute(
255
+ _safe_write(
256
+ self._conn,
224
257
  "UPDATE cache SET dirty=1 WHERE namespace=? AND key LIKE ?",
225
258
  (self._NS, f"{spreadsheet_id}:%"),
226
259
  )
227
260
  logger.debug("Marked all sheet data dirty for %s", spreadsheet_id)
228
- self._conn.commit()
229
261
 
230
262
  def mark_all_dirty(self):
231
- self._conn.execute("UPDATE cache SET dirty=1 WHERE namespace=?", (self._NS,))
232
- self._conn.commit()
263
+ _safe_write(self._conn, "UPDATE cache SET dirty=1 WHERE namespace=?", (self._NS,))
233
264
  logger.debug("Invalidated all sheet data cache entries")
234
265
 
235
266
  def close(self):
@@ -251,17 +282,17 @@ class DriveFolderCache:
251
282
 
252
283
  def _get_valid(self, folder_id: str, mime_type: str | None) -> sqlite3.Row | None:
253
284
  key = self._key(folder_id, mime_type)
254
- row = self._conn.execute(
285
+ row = _safe_fetchone(
286
+ self._conn,
255
287
  "SELECT value, fetched_at, dirty FROM cache WHERE namespace=? AND key=?",
256
288
  (self._NS, key),
257
- ).fetchone()
289
+ )
258
290
  if row is None or row["dirty"]:
259
291
  return None
260
292
  if time.time() - row["fetched_at"] > self._ttl:
261
- self._conn.execute(
262
- "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?", (self._NS, key)
293
+ _safe_write(
294
+ self._conn, "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?", (self._NS, key)
263
295
  )
264
- self._conn.commit()
265
296
  return None
266
297
  return row
267
298
 
@@ -274,25 +305,24 @@ class DriveFolderCache:
274
305
  return json.loads(row["value"])
275
306
 
276
307
  def store(self, folder_id: str, mime_type: str | None, files: list):
277
- self._conn.execute(
308
+ _safe_write(
309
+ self._conn,
278
310
  "INSERT OR REPLACE INTO cache (namespace, key, value, fetched_at, dirty)"
279
311
  " VALUES (?,?,?,?,0)",
280
312
  (self._NS, self._key(folder_id, mime_type), json.dumps(files), time.time()),
281
313
  )
282
- self._conn.commit()
283
314
  logger.debug("Cached %d files for folder %s (mime=%s)", len(files), folder_id, mime_type)
284
315
 
285
316
  def mark_dirty(self, folder_id: str):
286
- self._conn.execute(
317
+ _safe_write(
318
+ self._conn,
287
319
  "UPDATE cache SET dirty=1 WHERE namespace=? AND key LIKE ?",
288
320
  (self._NS, f"{folder_id}:%"),
289
321
  )
290
- self._conn.commit()
291
322
  logger.debug("Marked drive folder cache dirty for %s", folder_id)
292
323
 
293
324
  def mark_all_dirty(self):
294
- self._conn.execute("UPDATE cache SET dirty=1 WHERE namespace=?", (self._NS,))
295
- self._conn.commit()
325
+ _safe_write(self._conn, "UPDATE cache SET dirty=1 WHERE namespace=?", (self._NS,))
296
326
  logger.debug("Invalidated all drive folder cache entries")
297
327
 
298
328
  def close(self):
@@ -310,17 +340,19 @@ class DocContentCache:
310
340
  logger.debug("Doc cache opened: %s", db_path)
311
341
 
312
342
  def _get_valid(self, file_id: str) -> sqlite3.Row | None:
313
- row = self._conn.execute(
343
+ row = _safe_fetchone(
344
+ self._conn,
314
345
  "SELECT value, fetched_at, dirty FROM cache WHERE namespace=? AND key=?",
315
346
  (self._NS, file_id),
316
- ).fetchone()
347
+ )
317
348
  if row is None or row["dirty"]:
318
349
  return None
319
350
  if time.time() - row["fetched_at"] > self._ttl:
320
- self._conn.execute(
321
- "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?", (self._NS, file_id)
351
+ _safe_write(
352
+ self._conn,
353
+ "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?",
354
+ (self._NS, file_id),
322
355
  )
323
- self._conn.commit()
324
356
  return None
325
357
  return row
326
358
 
@@ -333,25 +365,24 @@ class DocContentCache:
333
365
  return json.loads(row["value"])
334
366
 
335
367
  def store(self, file_id: str, doc: dict):
336
- self._conn.execute(
368
+ _safe_write(
369
+ self._conn,
337
370
  "INSERT OR REPLACE INTO cache (namespace, key, value, fetched_at, dirty)"
338
371
  " VALUES (?,?,?,?,0)",
339
372
  (self._NS, file_id, json.dumps(doc), time.time()),
340
373
  )
341
- self._conn.commit()
342
374
  logger.debug("Cached doc %s", file_id)
343
375
 
344
376
  def mark_dirty(self, file_id: str):
345
- self._conn.execute(
377
+ _safe_write(
378
+ self._conn,
346
379
  "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?",
347
380
  (self._NS, file_id),
348
381
  )
349
- self._conn.commit()
350
382
  logger.debug("Marked doc cache dirty for %s", file_id)
351
383
 
352
384
  def mark_all_dirty(self):
353
- self._conn.execute("UPDATE cache SET dirty=1 WHERE namespace=?", (self._NS,))
354
- self._conn.commit()
385
+ _safe_write(self._conn, "UPDATE cache SET dirty=1 WHERE namespace=?", (self._NS,))
355
386
  logger.debug("Invalidated all doc cache entries")
356
387
 
357
388
  def close(self):
@@ -370,17 +401,17 @@ class CalendarCache:
370
401
  logger.debug("Calendar cache opened: %s", db_path)
371
402
 
372
403
  def _get_valid(self, key: str) -> sqlite3.Row | None:
373
- row = self._conn.execute(
404
+ row = _safe_fetchone(
405
+ self._conn,
374
406
  "SELECT value, fetched_at, dirty FROM cache WHERE namespace=? AND key=?",
375
407
  (self._NS, key),
376
- ).fetchone()
408
+ )
377
409
  if row is None or row["dirty"]:
378
410
  return None
379
411
  if time.time() - row["fetched_at"] > self._ttl:
380
- self._conn.execute(
381
- "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?", (self._NS, key)
412
+ _safe_write(
413
+ self._conn, "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?", (self._NS, key)
382
414
  )
383
- self._conn.commit()
384
415
  return None
385
416
  return row
386
417
 
@@ -393,12 +424,12 @@ class CalendarCache:
393
424
  return json.loads(row["value"])
394
425
 
395
426
  def store_list(self, calendars: list):
396
- self._conn.execute(
427
+ _safe_write(
428
+ self._conn,
397
429
  "INSERT OR REPLACE INTO cache (namespace, key, value, fetched_at, dirty)"
398
430
  " VALUES (?,?,?,?,0)",
399
431
  (self._NS, self._LIST_KEY, json.dumps(calendars), time.time()),
400
432
  )
401
- self._conn.commit()
402
433
  logger.debug("Cached calendar list (%d entries)", len(calendars))
403
434
 
404
435
  def get(self, calendar_id: str) -> dict | None:
@@ -410,25 +441,23 @@ class CalendarCache:
410
441
  return json.loads(row["value"])
411
442
 
412
443
  def store(self, calendar_id: str, calendar: dict):
413
- self._conn.execute(
444
+ _safe_write(
445
+ self._conn,
414
446
  "INSERT OR REPLACE INTO cache (namespace, key, value, fetched_at, dirty)"
415
447
  " VALUES (?,?,?,?,0)",
416
448
  (self._NS, calendar_id, json.dumps(calendar), time.time()),
417
449
  )
418
- self._conn.commit()
419
450
  logger.debug("Cached calendar %s", calendar_id)
420
451
 
421
452
  def mark_dirty(self, calendar_id: str):
422
453
  for key in (calendar_id, self._LIST_KEY):
423
- self._conn.execute(
424
- "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?", (self._NS, key)
454
+ _safe_write(
455
+ self._conn, "UPDATE cache SET dirty=1 WHERE namespace=? AND key=?", (self._NS, key)
425
456
  )
426
- self._conn.commit()
427
457
  logger.debug("Marked calendar cache dirty for %s", calendar_id)
428
458
 
429
459
  def mark_all_dirty(self):
430
- self._conn.execute("UPDATE cache SET dirty=1 WHERE namespace=?", (self._NS,))
431
- self._conn.commit()
460
+ _safe_write(self._conn, "UPDATE cache SET dirty=1 WHERE namespace=?", (self._NS,))
432
461
  logger.debug("Invalidated all calendar cache entries")
433
462
 
434
463
  def close(self):
@@ -43,8 +43,7 @@ class Run:
43
43
 
44
44
  @dataclass
45
45
  class Cell:
46
- runs: list[Run]
47
- nested_table: Table | None = None # one nested table rendered inside this cell
46
+ children: list[Run | Table] # ordered text runs and nested tables, in source order
48
47
  colspan: int = 1
49
48
  rowspan: int = 1
50
49
  is_header: bool = False