@zackbart/connecta 0.34.1 → 0.36.0
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.
- package/CHANGELOG.md +418 -0
- package/README.md +65 -105
- package/SECURITY.md +3 -2
- package/bin/connecta.mjs +41 -16
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.d.ts +2 -1
- package/dist/access-tokens.js +66 -30
- package/dist/activity.d.ts +1 -1
- package/dist/activity.js +2 -0
- package/dist/agent-config.d.ts +23 -0
- package/dist/agent-config.js +59 -0
- package/dist/agent-description.js +16 -0
- package/dist/agent-identity.js +4 -4
- package/dist/agent-observation.js +39 -0
- package/dist/auth/clerk-sdk/client.d.ts +2 -0
- package/dist/auth/clerk-sdk/generated.js +554 -563
- package/dist/auth/clerk-transport.js +70 -3
- package/dist/auth/clerk.d.ts +13 -0
- package/dist/auth/clerk.js +653 -246
- package/dist/auth/cloudflare-access.js +46 -42
- package/dist/auth/downstream-client-metadata.js +2 -2
- package/dist/auth/downstream-oauth.js +2044 -1161
- package/dist/auth/downstream-revocation.js +88 -88
- package/dist/auth/native-call.js +20 -0
- package/dist/auth/native.js +184 -0
- package/dist/auth/oauth-start-reset.js +11 -5
- package/dist/auth/oauth-token-response.js +121 -102
- package/dist/auth/oauth-v2-migration.js +97 -78
- package/dist/auth/renewable-grant.js +10 -0
- package/dist/auth/static-oauth.js +751 -281
- package/dist/auth/vercel-agent.js +266 -0
- package/dist/auth-admission.js +204 -0
- package/dist/auth-elicitation.js +350 -241
- package/dist/authorization-handoff.js +164 -90
- package/dist/call-admission-context.js +3 -10
- package/dist/call-admission.js +2 -0
- package/dist/call-classification.js +9 -0
- package/dist/call-recovery.js +32 -13
- package/dist/canonical-json.js +8 -0
- package/dist/catalog-cache.js +369 -213
- package/dist/catalog-service.js +659 -530
- package/dist/catalog.js +5 -1
- package/dist/concurrency.js +0 -25
- package/dist/config-defaults.d.ts +1 -0
- package/dist/config-defaults.js +1 -0
- package/dist/config-schema.d.ts +3 -1
- package/dist/config-schema.js +13 -3
- package/dist/config.d.ts +40 -6
- package/dist/config.js +40 -26
- package/dist/connector-caller.d.ts +4 -0
- package/dist/connector-caller.js +17 -4
- package/dist/connectors/api-connector.d.ts +29 -7
- package/dist/connectors/api-connector.js +145 -57
- package/dist/connectors/guarded-fetch.d.ts +51 -17
- package/dist/connectors/guarded-fetch.js +215 -121
- package/dist/connectors/negotiation-cache.js +20 -10
- package/dist/connectors/remote-mcp.d.ts +10 -30
- package/dist/connectors/remote-mcp.js +955 -563
- package/dist/d1.js +10 -243
- package/dist/derived-context.js +5 -0
- package/dist/describe-config.d.ts +3 -1
- package/dist/describe-config.js +2 -0
- package/dist/downstream-input.js +347 -260
- package/dist/errors.d.ts +87 -6
- package/dist/errors.js +114 -4
- package/dist/execute.js +87 -50
- package/dist/executor-admission.js +3 -0
- package/dist/executors/quickjs.js +163 -112
- package/dist/guarded-transport.js +50 -0
- package/dist/guest-runtime.js +1 -1
- package/dist/http/client.js +439 -0
- package/dist/http/errors.js +558 -0
- package/dist/http/fields.js +49 -0
- package/dist/http/hatch-paging.js +318 -0
- package/dist/http/hatch.js +1279 -0
- package/dist/http/json-schema.js +267 -0
- package/dist/http/node-transport.js +241 -0
- package/dist/http/oauth.js +162 -0
- package/dist/http/paging.js +227 -0
- package/dist/http/provider.js +221 -0
- package/dist/http/safety.js +82 -0
- package/dist/http/services.js +79 -0
- package/dist/http/tools.js +379 -0
- package/dist/http/vendor-index.js +138 -0
- package/dist/http/vendor.js +448 -0
- package/dist/http/write-request.js +66 -0
- package/dist/http-policy.d.ts +11 -0
- package/dist/http-policy.js +45 -0
- package/dist/inbound-credential.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -0
- package/dist/invocation-auth.js +30 -19
- package/dist/invocation.js +218 -85
- package/dist/mcp-result.js +0 -4
- package/dist/membership.d.ts +4 -0
- package/dist/meta-output.js +1 -1
- package/dist/meta-tools.js +531 -406
- package/dist/native-recovery.js +52 -0
- package/dist/native-tools.js +382 -0
- package/dist/node.d.ts +4 -3
- package/dist/node.js +45 -8
- package/dist/oauth-handoff.js +177 -104
- package/dist/oauth-partition.js +6 -20
- package/dist/oauth-sealing.js +6 -7
- package/dist/operation-vocabulary.js +101 -0
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +17 -4
- package/dist/operator-ui/contract.d.ts +8 -3
- package/dist/operator-ui/generated.js +4 -4
- package/dist/operator-ui/model.d.ts +2 -3
- package/dist/program-writes.js +47 -26
- package/dist/provider-call-facts.d.ts +115 -0
- package/dist/provider-call-facts.js +182 -0
- package/dist/provider-call.d.ts +112 -0
- package/dist/provider-call.js +271 -0
- package/dist/provider.d.ts +65 -31
- package/dist/provider.js +116 -39
- package/dist/providers/_shared/google/hatch.js +91 -0
- package/dist/providers/_shared/google/kit.js +427 -0
- package/dist/providers/_shared/google/result-size.js +15 -2
- package/dist/providers/_shared/google/service-account.d.ts +2 -1
- package/dist/providers/_shared/google/service-account.js +8 -4
- package/dist/providers/_shared/google/workspace.d.ts +6 -210
- package/dist/providers/_shared/google/workspace.js +6 -425
- package/dist/providers/_shared/rest/compact-index.js +86 -0
- package/dist/providers/_shared/rest/kit.js +146 -0
- package/dist/providers/_shared/rest/operation-index.js +230 -14
- package/dist/providers/_shared/rest/response-media.js +11 -0
- package/dist/providers/_shared/rest/value-safety.js +353 -195
- package/dist/providers/_shared/sensitive-fields.js +27 -0
- package/dist/providers/breeze/api.js +584 -0
- package/dist/providers/breeze/index.d.ts +1 -3
- package/dist/providers/breeze/index.js +67 -1420
- package/dist/providers/breeze/openapi.generated.js +60 -0
- package/dist/providers/breeze/skill.generated.js +2 -6
- package/dist/providers/breeze/tools.js +696 -0
- package/dist/providers/ccb/api.js +496 -0
- package/dist/providers/ccb/index.d.ts +15 -37
- package/dist/providers/ccb/index.js +77 -1702
- package/dist/providers/ccb/lib/constants.d.ts +34 -0
- package/dist/providers/ccb/lib/constants.js +74 -0
- package/dist/providers/ccb/lib/guide.js +9 -0
- package/dist/providers/ccb/lib/verdicts.js +387 -0
- package/dist/providers/ccb/openapi.generated.js +397 -0
- package/dist/providers/ccb/safety.js +295 -0
- package/dist/providers/ccb/skill.generated.js +2 -6
- package/dist/providers/ccb/tools.js +853 -0
- package/dist/providers/cloudflare/api.js +1066 -0
- package/dist/providers/cloudflare/index.d.ts +5 -22
- package/dist/providers/cloudflare/index.js +79 -197
- package/dist/providers/cloudflare/lib/config.d.ts +16 -0
- package/dist/providers/cloudflare/lib/config.js +2 -0
- package/dist/providers/cloudflare/lib/discovery.js +29 -0
- package/dist/providers/cloudflare/{graphql.js → lib/graphql.js} +1 -1
- package/dist/providers/cloudflare/lib/hatch.js +103 -0
- package/dist/providers/cloudflare/lib/path-bounds.js +28 -0
- package/dist/providers/cloudflare/lib/response-media.js +75 -0
- package/dist/providers/cloudflare/openapi.generated.js +4 -3
- package/dist/providers/cloudflare/{value-safety.js → safety.js} +487 -0
- package/dist/providers/cloudflare/skill.generated.js +2 -10
- package/dist/providers/cloudflare/tools.js +360 -0
- package/dist/providers/docs/api.js +51 -0
- package/dist/providers/docs/index.d.ts +1 -12
- package/dist/providers/docs/index.js +53 -1165
- package/dist/providers/docs/lib/constants.d.ts +18 -0
- package/dist/providers/docs/lib/constants.js +18 -0
- package/dist/providers/docs/lib/discovery.js +26 -0
- package/dist/providers/docs/lib/requests.js +48 -0
- package/dist/providers/docs/lib/schemas.js +168 -0
- package/dist/providers/docs/openapi.generated.js +16 -0
- package/dist/providers/docs/safety.js +54 -0
- package/dist/providers/docs/skill.generated.js +2 -6
- package/dist/providers/docs/tools.js +801 -0
- package/dist/providers/drive/api.js +177 -0
- package/dist/providers/drive/index.d.ts +1 -12
- package/dist/providers/drive/index.js +38 -1448
- package/dist/providers/drive/lib/constants.d.ts +6 -0
- package/dist/providers/drive/lib/constants.js +6 -0
- package/dist/providers/drive/lib/contracts.js +532 -0
- package/dist/providers/drive/lib/hatch.js +197 -0
- package/dist/providers/drive/openapi.generated.js +77 -0
- package/dist/providers/drive/safety.js +90 -0
- package/dist/providers/drive/skill.generated.js +2 -6
- package/dist/providers/drive/tools.js +1290 -0
- package/dist/providers/forms/api.js +71 -0
- package/dist/providers/forms/index.d.ts +1 -12
- package/dist/providers/forms/index.js +49 -1145
- package/dist/providers/forms/lib/constants.d.ts +14 -0
- package/dist/providers/forms/lib/constants.js +19 -0
- package/dist/providers/forms/lib/hatch.js +72 -0
- package/dist/providers/forms/openapi.generated.js +23 -0
- package/dist/providers/forms/safety.js +34 -0
- package/dist/providers/forms/skill.generated.js +2 -8
- package/dist/providers/forms/tools.js +1023 -0
- package/dist/providers/github/api.js +150 -0
- package/dist/providers/github/index.d.ts +4 -5
- package/dist/providers/github/index.js +30 -87
- package/dist/providers/github/{auth.d.ts → lib/auth.d.ts} +5 -3
- package/dist/providers/github/{auth.js → lib/auth.js} +40 -43
- package/dist/providers/github/lib/discovery.js +132 -0
- package/dist/providers/github/lib/operations.js +58 -0
- package/dist/providers/github/{scope.d.ts → lib/scope.d.ts} +2 -0
- package/dist/providers/github/{scope.js → lib/scope.js} +25 -1
- package/dist/providers/github/openapi.generated.js +1216 -0
- package/dist/providers/github/safety.js +216 -0
- package/dist/providers/github/skill.generated.js +2 -5
- package/dist/providers/github/tools.js +506 -0
- package/dist/providers/gmail/api.js +114 -0
- package/dist/providers/gmail/index.d.ts +1 -12
- package/dist/providers/gmail/index.js +73 -1621
- package/dist/providers/gmail/lib/constants.d.ts +13 -0
- package/dist/providers/gmail/lib/constants.js +16 -0
- package/dist/providers/gmail/lib/index.js +173 -0
- package/dist/providers/gmail/lib/schemas.js +176 -0
- package/dist/providers/gmail/lib/threading.js +120 -0
- package/dist/providers/gmail/openapi.generated.js +92 -0
- package/dist/providers/gmail/safety.js +21 -0
- package/dist/providers/gmail/skill.generated.js +2 -6
- package/dist/providers/gmail/tools.js +1435 -0
- package/dist/providers/overflow/api.js +1008 -0
- package/dist/providers/overflow/index.d.ts +1 -17
- package/dist/providers/overflow/index.js +60 -1488
- package/dist/providers/overflow/skill.generated.js +2 -5
- package/dist/providers/overflow/tools.js +873 -0
- package/dist/providers/planning-center/api.js +467 -0
- package/dist/providers/planning-center/index.d.ts +4 -35
- package/dist/providers/planning-center/index.js +69 -2445
- package/dist/providers/planning-center/lib/constants.d.ts +58 -0
- package/dist/providers/planning-center/lib/constants.js +83 -0
- package/dist/providers/planning-center/lib/discovery.js +40 -0
- package/dist/providers/planning-center/lib/guide.js +39 -0
- package/dist/providers/planning-center/lib/json-api.js +83 -0
- package/dist/providers/planning-center/lib/verdicts.js +1758 -0
- package/dist/providers/planning-center/openapi.generated.js +1764 -0
- package/dist/providers/planning-center/safety.js +188 -0
- package/dist/providers/planning-center/skill.generated.js +2 -27
- package/dist/providers/planning-center/tools.js +1010 -0
- package/dist/providers/quickbooks/api.js +299 -0
- package/dist/providers/quickbooks/index.d.ts +13 -0
- package/dist/providers/quickbooks/index.js +93 -0
- package/dist/providers/quickbooks/lib/query.js +118 -0
- package/dist/providers/quickbooks/lib/writes.js +168 -0
- package/dist/providers/quickbooks/safety.js +222 -0
- package/dist/providers/quickbooks/skill.generated.js +5 -0
- package/dist/providers/quickbooks/tools.js +117 -0
- package/dist/providers/sheets/api.js +133 -0
- package/dist/providers/sheets/index.d.ts +4 -16
- package/dist/providers/sheets/index.js +61 -1172
- package/dist/providers/sheets/lib/constants.d.ts +14 -0
- package/dist/providers/sheets/lib/constants.js +14 -0
- package/dist/providers/sheets/lib/hatch.js +163 -0
- package/dist/providers/sheets/lib/requests.js +105 -0
- package/dist/providers/sheets/lib/schemas.js +138 -0
- package/dist/providers/sheets/openapi.generated.js +30 -0
- package/dist/providers/sheets/safety.js +27 -0
- package/dist/providers/sheets/skill.generated.js +2 -7
- package/dist/providers/sheets/tools.js +733 -0
- package/dist/providers/slides/api.js +147 -0
- package/dist/providers/slides/index.d.ts +2 -10
- package/dist/providers/slides/index.js +48 -2555
- package/dist/providers/slides/lib/constants.d.ts +11 -0
- package/dist/providers/slides/lib/constants.js +11 -0
- package/dist/providers/slides/lib/requests.js +60 -0
- package/dist/providers/slides/lib/schemas.js +565 -0
- package/dist/providers/slides/openapi.generated.js +18 -0
- package/dist/providers/slides/skill.generated.js +2 -6
- package/dist/providers/slides/tools.js +1968 -0
- package/dist/providers/stripe/api.js +401 -0
- package/dist/providers/stripe/index.d.ts +5 -27
- package/dist/providers/stripe/index.js +74 -210
- package/dist/providers/stripe/lib/config.d.ts +4 -0
- package/dist/providers/stripe/lib/config.js +2 -0
- package/dist/providers/stripe/openapi.generated.js +4 -3
- package/dist/providers/stripe/{value-safety.js → safety.js} +5 -5
- package/dist/providers/stripe/skill.generated.js +2 -11
- package/dist/providers/stripe/tools.js +77 -0
- package/dist/providers/tithely/api.js +531 -0
- package/dist/providers/tithely/index.d.ts +17 -0
- package/dist/providers/tithely/index.js +86 -965
- package/dist/providers/tithely/skill.generated.js +2 -5
- package/dist/providers/tithely/tools.js +426 -0
- package/dist/providers/vercel/api.js +551 -0
- package/dist/providers/vercel/index.d.ts +9 -25
- package/dist/providers/vercel/index.js +65 -260
- package/dist/providers/vercel/lib/config.d.ts +2 -0
- package/dist/providers/vercel/lib/config.js +2 -0
- package/dist/providers/vercel/lib/index.js +117 -0
- package/dist/providers/vercel/lib/logs.js +100 -0
- package/dist/providers/vercel/lib/pin.js +270 -0
- package/dist/providers/vercel/openapi.generated.js +10 -5
- package/dist/providers/vercel/{value-safety.js → safety.js} +247 -38
- package/dist/providers/vercel/skill.generated.js +2 -7
- package/dist/providers/vercel/tools.js +642 -0
- package/dist/redaction-identity.js +47 -0
- package/dist/redaction-marker.js +125 -0
- package/dist/registry.d.ts +21 -15
- package/dist/registry.js +409 -376
- package/dist/routes/access-tokens.js +1 -1
- package/dist/routes/actor-labels.js +1 -1
- package/dist/routes/connect.js +1 -1
- package/dist/routes/dispatch.js +103 -0
- package/dist/routes/mcp.js +170 -79
- package/dist/routes/oauth.js +6 -1
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +5 -4
- package/dist/routes/shared.js +244 -177
- package/dist/routes/skills.js +10 -8
- package/dist/routes/ui-config.js +27 -3
- package/dist/routes/ui-data.js +21 -20
- package/dist/routes/ui.js +1 -1
- package/dist/runtime/call-admission.js +0 -26
- package/dist/runtime/call-entry.js +30 -0
- package/dist/runtime/call.js +552 -0
- package/dist/runtime/connector-scope.js +7 -0
- package/dist/runtime/credential-probe.js +34 -0
- package/dist/runtime/executor.js +40 -0
- package/dist/runtime/native-storage.js +63 -0
- package/dist/runtime/registry.js +27 -0
- package/dist/runtime/request-scope.js +72 -0
- package/dist/runtime/run.js +15 -7
- package/dist/runtime/shared-read.js +34 -5
- package/dist/runtime/storage.js +34 -21
- package/dist/sent-secrets.d.ts +194 -37
- package/dist/sent-secrets.js +1743 -340
- package/dist/server.js +5 -56
- package/dist/skills.js +322 -223
- package/dist/sqlite.js +15 -14
- package/dist/storage/d1-copy.js +255 -0
- package/dist/storage/d1-driver.js +15 -0
- package/dist/storage/keys.js +28 -0
- package/dist/storage/sql-driver.js +88 -0
- package/dist/storage/sql-native.js +544 -0
- package/dist/storage/sql-schema.d.ts +6 -0
- package/dist/storage/sql-schema.js +15 -0
- package/dist/storage/sql.d.ts +14 -20
- package/dist/storage/sql.js +29 -535
- package/dist/timeout.js +0 -26
- package/dist/tool-safety.js +20 -10
- package/dist/types.d.ts +9 -109
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +0 -1
- package/dist/upward.js +3 -0
- package/dist/usage-guide.js +3 -1
- package/dist/validate.d.ts +4 -2
- package/dist/validate.js +86 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +312 -292
- package/dist/workspace-subject.js +5 -1
- package/dist/write-outcome.js +454 -34
- package/documentation/README.md +28 -7
- package/documentation/architecture.md +2409 -255
- package/documentation/auth.md +867 -114
- package/documentation/code-mode.md +49 -16
- package/documentation/deploying.md +164 -20
- package/documentation/hosted-mcp-migration.md +460 -0
- package/documentation/integrating.md +129 -144
- package/documentation/meta-tools.md +409 -32
- package/documentation/operating.md +27 -3
- package/documentation/operator-tests.md +3 -0
- package/documentation/operator-ui.md +84 -48
- package/documentation/provider-auth-migration.md +16 -63
- package/documentation/provider-migration-0.29.md +10 -5
- package/documentation/provider-migration-0.35.md +975 -0
- package/documentation/providers.md +1347 -0
- package/examples/worker/AGENTS.md +23 -9
- package/examples/worker/README.md +254 -135
- package/examples/worker/alchemy.run.ts +116 -0
- package/examples/worker/gitignore +10 -0
- package/examples/worker/src/connecta.config.ts +23 -21
- package/examples/worker/wrangler.jsonc +14 -5
- package/guides/connecting-agents.md +157 -0
- package/guides/how-it-works.md +156 -0
- package/guides/safety-and-trust.md +153 -0
- package/package.json +30 -50
- package/templates/node/.env.example +3 -0
- package/templates/node/README.md +22 -2
- package/templates/node/docker-compose.yml +1 -0
- package/templates/node/package.json +3 -2
- package/templates/node/src/connecta.config.ts +8 -0
- package/dist/catalog-drift.d.ts +0 -138
- package/dist/catalog-drift.js +0 -578
- package/dist/operator-ui/agents.d.ts +0 -74
- package/dist/operator-ui/agents.js +0 -104
- package/dist/operator-ui/config-summary.js +0 -565
- package/dist/operator-ui/connection-map.js +0 -376
- package/dist/operator-ui/endpoints.js +0 -221
- package/dist/operator-ui/fix-prompts.d.ts +0 -18
- package/dist/operator-ui/fix-prompts.js +0 -87
- package/dist/operator-ui/health.js +0 -52
- package/dist/operator-ui/member-page.js +0 -97
- package/dist/operator-ui/overview.js +0 -123
- package/dist/operator-ui/setup-commands.js +0 -51
- package/dist/operator-ui/view.d.ts +0 -401
- package/dist/operator-ui/view.js +0 -705
- package/dist/providers/_shared/request-token.js +0 -83
- package/dist/providers/_shared/rest/dispatch.js +0 -41
- package/dist/providers/_shared/rest/operation-index.d.ts +0 -133
- package/dist/providers/_shared/rest/tools.d.ts +0 -141
- package/dist/providers/_shared/rest/tools.js +0 -569
- package/dist/providers/_shared/rest/value-safety.d.ts +0 -201
- package/dist/providers/basecamp/index.d.ts +0 -45
- package/dist/providers/basecamp/index.js +0 -997
- package/dist/providers/basecamp/skill.generated.js +0 -9
- package/dist/providers/cloudflare/rest.d.ts +0 -37
- package/dist/providers/cloudflare/rest.js +0 -1097
- package/dist/providers/github/hosted.js +0 -218
- package/dist/providers/github/rest.js +0 -321
- package/dist/providers/infisical/index.d.ts +0 -7
- package/dist/providers/infisical/index.js +0 -713
- package/dist/providers/infisical/skill.generated.js +0 -8
- package/dist/providers/intercom/index.d.ts +0 -24
- package/dist/providers/intercom/index.js +0 -121
- package/dist/providers/intercom/skill.generated.js +0 -8
- package/dist/providers/linear/index.d.ts +0 -55
- package/dist/providers/linear/index.js +0 -537
- package/dist/providers/linear/skill.generated.js +0 -8
- package/dist/providers/mixpanel/index.d.ts +0 -59
- package/dist/providers/mixpanel/index.js +0 -472
- package/dist/providers/mixpanel/operations.js +0 -20
- package/dist/providers/mixpanel/rest.js +0 -603
- package/dist/providers/mixpanel/skill.generated.js +0 -10
- package/dist/providers/mixpanel/value-safety.js +0 -16
- package/dist/providers/notion/index.d.ts +0 -67
- package/dist/providers/notion/index.js +0 -368
- package/dist/providers/notion/openapi.generated.js +0 -75
- package/dist/providers/notion/rest.d.ts +0 -24
- package/dist/providers/notion/rest.js +0 -1226
- package/dist/providers/notion/skill.generated.js +0 -11
- package/dist/providers/notion/value-safety.js +0 -56
- package/dist/providers/revenuecat/index.d.ts +0 -51
- package/dist/providers/revenuecat/index.js +0 -697
- package/dist/providers/revenuecat/skill.generated.js +0 -11
- package/dist/providers/stripe/rest.d.ts +0 -14
- package/dist/providers/stripe/rest.js +0 -402
- package/dist/providers/vercel/rest.d.ts +0 -20
- package/dist/providers/vercel/rest.js +0 -1020
- package/dist/providers/wispr-flow/index.d.ts +0 -14
- package/dist/providers/wispr-flow/index.js +0 -117
- package/dist/providers/wispr-flow/skill.generated.js +0 -8
- /package/dist/providers/github/{key.js → lib/key.js} +0 -0
|
@@ -20,7 +20,7 @@ import { skill } from "./skill.generated.js";
|
|
|
20
20
|
* shared Workspace result budget, so it is deliverable inside a program and
|
|
21
21
|
* directly alike, and whatever a result cannot carry is named with the exact
|
|
22
22
|
* cursor that reads it: text cut on a slide continues in get_page, element by
|
|
23
|
-
* element and character by character;
|
|
23
|
+
* element and character by character; full structural JSON comes from slides_api_read;
|
|
24
24
|
* layouts continue in list_layouts. Every cursor is bound to the deck and to
|
|
25
25
|
* the revision it was issued at, so a deck that changed between pages is a
|
|
26
26
|
* `conflict` to restart, never a page that skips or repeats.
|
|
@@ -43,2523 +43,14 @@ import { skill } from "./skill.generated.js";
|
|
|
43
43
|
* call, and `pnpm run providers:check --provider slides` reports a touched
|
|
44
44
|
* contract that moved or a method that stopped accepting the scope below.
|
|
45
45
|
*/
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
* Exactly the scope this connection requests, and exactly what the Admin
|
|
55
|
-
* console's domain-wide delegation entry must list for it. `presentations`
|
|
56
|
-
* reads and writes every deck the user can open; Slides offers nothing
|
|
57
|
-
* narrower that can also write.
|
|
58
|
-
*/
|
|
59
|
-
export const SLIDES_SCOPES = ["https://www.googleapis.com/auth/presentations"];
|
|
60
|
-
/** Slides per page of get_presentation. Slides itself returns the whole deck. */
|
|
61
|
-
const MAX_PAGE_SIZE = 100;
|
|
62
|
-
const DEFAULT_PAGE_SIZE = 20;
|
|
63
|
-
/** Elements per page of get_page, and rows per page of list_layouts. */
|
|
64
|
-
const MAX_ROWS = 500;
|
|
65
|
-
const DEFAULT_ELEMENTS = 50;
|
|
66
|
-
const DEFAULT_LAYOUTS = 100;
|
|
67
|
-
/** Text kept per slide (its elements together, and its notes apart). */
|
|
68
|
-
const DEFAULT_SLIDE_CHARS = 4_000;
|
|
69
|
-
const MAX_SLIDE_CHARS = 50_000;
|
|
70
|
-
/** A whole presentation without styles; with raw: true, with them. */
|
|
71
|
-
const SLIDES_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
72
|
-
/** Requests per raw batch: connecta's bound; Slides publishes none. */
|
|
73
|
-
const MAX_BATCH_REQUESTS = 100;
|
|
74
|
-
const MAX_REPLACEMENTS = 50;
|
|
75
|
-
/**
|
|
76
|
-
* Every kind of Request Slides' batchUpdate accepts, from its Discovery
|
|
77
|
-
* document's `Request` schema. The raw hatch checks each request's one key
|
|
78
|
-
* against this list, and nothing inside it: an unknown kind is a typo or a
|
|
79
|
-
* new kind to review here, and refusing it locally costs no round trip. The
|
|
80
|
-
* contents are Slides' to validate. Too many names for a schema enum inside
|
|
81
|
-
* the H7 budget, so the handler holds the list. A kind Google adds moves
|
|
82
|
-
* the batchUpdate contract `src/providers/slides/drift.json` records.
|
|
83
|
-
*/
|
|
84
|
-
const REQUEST_KINDS = new Set([
|
|
85
|
-
"addCommentReply",
|
|
86
|
-
"createImage",
|
|
87
|
-
"createLine",
|
|
88
|
-
"createParagraphBullets",
|
|
89
|
-
"createShape",
|
|
90
|
-
"createSheetsChart",
|
|
91
|
-
"createSlide",
|
|
92
|
-
"createTable",
|
|
93
|
-
"createVideo",
|
|
94
|
-
"deleteComment",
|
|
95
|
-
"deleteCommentReply",
|
|
96
|
-
"deleteObject",
|
|
97
|
-
"deleteParagraphBullets",
|
|
98
|
-
"deleteTableColumn",
|
|
99
|
-
"deleteTableRow",
|
|
100
|
-
"deleteText",
|
|
101
|
-
"duplicateObject",
|
|
102
|
-
"groupObjects",
|
|
103
|
-
"insertComment",
|
|
104
|
-
"insertTableColumns",
|
|
105
|
-
"insertTableRows",
|
|
106
|
-
"insertText",
|
|
107
|
-
"mergeTableCells",
|
|
108
|
-
"refreshSheetsChart",
|
|
109
|
-
"replaceAllShapesWithImage",
|
|
110
|
-
"replaceAllShapesWithSheetsChart",
|
|
111
|
-
"replaceAllText",
|
|
112
|
-
"replaceImage",
|
|
113
|
-
"rerouteLine",
|
|
114
|
-
"ungroupObjects",
|
|
115
|
-
"unmergeTableCells",
|
|
116
|
-
"updateCommentPost",
|
|
117
|
-
"updateImageProperties",
|
|
118
|
-
"updateLineCategory",
|
|
119
|
-
"updateLineProperties",
|
|
120
|
-
"updatePageElementAltText",
|
|
121
|
-
"updatePageElementTransform",
|
|
122
|
-
"updatePageElementsZOrder",
|
|
123
|
-
"updatePageProperties",
|
|
124
|
-
"updateParagraphStyle",
|
|
125
|
-
"updateShapeProperties",
|
|
126
|
-
"updateSlideProperties",
|
|
127
|
-
"updateSlidesPosition",
|
|
128
|
-
"updateTableBorderProperties",
|
|
129
|
-
"updateTableCellProperties",
|
|
130
|
-
"updateTableColumnProperties",
|
|
131
|
-
"updateTableRowProperties",
|
|
132
|
-
"updateTextStyle",
|
|
133
|
-
"updateVideoProperties",
|
|
134
|
-
]);
|
|
135
|
-
/** What get_presentation's first page may spend on its layout preview. */
|
|
136
|
-
const MAX_LAYOUT_PREVIEW_BYTES = 24 * 1024;
|
|
137
|
-
/** Byte bounds for short labels: names, titles, alt text. */
|
|
138
|
-
const MAX_NAME_BYTES = 256;
|
|
139
|
-
const MAX_TITLE_BYTES = 4 * 1024;
|
|
140
|
-
const MAX_ALT_TEXT_BYTES = 4 * 1024;
|
|
141
|
-
/** A slide title in get_presentation, in characters, before maxCharsPerSlide. */
|
|
142
|
-
const MAX_TITLE_CHARS = 500;
|
|
143
|
-
/** The longest thumbnail link passed on; a real one is well under 2 KB. */
|
|
144
|
-
const MAX_URL_BYTES = 16 * 1024;
|
|
145
|
-
/** One batchUpdate reply kept whole up to this; a larger one is summarized. */
|
|
146
|
-
const MAX_REPLY_BYTES = 2 * 1024;
|
|
147
|
-
/** How deep, and how many array items, a reply summary walks. */
|
|
148
|
-
const MAX_REPLY_DEPTH = 12;
|
|
149
|
-
const MAX_REPLY_ITEMS = 20;
|
|
150
|
-
/** An id or revision longer than this is not copied into a result. */
|
|
151
|
-
const MAX_ID_BYTES = 1024;
|
|
152
|
-
/** replace_all_text echoes each find this far, in order with its count. */
|
|
153
|
-
const MAX_ECHO_CHARS = 100;
|
|
154
|
-
/**
|
|
155
|
-
* The comment kinds of Slides' Request. A batch carrying any of them has
|
|
156
|
-
* comment changes Slides saves apart from the deck's, and only `ALL_SAVED`
|
|
157
|
-
* says they all landed (#696).
|
|
158
|
-
*/
|
|
159
|
-
const COMMENT_KINDS = new Set([
|
|
160
|
-
"addCommentReply",
|
|
161
|
-
"deleteComment",
|
|
162
|
-
"deleteCommentReply",
|
|
163
|
-
"insertComment",
|
|
164
|
-
"updateCommentPost",
|
|
165
|
-
]);
|
|
166
|
-
/** The comment save states that say every comment change in a batch landed, or none was asked for. */
|
|
167
|
-
const COMMENTS_SETTLED = new Set(["NO_UPDATES_REQUESTED", "ALL_SAVED"]);
|
|
168
|
-
/**
|
|
169
|
-
* `commentUpdateState`'s zero value. ProtoJSON omits a zero enum, so a batch
|
|
170
|
-
* with comment requests whose reply has no state is reported as this one:
|
|
171
|
-
* Slides did not say the comments saved.
|
|
172
|
-
*/
|
|
173
|
-
const COMMENT_STATE_UNSPECIFIED = "COMMENT_UPDATE_STATE_UNSPECIFIED";
|
|
174
|
-
/**
|
|
175
|
-
* The only view mode that cannot hide comments silently. The default omits
|
|
176
|
-
* them, and DEFAULT_FOR_CURRENT_ACCESS omits them for a view-only account,
|
|
177
|
-
* which would read as a deck with none; INCLUDED refuses that account with a
|
|
178
|
-
* 403 instead.
|
|
179
|
-
*/
|
|
180
|
-
const COMMENTS_INCLUDED = "COMMENTS_VIEW_MODE_INCLUDED";
|
|
181
|
-
/** Slides' cap on a post's text and an assignee's address, in UTF-8 bytes. */
|
|
182
|
-
const MAX_COMMENT_BYTES = 2048;
|
|
183
|
-
/** Comment threads per page of list_comments. */
|
|
184
|
-
const DEFAULT_THREADS = 50;
|
|
185
|
-
const MAX_THREADS = 500;
|
|
186
|
-
/** A post's text in a write result; Slides caps what it accepts well below this. */
|
|
187
|
-
const MAX_POST_BYTES = 8 * 1024;
|
|
188
|
-
/** EMU per point, the two units a Slides transform uses. */
|
|
189
|
-
const EMU_PER_PT = 12_700;
|
|
190
|
-
/**
|
|
191
|
-
* The partial responses the projected reads ask for: ids, names, and text,
|
|
192
|
-
* without the styles that are most of a deck's bytes. Each element kind keeps
|
|
193
|
-
* one field Slides always sets, so its presence survives the mask. Groups stay
|
|
194
|
-
* whole, because a mask cannot recurse into their children. get_presentation
|
|
195
|
-
* and get_page ask for the same element fields, so both read a page's
|
|
196
|
-
* elements identically — the same reading order, the same text — and a
|
|
197
|
-
* cursor one issues the other honors.
|
|
198
|
-
*/
|
|
199
|
-
const TEXT_ONLY = "text(textElements(textRun(content),autoText(content)))";
|
|
200
|
-
const ELEMENT_FIELDS = "objectId,title,description,transform,elementGroup,line(lineType),image(contentUrl),video(source)," +
|
|
201
|
-
`sheetsChart(spreadsheetId),wordArt,table(rows,columns,tableRows(tableCells(${TEXT_ONLY}))),` +
|
|
202
|
-
`shape(shapeType,placeholder(type,index,parentObjectId),${TEXT_ONLY})`;
|
|
203
|
-
const LAYOUT_FIELDS = "masters(objectId,masterProperties(displayName)),layouts(objectId,layoutProperties(name,displayName,masterObjectId))," +
|
|
204
|
-
"notesMaster(objectId)";
|
|
205
|
-
const PRESENTATION_FIELDS = [
|
|
206
|
-
"presentationId",
|
|
207
|
-
"title",
|
|
208
|
-
"revisionId",
|
|
209
|
-
"locale",
|
|
210
|
-
"pageSize",
|
|
211
|
-
LAYOUT_FIELDS,
|
|
212
|
-
`slides(objectId,slideProperties(layoutObjectId,isSkipped,notesPage(objectId,notesProperties,pageElements(${ELEMENT_FIELDS}))),` +
|
|
213
|
-
`pageElements(${ELEMENT_FIELDS}))`,
|
|
214
|
-
].join(",");
|
|
215
|
-
const PAGE_FIELDS = [
|
|
216
|
-
"objectId",
|
|
217
|
-
"pageType",
|
|
218
|
-
"revisionId",
|
|
219
|
-
"slideProperties(layoutObjectId,masterObjectId,isSkipped,notesPage(objectId))",
|
|
220
|
-
"layoutProperties(name,displayName,masterObjectId)",
|
|
221
|
-
"masterProperties(displayName)",
|
|
222
|
-
"notesProperties(speakerNotesObjectId)",
|
|
223
|
-
`pageElements(${ELEMENT_FIELDS})`,
|
|
224
|
-
].join(",");
|
|
225
|
-
const LAYOUT_LIST_FIELDS = `presentationId,revisionId,${LAYOUT_FIELDS}`;
|
|
226
|
-
/**
|
|
227
|
-
* A deck's threads, and the anchors on every page a comment can sit on that
|
|
228
|
-
* place them: slides, their notes pages, layouts, masters, the notes master.
|
|
229
|
-
*/
|
|
230
|
-
const COMMENT_DECK_FIELDS = "presentationId,revisionId,commentsViewMode,comments," +
|
|
231
|
-
"slides(objectId,commentAnchors,slideProperties(notesPage(objectId,commentAnchors)))," +
|
|
232
|
-
"layouts(objectId,commentAnchors),masters(objectId,commentAnchors),notesMaster(objectId,commentAnchors)";
|
|
233
|
-
const COMMENT_PAGE_FIELDS = "objectId,revisionId,commentsViewMode,comments,commentAnchors";
|
|
234
|
-
function asRecord(value) {
|
|
235
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
236
|
-
}
|
|
237
|
-
function asArray(value) {
|
|
238
|
-
return Array.isArray(value) ? value : [];
|
|
239
|
-
}
|
|
240
|
-
function compact(value) {
|
|
241
|
-
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
242
|
-
}
|
|
243
|
-
function text(value) {
|
|
244
|
-
return typeof value === "string" && value !== "" ? value : undefined;
|
|
245
|
-
}
|
|
246
|
-
function editUrl(presentationId) {
|
|
247
|
-
return `https://docs.google.com/presentation/d/${encodeURIComponent(presentationId)}/edit`;
|
|
248
|
-
}
|
|
249
|
-
// --- Result size --------------------------------------------------------------------
|
|
250
|
-
/**
|
|
251
|
-
* The postcondition every read result meets: one result, under the shared
|
|
252
|
-
* budget, whichever way it was called. Each read is built to fit, so this
|
|
253
|
-
* failing means a shape the building missed; it refuses, explicitly, rather
|
|
254
|
-
* than hand back a result neither delivery route can carry.
|
|
255
|
-
*/
|
|
256
|
-
function deliverable(result, tool, advice = "read a smaller part, or with raw: true, which pages in chunks") {
|
|
257
|
-
const bytes = jsonBytes(result);
|
|
258
|
-
if (bytes > RESULT_BUDGET_BYTES) {
|
|
259
|
-
throw new ConnectorCallError("connector_call_failed", `${tool} built a result of ${bytes} bytes, more than one result can carry (${RESULT_BUDGET_BYTES}). It only read, so nothing was changed; ${advice}.`, { retryable: false });
|
|
260
|
-
}
|
|
261
|
-
return result;
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* The same postcondition for a write's result, which comes after Google
|
|
265
|
-
* answered 2xx: the write has applied, so a result too large to deliver is
|
|
266
|
-
* refused with that said, and with where to see the outcome — never as if
|
|
267
|
-
* nothing changed, and never as something to retry.
|
|
268
|
-
*/
|
|
269
|
-
function appliedDeliverable(result, tool, reread) {
|
|
270
|
-
const bytes = jsonBytes(result);
|
|
271
|
-
if (bytes > RESULT_BUDGET_BYTES)
|
|
272
|
-
throw appliedButUnreadable(tool, `its result is ${bytes} bytes, more than one result can carry`, reread);
|
|
273
|
-
return result;
|
|
274
|
-
}
|
|
275
|
-
function appliedButUnreadable(tool, why, reread) {
|
|
276
|
-
return new ConnectorCallError("connector_call_failed", `${tool} applied — Google answered 2xx — but ${why}. Do not repeat it; ${reread}.`, { retryable: false });
|
|
277
|
-
}
|
|
278
|
-
/** A label cut to `maxBytes` of JSON, saying where the whole of it is. */
|
|
279
|
-
function label(value, maxBytes, whole) {
|
|
280
|
-
return value === undefined
|
|
281
|
-
? undefined
|
|
282
|
-
: clampText(value, maxBytes, (dropped) => `[… ${dropped} more characters; ${whole}]`);
|
|
283
|
-
}
|
|
284
|
-
/** The first `max` UTF-16 units of `value`, never ending half a surrogate pair. */
|
|
285
|
-
function headOf(value, max) {
|
|
286
|
-
if (value.length <= max)
|
|
287
|
-
return value;
|
|
288
|
-
const code = value.charCodeAt(max - 1);
|
|
289
|
-
return value.slice(0, code >= 0xd800 && code <= 0xdbff ? max - 1 : max);
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* The longest prefix of `value` whose JSON fits `maxBytes` together with the
|
|
293
|
-
* marker its cut earns, as text and as the number of characters it keeps.
|
|
294
|
-
*/
|
|
295
|
-
function prefixWithin(value, maxBytes, marker) {
|
|
296
|
-
let dropped = 0;
|
|
297
|
-
const clamped = clampText(value, maxBytes, (count) => {
|
|
298
|
-
dropped = count;
|
|
299
|
-
return marker(count);
|
|
300
|
-
});
|
|
301
|
-
if (clamped === value)
|
|
302
|
-
return { text: value, kept: value.length };
|
|
303
|
-
if (clamped === "")
|
|
304
|
-
return { text: "", kept: 0 };
|
|
305
|
-
return { text: clamped, kept: value.length - dropped };
|
|
306
|
-
}
|
|
307
|
-
const CURSOR_TOOLS = {
|
|
308
|
-
deck: "get_presentation",
|
|
309
|
-
layouts: "list_layouts",
|
|
310
|
-
page: "get_page",
|
|
311
|
-
comments: "list_comments",
|
|
312
|
-
};
|
|
313
|
-
function encodeCursor(cursor) {
|
|
314
|
-
let binary = "";
|
|
315
|
-
for (const byte of new TextEncoder().encode(JSON.stringify(compact(cursor)))) {
|
|
316
|
-
binary += String.fromCharCode(byte);
|
|
317
|
-
}
|
|
318
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
319
|
-
}
|
|
320
|
-
/**
|
|
321
|
-
* A cursor this tool issued for this deck and page, in this mode, or a
|
|
322
|
-
* refusal that says which of those it is not. The state is checked later,
|
|
323
|
-
* against the deck as it is read.
|
|
324
|
-
*/
|
|
325
|
-
function decodeCursor(value, expected) {
|
|
326
|
-
if (value === undefined)
|
|
327
|
-
return undefined;
|
|
328
|
-
const refuse = (why) => new ConnectorCallError("invalid_args", `${why} Omit cursor to start from the beginning.`);
|
|
329
|
-
let cursor;
|
|
330
|
-
try {
|
|
331
|
-
const base64 = String(value).replace(/-/g, "+").replace(/_/g, "/");
|
|
332
|
-
const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
|
|
333
|
-
cursor = JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))));
|
|
334
|
-
}
|
|
335
|
-
catch {
|
|
336
|
-
throw refuse("cursor is not one this connection issued; pass page.nextCursor back unchanged.");
|
|
337
|
-
}
|
|
338
|
-
const counter = (field) => field === undefined || (Number.isSafeInteger(field) && field >= 0);
|
|
339
|
-
if (!cursor ||
|
|
340
|
-
typeof cursor !== "object" ||
|
|
341
|
-
typeof cursor.p !== "string" ||
|
|
342
|
-
typeof cursor.s !== "string" ||
|
|
343
|
-
!Number.isSafeInteger(cursor.i) ||
|
|
344
|
-
cursor.i < 0 ||
|
|
345
|
-
!counter(cursor.o) ||
|
|
346
|
-
!counter(cursor.j)) {
|
|
347
|
-
throw refuse("cursor is not one this connection issued; pass page.nextCursor back unchanged.");
|
|
348
|
-
}
|
|
349
|
-
if (cursor.k !== expected.k) {
|
|
350
|
-
const tool = Object.hasOwn(CURSOR_TOOLS, cursor.k) ? CURSOR_TOOLS[cursor.k] : undefined;
|
|
351
|
-
throw refuse(tool
|
|
352
|
-
? `cursor belongs to another tool (${tool}).`
|
|
353
|
-
: "cursor is not one this connection issued; pass page.nextCursor back unchanged.");
|
|
354
|
-
}
|
|
355
|
-
if (cursor.p !== expected.p)
|
|
356
|
-
throw refuse("cursor continues a different presentation.");
|
|
357
|
-
if ((cursor.g ?? undefined) !== expected.g)
|
|
358
|
-
throw refuse("cursor continues a different page.");
|
|
359
|
-
if ((cursor.raw === 1) !== expected.raw) {
|
|
360
|
-
throw refuse(`cursor was issued with raw: ${cursor.raw === 1}; pass the same raw to continue.`);
|
|
361
|
-
}
|
|
362
|
-
return cursor;
|
|
363
|
-
}
|
|
364
|
-
/**
|
|
365
|
-
* The deck state a cursor binds to: the revision when Slides gives one, which
|
|
366
|
-
* it does to anyone who may edit. A viewer gets none, so a cursor binds to a
|
|
367
|
-
* SHA-256 of exactly what its paging depends on instead — content, never a
|
|
368
|
-
* length or a count standing in for it.
|
|
369
|
-
*/
|
|
370
|
-
async function stateOf(revisionId, fingerprint) {
|
|
371
|
-
if (typeof revisionId === "string" && revisionId !== "")
|
|
372
|
-
return `r:${revisionId}`;
|
|
373
|
-
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(fingerprint()))));
|
|
374
|
-
return `f:${Array.from(digest.subarray(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
375
|
-
}
|
|
376
|
-
/** A cursor's deck changed under it: continuing could skip or repeat. */
|
|
377
|
-
function assertUnchanged(cursor, state, what) {
|
|
378
|
-
if (cursor && cursor.s !== state) {
|
|
379
|
-
throw new ConnectorCallError("conflict", `The ${what} changed since this cursor was issued, so continuing from it could skip or repeat what changed. Start again without a cursor.`);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
// --- Reading elements ---------------------------------------------------------------
|
|
383
|
-
/** A shape's or cell's text: runs and auto text in order, soft breaks as newlines. */
|
|
384
|
-
function textOf(content) {
|
|
385
|
-
let out = "";
|
|
386
|
-
for (const element of asArray(asRecord(content)["textElements"]).map(asRecord)) {
|
|
387
|
-
const run = asRecord(element["textRun"])["content"] ?? asRecord(element["autoText"])["content"];
|
|
388
|
-
if (typeof run === "string")
|
|
389
|
-
out += run;
|
|
390
|
-
}
|
|
391
|
-
return out.replaceAll("\u000b", "\n").replace(/\s+$/, "");
|
|
392
|
-
}
|
|
393
|
-
/** A table as tab-separated cells, one row per line. */
|
|
394
|
-
function tableText(table) {
|
|
395
|
-
return asArray(table["tableRows"])
|
|
396
|
-
.map((row) => asArray(asRecord(row)["tableCells"])
|
|
397
|
-
.map((cell) => textOf(asRecord(cell)["text"]).replace(/[\t\n]+/g, " "))
|
|
398
|
-
.join("\t")
|
|
399
|
-
.replace(/\t+$/, ""))
|
|
400
|
-
.join("\n")
|
|
401
|
-
.replace(/\s+$/, "");
|
|
402
|
-
}
|
|
403
|
-
const IDENTITY = [1, 0, 0, 1, 0, 0];
|
|
404
|
-
/**
|
|
405
|
-
* A transform as a matrix. ProtoJSON omits a zero, and a zero scale is real —
|
|
406
|
-
* a group turned 90° has none — so an omitted coefficient of a present
|
|
407
|
-
* transform is 0. Only a transform that is absent altogether is the identity.
|
|
408
|
-
*/
|
|
409
|
-
function matrixOf(transform) {
|
|
410
|
-
if (!transform || typeof transform !== "object" || Array.isArray(transform))
|
|
411
|
-
return IDENTITY;
|
|
412
|
-
const t = transform;
|
|
413
|
-
const n = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
414
|
-
const unit = t["unit"] === "PT" ? EMU_PER_PT : 1;
|
|
415
|
-
return [
|
|
416
|
-
n(t["scaleX"]),
|
|
417
|
-
n(t["shearY"]),
|
|
418
|
-
n(t["shearX"]),
|
|
419
|
-
n(t["scaleY"]),
|
|
420
|
-
n(t["translateX"]) * unit,
|
|
421
|
-
n(t["translateY"]) * unit,
|
|
422
|
-
];
|
|
423
|
-
}
|
|
424
|
-
/** `parent` preconcatenated with `child`, as Slides composes group transforms. */
|
|
425
|
-
function compose(parent, child) {
|
|
426
|
-
const [a, b, c, d, e, f] = parent;
|
|
427
|
-
const [ca, cb, cc, cd, ce, cf] = child;
|
|
428
|
-
return [a * ca + c * cb, b * ca + d * cb, a * cc + c * cd, b * cc + d * cd, a * ce + c * cf + e, b * ce + d * cf + f];
|
|
429
|
-
}
|
|
430
|
-
function kindOf(element) {
|
|
431
|
-
if (element["shape"])
|
|
432
|
-
return "shape";
|
|
433
|
-
if (element["table"])
|
|
434
|
-
return "table";
|
|
435
|
-
if (element["wordArt"])
|
|
436
|
-
return "wordArt";
|
|
437
|
-
if (element["image"])
|
|
438
|
-
return "image";
|
|
439
|
-
if (element["video"])
|
|
440
|
-
return "video";
|
|
441
|
-
if (element["sheetsChart"])
|
|
442
|
-
return "chart";
|
|
443
|
-
if (element["line"])
|
|
444
|
-
return "line";
|
|
445
|
-
return "other";
|
|
446
|
-
}
|
|
447
|
-
function collect(elements, parent, groupId, depth, out) {
|
|
448
|
-
for (const element of asArray(elements).map(asRecord)) {
|
|
449
|
-
const matrix = compose(parent, matrixOf(element["transform"]));
|
|
450
|
-
const group = asRecord(element["elementGroup"]);
|
|
451
|
-
if (Array.isArray(group["children"]) && depth < 20) {
|
|
452
|
-
collect(group["children"], matrix, text(element["objectId"]), depth + 1, out);
|
|
453
|
-
continue;
|
|
454
|
-
}
|
|
455
|
-
const kind = kindOf(element);
|
|
456
|
-
out.push({
|
|
457
|
-
element,
|
|
458
|
-
groupId,
|
|
459
|
-
kind,
|
|
460
|
-
text: kind === "shape"
|
|
461
|
-
? textOf(asRecord(element["shape"])["text"])
|
|
462
|
-
: kind === "table"
|
|
463
|
-
? tableText(asRecord(element["table"]))
|
|
464
|
-
: kind === "wordArt"
|
|
465
|
-
? (text(asRecord(element["wordArt"])["renderedText"]) ?? "")
|
|
466
|
-
: "",
|
|
467
|
-
x: matrix[4],
|
|
468
|
-
y: matrix[5],
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
/**
|
|
473
|
-
* A page's leaf elements in reading order: top to bottom, then left to right,
|
|
474
|
-
* by absolute position rounded to the point, so a hairline offset does not
|
|
475
|
-
* reorder a row. The index into this list is what a get_page cursor counts.
|
|
476
|
-
*/
|
|
477
|
-
function readingOrder(elements) {
|
|
478
|
-
const out = [];
|
|
479
|
-
collect(elements, IDENTITY, undefined, 0, out);
|
|
480
|
-
return out.sort((left, right) => Math.round(left.y / EMU_PER_PT) - Math.round(right.y / EMU_PER_PT) ||
|
|
481
|
-
Math.round(left.x / EMU_PER_PT) - Math.round(right.x / EMU_PER_PT));
|
|
482
|
-
}
|
|
483
|
-
/** What a projected page's cursor depends on when there is no revision. */
|
|
484
|
-
function leafFingerprint(leaves) {
|
|
485
|
-
// The text itself, not its length: a continuation resumes at a character
|
|
486
|
-
// offset, and an edit that keeps the length still moves what is there.
|
|
487
|
-
return leaves.map((leaf) => [leaf.element["objectId"] ?? null, leaf.kind, leaf.text]);
|
|
488
|
-
}
|
|
489
|
-
function placeholderOf(leaf) {
|
|
490
|
-
return leaf.kind === "shape" ? asRecord(asRecord(leaf.element["shape"])["placeholder"]) : {};
|
|
491
|
-
}
|
|
492
|
-
function altTextOf(element) {
|
|
493
|
-
return [text(element["title"]), text(element["description"])].filter(Boolean).join(": ") || undefined;
|
|
494
|
-
}
|
|
495
|
-
/** The fields of an element both readers share: what it is, and how to address it. */
|
|
496
|
-
function describeLeaf(leaf, raw) {
|
|
497
|
-
const placeholder = placeholderOf(leaf);
|
|
498
|
-
return compact({
|
|
499
|
-
objectId: text(leaf.element["objectId"]),
|
|
500
|
-
kind: leaf.kind,
|
|
501
|
-
placeholder: text(placeholder["type"]),
|
|
502
|
-
altText: label(altTextOf(leaf.element), MAX_ALT_TEXT_BYTES, raw),
|
|
503
|
-
// A linked chart's data lives in Sheets; the id is how to reach it.
|
|
504
|
-
spreadsheetId: leaf.kind === "chart" ? text(asRecord(leaf.element["sheetsChart"])["spreadsheetId"]) : undefined,
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
const RAW_HAS_IT = "get_page with raw: true has all of it";
|
|
508
|
-
// --- get_presentation ---------------------------------------------------------------
|
|
509
|
-
const TITLE_PLACEHOLDERS = new Set(["TITLE", "CENTERED_TITLE"]);
|
|
510
|
-
/** Why text was cut, as the marker tells the reader. */
|
|
511
|
-
const RAISE_CAP = "raise maxCharsPerSlide, or continue with get_page from textCursor";
|
|
512
|
-
const OVER_RESULT = "this slide is larger than one result can carry; continue with get_page from textCursor";
|
|
513
|
-
/**
|
|
514
|
-
* One slide's projection: its elements' text in reading order under a shared
|
|
515
|
-
* per-slide character budget, its speaker notes under the same budget apart,
|
|
516
|
-
* and, for every cut, the get_page cursor that reads on from the cut.
|
|
517
|
-
*/
|
|
518
|
-
async function projectSlide(value, index, deck, maxChars, why = RAISE_CAP) {
|
|
519
|
-
const slide = asRecord(value);
|
|
520
|
-
const slideId = text(slide["objectId"]) ?? "";
|
|
521
|
-
const properties = asRecord(slide["slideProperties"]);
|
|
522
|
-
const leaves = readingOrder(slide["pageElements"]);
|
|
523
|
-
let state;
|
|
524
|
-
const slideState = () => (state ??= stateOf(deck.revisionId, () => leafFingerprint(leaves)));
|
|
525
|
-
let budget = maxChars;
|
|
526
|
-
let truncated = false;
|
|
527
|
-
let omitted = 0;
|
|
528
|
-
let title;
|
|
529
|
-
const elements = [];
|
|
530
|
-
for (const [position, leaf] of leaves.entries()) {
|
|
531
|
-
const placeholder = text(placeholderOf(leaf)["type"]);
|
|
532
|
-
const alt = altTextOf(leaf.element);
|
|
533
|
-
// Decorative lines and empty boxes carry nothing to read; they are
|
|
534
|
-
// counted, not listed. An empty placeholder is kept: its id is where
|
|
535
|
-
// insertText fills a new slide's title or body.
|
|
536
|
-
if (leaf.kind === "line" ||
|
|
537
|
-
(!leaf.text && !alt && !placeholder && (leaf.kind === "shape" || leaf.kind === "wordArt"))) {
|
|
538
|
-
omitted += 1;
|
|
539
|
-
continue;
|
|
540
|
-
}
|
|
541
|
-
if (placeholder && TITLE_PLACEHOLDERS.has(placeholder) && title === undefined && leaf.text) {
|
|
542
|
-
const line = leaf.text.replace(/\n+/g, " ");
|
|
543
|
-
const keep = headOf(line, Math.min(MAX_TITLE_CHARS, maxChars));
|
|
544
|
-
title =
|
|
545
|
-
keep.length < line.length
|
|
546
|
-
? `${keep}\n[… ${line.length - keep.length} more characters; the title element's text has them]`
|
|
547
|
-
: line;
|
|
548
|
-
}
|
|
549
|
-
const row = describeLeaf(leaf, RAW_HAS_IT);
|
|
550
|
-
if (row["altText"] !== alt)
|
|
551
|
-
truncated = true;
|
|
552
|
-
if (leaf.text) {
|
|
553
|
-
const kept = headOf(leaf.text, Math.max(budget, 0));
|
|
554
|
-
budget -= kept.length;
|
|
555
|
-
if (kept.length < leaf.text.length) {
|
|
556
|
-
truncated = true;
|
|
557
|
-
row["text"] = `${kept}\n[… ${leaf.text.length - kept.length} more characters truncated; ${why}]`;
|
|
558
|
-
row["truncated"] = true;
|
|
559
|
-
row["textCursor"] = encodeCursor({
|
|
560
|
-
k: "page",
|
|
561
|
-
p: deck.presentationId,
|
|
562
|
-
g: slideId,
|
|
563
|
-
s: await slideState(),
|
|
564
|
-
i: position,
|
|
565
|
-
o: kept.length,
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
else {
|
|
569
|
-
row["text"] = kept;
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
row["position"] = position;
|
|
573
|
-
elements.push(row);
|
|
574
|
-
}
|
|
575
|
-
const notesPage = asRecord(properties["notesPage"]);
|
|
576
|
-
const notesPageId = text(notesPage["objectId"]);
|
|
577
|
-
const notesId = text(asRecord(notesPage["notesProperties"])["speakerNotesObjectId"]);
|
|
578
|
-
const notesLeaves = readingOrder(notesPage["pageElements"]);
|
|
579
|
-
const notesAt = notesLeaves.findIndex((leaf) => notesId !== undefined && leaf.element["objectId"] === notesId);
|
|
580
|
-
const notesText = notesAt >= 0 ? notesLeaves[notesAt].text : "";
|
|
581
|
-
let notes;
|
|
582
|
-
let notesCursor;
|
|
583
|
-
if (notesText) {
|
|
584
|
-
const kept = headOf(notesText, maxChars);
|
|
585
|
-
if (kept.length < notesText.length) {
|
|
586
|
-
truncated = true;
|
|
587
|
-
notes = `${kept}\n[… ${notesText.length - kept.length} more characters truncated; ${why.replace("textCursor", "notesCursor")}]`;
|
|
588
|
-
notesCursor = encodeCursor({
|
|
589
|
-
k: "page",
|
|
590
|
-
p: deck.presentationId,
|
|
591
|
-
g: notesPageId ?? "",
|
|
592
|
-
s: await stateOf(deck.revisionId, () => leafFingerprint(notesLeaves)),
|
|
593
|
-
i: notesAt,
|
|
594
|
-
o: kept.length,
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
|
-
else {
|
|
598
|
-
notes = notesText;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
const layoutId = text(properties["layoutObjectId"]);
|
|
602
|
-
return compact({
|
|
603
|
-
objectId: slideId || undefined,
|
|
604
|
-
index,
|
|
605
|
-
layout: layoutId ? deck.layouts.get(layoutId) : undefined,
|
|
606
|
-
layoutId,
|
|
607
|
-
skipped: properties["isSkipped"] === true || undefined,
|
|
608
|
-
title,
|
|
609
|
-
elements,
|
|
610
|
-
omittedElements: omitted > 0 ? omitted : undefined,
|
|
611
|
-
notesPageId,
|
|
612
|
-
notes,
|
|
613
|
-
notesCursor,
|
|
614
|
-
truncated: truncated || undefined,
|
|
615
|
-
// Kept for fitting: where each listed element sits among the page's
|
|
616
|
-
// leaves, which an elementsCursor counts. Removed before returning.
|
|
617
|
-
_state: slideState,
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
/** A projected slide as returned: the fitting bookkeeping gone. */
|
|
621
|
-
function finished(slide) {
|
|
622
|
-
const { _state, ...rest } = slide;
|
|
623
|
-
return {
|
|
624
|
-
...rest,
|
|
625
|
-
elements: asArray(rest["elements"]).map((element) => {
|
|
626
|
-
const { position: _position, ...row } = asRecord(element);
|
|
627
|
-
return row;
|
|
628
|
-
}),
|
|
629
|
-
};
|
|
630
|
-
}
|
|
631
|
-
/**
|
|
632
|
-
* A page's first slide that is too large for the `room` left, made to fit:
|
|
633
|
-
* its text halved until it does, then — for a slide of so many elements that
|
|
634
|
-
* it does not fit with no text — its last elements left out, counted, and
|
|
635
|
-
* named by the get_page cursor that lists them. Every cut says it was the
|
|
636
|
-
* result's size, not maxCharsPerSlide. A later slide that does not fit starts
|
|
637
|
-
* the next page whole instead.
|
|
638
|
-
*/
|
|
639
|
-
async function fitSlide(value, index, deck, maxChars, room) {
|
|
640
|
-
for (let chars = Math.floor(maxChars / 2);; chars = Math.floor(chars / 2)) {
|
|
641
|
-
const slide = await projectSlide(value, index, deck, chars, OVER_RESULT);
|
|
642
|
-
if (jsonBytes(finished(slide)) <= room)
|
|
643
|
-
return finished(slide);
|
|
644
|
-
if (chars > 0)
|
|
645
|
-
continue;
|
|
646
|
-
const elements = asArray(slide["elements"]).map(asRecord);
|
|
647
|
-
const state = await slide["_state"]();
|
|
648
|
-
const keeping = (count) => finished({
|
|
649
|
-
...slide,
|
|
650
|
-
elements: elements.slice(0, count),
|
|
651
|
-
elementsNotShown: elements.length - count,
|
|
652
|
-
elementsCursor: encodeCursor({
|
|
653
|
-
k: "page",
|
|
654
|
-
p: deck.presentationId,
|
|
655
|
-
g: text(asRecord(value)["objectId"]) ?? "",
|
|
656
|
-
s: state,
|
|
657
|
-
i: count < elements.length ? Number(elements[count]["position"]) : 0,
|
|
658
|
-
}),
|
|
659
|
-
truncated: true,
|
|
660
|
-
});
|
|
661
|
-
let low = 0;
|
|
662
|
-
let high = elements.length;
|
|
663
|
-
while (low < high) {
|
|
664
|
-
const middle = Math.ceil((low + high) / 2);
|
|
665
|
-
if (jsonBytes(keeping(middle)) <= room)
|
|
666
|
-
low = middle;
|
|
667
|
-
else
|
|
668
|
-
high = middle - 1;
|
|
669
|
-
}
|
|
670
|
-
// The result's postcondition refuses a slide that still does not fit.
|
|
671
|
-
return keeping(low);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
/** A raw slide no result can carry, named, with where to read it instead. */
|
|
675
|
-
function rawNotShown(value, bytes) {
|
|
676
|
-
const id = text(asRecord(value)["objectId"]);
|
|
677
|
-
return compact({
|
|
678
|
-
objectId: id,
|
|
679
|
-
rawNotShown: `This slide's raw JSON is ${bytes} bytes, more than one result can carry. get_page with pageObjectId "${id ?? ""}" and raw: true reads all of it, in chunks.`,
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
// --- Layouts ------------------------------------------------------------------------
|
|
683
|
-
/** Masters, each followed by its layouts, as rows create_slide and get_page take ids from. */
|
|
684
|
-
function layoutRows(presentation) {
|
|
685
|
-
const whole = "get_page on this id with raw: true has the whole name";
|
|
686
|
-
const masters = asArray(presentation["masters"]).map(asRecord);
|
|
687
|
-
const layouts = asArray(presentation["layouts"]).map(asRecord);
|
|
688
|
-
const layoutRow = (layout) => {
|
|
689
|
-
const properties = asRecord(layout["layoutProperties"]);
|
|
690
|
-
return compact({
|
|
691
|
-
objectId: text(layout["objectId"]),
|
|
692
|
-
kind: "layout",
|
|
693
|
-
name: label(text(properties["name"]), MAX_NAME_BYTES, whole),
|
|
694
|
-
displayName: label(text(properties["displayName"]), MAX_NAME_BYTES, whole),
|
|
695
|
-
masterId: text(properties["masterObjectId"]),
|
|
696
|
-
});
|
|
697
|
-
};
|
|
698
|
-
const rows = [];
|
|
699
|
-
const placed = new Set();
|
|
700
|
-
for (const master of masters) {
|
|
701
|
-
const id = text(master["objectId"]);
|
|
702
|
-
rows.push(compact({
|
|
703
|
-
objectId: id,
|
|
704
|
-
kind: "master",
|
|
705
|
-
displayName: label(text(asRecord(master["masterProperties"])["displayName"]), MAX_NAME_BYTES, whole),
|
|
706
|
-
}));
|
|
707
|
-
for (const layout of layouts) {
|
|
708
|
-
if (id !== undefined && asRecord(layout["layoutProperties"])["masterObjectId"] === id) {
|
|
709
|
-
rows.push(layoutRow(layout));
|
|
710
|
-
placed.add(layout);
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
for (const layout of layouts)
|
|
715
|
-
if (!placed.has(layout))
|
|
716
|
-
rows.push(layoutRow(layout));
|
|
717
|
-
return rows;
|
|
718
|
-
}
|
|
719
|
-
/** What a list_layouts cursor depends on when there is no revision. */
|
|
720
|
-
function layoutFingerprint(rows) {
|
|
721
|
-
return rows.map((row) => row["objectId"] ?? null);
|
|
722
|
-
}
|
|
723
|
-
/** A layout's name as a person picks it, bounded: its display name, else its kind. */
|
|
724
|
-
function layoutNames(presentation) {
|
|
725
|
-
const names = new Map();
|
|
726
|
-
for (const layout of asArray(presentation["layouts"]).map(asRecord)) {
|
|
727
|
-
const id = text(layout["objectId"]);
|
|
728
|
-
const properties = asRecord(layout["layoutProperties"]);
|
|
729
|
-
const name = label(text(properties["displayName"]) ?? text(properties["name"]), MAX_NAME_BYTES, "get_page on layoutId with raw: true has the whole name");
|
|
730
|
-
if (id && name)
|
|
731
|
-
names.set(id, name);
|
|
732
|
-
}
|
|
733
|
-
return names;
|
|
734
|
-
}
|
|
735
|
-
/**
|
|
736
|
-
* Rows from `start` that fit `room` bytes and `limit` rows, as a page of
|
|
737
|
-
* them and the index the next page starts at.
|
|
738
|
-
*/
|
|
739
|
-
function rowsWithin(rows, start, limit, room) {
|
|
740
|
-
const out = [];
|
|
741
|
-
let used = 2;
|
|
742
|
-
let next = start;
|
|
743
|
-
while (next < rows.length && out.length < limit) {
|
|
744
|
-
const size = jsonBytes(rows[next]) + 1;
|
|
745
|
-
if (used + size > room)
|
|
746
|
-
break;
|
|
747
|
-
out.push(rows[next]);
|
|
748
|
-
used += size;
|
|
749
|
-
next += 1;
|
|
750
|
-
}
|
|
751
|
-
return { rows: out, next };
|
|
752
|
-
}
|
|
753
|
-
function dimension(value) {
|
|
754
|
-
const record = asRecord(value);
|
|
755
|
-
return typeof record["magnitude"] === "number"
|
|
756
|
-
? { magnitude: record["magnitude"], unit: text(record["unit"]) ?? "EMU" }
|
|
757
|
-
: undefined;
|
|
758
|
-
}
|
|
759
|
-
/** The size of a page object carrying the longest cursor a result may hold. */
|
|
760
|
-
function pageEnvelopeBytes(cursor) {
|
|
761
|
-
const longest = { ...cursor, i: 999_999, o: 99_999_999, j: cursor.j === undefined ? undefined : 999_999 };
|
|
762
|
-
return jsonBytes({ page: { hasMore: true, nextCursor: encodeCursor(longest) } });
|
|
763
|
-
}
|
|
764
|
-
// --- get_page -------------------------------------------------------------------------
|
|
765
|
-
/** What get_page says about the page itself, by the page's kind. */
|
|
766
|
-
function describePage(page) {
|
|
767
|
-
const whole = "raw: true has the whole name";
|
|
768
|
-
const slide = asRecord(page["slideProperties"]);
|
|
769
|
-
const layout = asRecord(page["layoutProperties"]);
|
|
770
|
-
return compact({
|
|
771
|
-
pageType: text(page["pageType"]),
|
|
772
|
-
layoutId: text(slide["layoutObjectId"]),
|
|
773
|
-
masterId: text(slide["masterObjectId"]) ?? text(layout["masterObjectId"]),
|
|
774
|
-
notesPageId: text(asRecord(slide["notesPage"])["objectId"]),
|
|
775
|
-
skipped: slide["isSkipped"] === true || undefined,
|
|
776
|
-
speakerNotesObjectId: text(asRecord(page["notesProperties"])["speakerNotesObjectId"]),
|
|
777
|
-
name: label(text(layout["name"]), MAX_NAME_BYTES, whole),
|
|
778
|
-
displayName: label(text(layout["displayName"]) ?? text(asRecord(page["masterProperties"])["displayName"]), MAX_NAME_BYTES, whole),
|
|
779
|
-
});
|
|
780
|
-
}
|
|
781
|
-
/**
|
|
782
|
-
* A page's raw rows: the page itself without its elements — its notes page
|
|
783
|
-
* reduced to its id, which get_page reads on its own — then each top-level
|
|
784
|
-
* element as Slides sends it.
|
|
785
|
-
*/
|
|
786
|
-
function rawItems(page) {
|
|
787
|
-
const { pageElements, ...properties } = page;
|
|
788
|
-
const slide = asRecord(properties["slideProperties"]);
|
|
789
|
-
if (slide["notesPage"]) {
|
|
790
|
-
properties["slideProperties"] = {
|
|
791
|
-
...slide,
|
|
792
|
-
notesPage: compact({ objectId: text(asRecord(slide["notesPage"])["objectId"]) }),
|
|
793
|
-
};
|
|
794
|
-
}
|
|
795
|
-
return [properties, ...asArray(pageElements).map(asRecord)];
|
|
796
|
-
}
|
|
797
|
-
/**
|
|
798
|
-
* Projected rows of a page from element `i`, text offset `o`: every leaf,
|
|
799
|
-
* in reading order, with its text continued across pages wherever one page
|
|
800
|
-
* cannot hold it.
|
|
801
|
-
*/
|
|
802
|
-
function projectedRows(leaves, start, limit, room) {
|
|
803
|
-
const elements = [];
|
|
804
|
-
let used = 2;
|
|
805
|
-
let index = start.i;
|
|
806
|
-
let offset = start.o;
|
|
807
|
-
while (index < leaves.length && elements.length < limit) {
|
|
808
|
-
const leaf = leaves[index];
|
|
809
|
-
const placeholder = placeholderOf(leaf);
|
|
810
|
-
const row = {
|
|
811
|
-
...describeLeaf(leaf, RAW_HAS_IT),
|
|
812
|
-
...compact({
|
|
813
|
-
groupId: leaf.groupId,
|
|
814
|
-
placeholderIndex: typeof placeholder["index"] === "number" ? placeholder["index"] : undefined,
|
|
815
|
-
placeholderParentId: text(placeholder["parentObjectId"]),
|
|
816
|
-
textOffset: offset > 0 ? offset : undefined,
|
|
817
|
-
}),
|
|
818
|
-
};
|
|
819
|
-
const rest = leaf.text.slice(offset);
|
|
820
|
-
if (!rest) {
|
|
821
|
-
const size = jsonBytes(row) + 1;
|
|
822
|
-
if (used + size > room)
|
|
823
|
-
break;
|
|
824
|
-
elements.push(row);
|
|
825
|
-
used += size;
|
|
826
|
-
index += 1;
|
|
827
|
-
offset = 0;
|
|
828
|
-
continue;
|
|
829
|
-
}
|
|
830
|
-
// The row as it would be if its text were cut, so the text's share is
|
|
831
|
-
// measured against everything else the row carries.
|
|
832
|
-
const shell = { ...row, text: "", truncated: true, textLength: leaf.text.length };
|
|
833
|
-
const available = room - used - 1 - (jsonBytes(shell) - 2);
|
|
834
|
-
const cut = available > 2
|
|
835
|
-
? prefixWithin(rest, available, (dropped) => `\n[… ${dropped} more characters continue on the next page]`)
|
|
836
|
-
: { text: "", kept: 0 };
|
|
837
|
-
if (cut.kept === 0 && elements.length > 0)
|
|
838
|
-
break;
|
|
839
|
-
if (cut.kept === rest.length) {
|
|
840
|
-
const whole = { ...row, text: rest };
|
|
841
|
-
used += jsonBytes(whole) + 1;
|
|
842
|
-
elements.push(whole);
|
|
843
|
-
index += 1;
|
|
844
|
-
offset = 0;
|
|
845
|
-
continue;
|
|
846
|
-
}
|
|
847
|
-
if (cut.kept === 0) {
|
|
848
|
-
throw new ConnectorCallError("connector_call_failed", "get_page could not fit any of an element's text in one result.", { retryable: false });
|
|
849
|
-
}
|
|
850
|
-
elements.push({ ...shell, text: cut.text });
|
|
851
|
-
return { elements, next: { i: index, o: offset + cut.kept } };
|
|
852
|
-
}
|
|
853
|
-
return { elements, next: index < leaves.length ? { i: index, o: offset } : undefined };
|
|
854
|
-
}
|
|
855
|
-
const PAGE_RAW = {
|
|
856
|
-
tool: "get_page",
|
|
857
|
-
propertiesFirst: true,
|
|
858
|
-
idOf: (item) => ({ objectId: text(item["objectId"]) }),
|
|
859
|
-
};
|
|
860
|
-
/**
|
|
861
|
-
* Raw rows from item `i` (for get_page, 0 is the page's own properties),
|
|
862
|
-
* JSON offset `o`: each whole where it fits, and one that fits no page alone
|
|
863
|
-
* in chunks of its JSON text, which concatenated parse to the item.
|
|
864
|
-
*/
|
|
865
|
-
function rawRows(items, start, limit, room, layout = PAGE_RAW) {
|
|
866
|
-
const out = { elements: [], next: undefined };
|
|
867
|
-
let used = 2;
|
|
868
|
-
let index = start.i;
|
|
869
|
-
let offset = start.o;
|
|
870
|
-
let rows = 0;
|
|
871
|
-
while (index < items.length && rows < limit) {
|
|
872
|
-
const item = items[index];
|
|
873
|
-
const size = jsonBytes(item) + 32;
|
|
874
|
-
if (offset === 0 && used + size <= room) {
|
|
875
|
-
if (index === 0 && layout.propertiesFirst)
|
|
876
|
-
out.properties = item;
|
|
877
|
-
else
|
|
878
|
-
out.elements.push(item);
|
|
879
|
-
used += size;
|
|
880
|
-
rows += 1;
|
|
881
|
-
index += 1;
|
|
882
|
-
continue;
|
|
883
|
-
}
|
|
884
|
-
// Whole on a page of its own: start the next page with it.
|
|
885
|
-
if (offset === 0 && rows > 0 && size <= room)
|
|
886
|
-
break;
|
|
887
|
-
const json = JSON.stringify(item);
|
|
888
|
-
const id = index === 0 && layout.propertiesFirst ? {} : layout.idOf(item);
|
|
889
|
-
const shell = { ...id, rawJson: { json: "", offset, length: json.length } };
|
|
890
|
-
const available = room - used - (jsonBytes(shell) - 2) - 32;
|
|
891
|
-
const cut = available > 2 ? prefixWithin(json.slice(offset), available, () => "") : { text: "", kept: 0 };
|
|
892
|
-
if (cut.kept === 0) {
|
|
893
|
-
if (rows > 0)
|
|
894
|
-
break;
|
|
895
|
-
throw new ConnectorCallError("connector_call_failed", `${layout.tool} could not fit any part of a raw item in one result.`, {
|
|
896
|
-
retryable: false,
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
|
-
const chunk = { json: cut.text, offset, length: json.length };
|
|
900
|
-
if (index === 0 && layout.propertiesFirst)
|
|
901
|
-
out.propertiesJson = chunk;
|
|
902
|
-
else
|
|
903
|
-
out.elements.push(compact({ ...id, rawJson: chunk }));
|
|
904
|
-
rows += 1;
|
|
905
|
-
if (offset + cut.kept < json.length) {
|
|
906
|
-
out.next = { i: index, o: offset + cut.kept };
|
|
907
|
-
return out;
|
|
908
|
-
}
|
|
909
|
-
used += jsonBytes(chunk) + 32;
|
|
910
|
-
index += 1;
|
|
911
|
-
offset = 0;
|
|
912
|
-
}
|
|
913
|
-
out.next = index < items.length ? { i: index, o: offset } : undefined;
|
|
914
|
-
return out;
|
|
915
|
-
}
|
|
916
|
-
/**
|
|
917
|
-
* What a raw get_page cursor depends on when there is no revision: the exact
|
|
918
|
-
* JSON of every item, since a chunk resumes at an offset into that text, and
|
|
919
|
-
* an edit of equal size would otherwise stitch two versions into one.
|
|
920
|
-
*/
|
|
921
|
-
function rawFingerprint(items) {
|
|
922
|
-
return items.map((item) => JSON.stringify(item));
|
|
923
|
-
}
|
|
924
|
-
// --- Writing ----------------------------------------------------------------------
|
|
925
|
-
/**
|
|
926
|
-
* One batchUpdate. A write that names `requiredRevisionId` is revision
|
|
927
|
-
* guarded: the shared layer turns Slides' precondition refusal into a
|
|
928
|
-
* `conflict`, so a deck someone changed since the read is re-read, not
|
|
929
|
-
* retried. Nothing was applied in that case: a batch Slides refuses applies
|
|
930
|
-
* none of its requests. One it accepts applies its deck changes together,
|
|
931
|
-
* and its comment changes apart (see {@link commentSaveFields}).
|
|
932
|
-
*/
|
|
933
|
-
async function batchUpdate(client, ctx, presentationId, requests, requiredRevisionId) {
|
|
934
|
-
return asRecord(await client.json({
|
|
935
|
-
method: "POST",
|
|
936
|
-
path: `/presentations/${encodeURIComponent(presentationId)}:batchUpdate`,
|
|
937
|
-
body: compact({
|
|
938
|
-
requests,
|
|
939
|
-
writeControl: requiredRevisionId === undefined ? undefined : { requiredRevisionId },
|
|
940
|
-
}),
|
|
941
|
-
}, ctx, { revisionGuarded: requiredRevisionId !== undefined }));
|
|
942
|
-
}
|
|
943
|
-
/**
|
|
944
|
-
* A create whose outcome Google left uncertain — sent with no answer back, a
|
|
945
|
-
* redirect, a server failure after it was sent, or a 2xx whose answer broke —
|
|
946
|
-
* keeps the shared verdict and adds what to look for before creating again,
|
|
947
|
-
* since a second create makes a second deck or slide. Only a request that
|
|
948
|
-
* never left, or that Google explicitly refused, is known not to have
|
|
949
|
-
* applied, so every other dispatched outcome gets the advice, whatever phase
|
|
950
|
-
* name the shared layer gives it. The advice claims nothing about whether it
|
|
951
|
-
* applied; the shared verdict says that, and says "probably applied" only
|
|
952
|
-
* after a recorded 2xx. Any other failure passes through.
|
|
953
|
-
*/
|
|
954
|
-
function afterUnknownCreate(error, lookFor) {
|
|
955
|
-
const outcome = googleOutcomeOf(error);
|
|
956
|
-
if (!(error instanceof ConnectorCallError) || !outcome?.dispatched || outcome.phase === "refused") {
|
|
957
|
-
return error;
|
|
958
|
-
}
|
|
959
|
-
return new ConnectorCallError(error.code, `${error.message} ${lookFor}`, {
|
|
960
|
-
retryable: error.retryable,
|
|
961
|
-
cause: error,
|
|
962
|
-
});
|
|
963
|
-
}
|
|
964
|
-
/**
|
|
965
|
-
* A field that names something to address later: objectId, commentId,
|
|
966
|
-
* postId, anchorId, … — and a post author's `user`, the `users/{id}` that
|
|
967
|
-
* identifies who wrote it.
|
|
968
|
-
*/
|
|
969
|
-
const ID_KEY = /^(id|user|[a-z][A-Za-z0-9]*Id)$/;
|
|
970
|
-
/**
|
|
971
|
-
* An id as copied from Google into a result: whole, or not at all. A cut id
|
|
972
|
-
* names nothing, so one too long to return is dropped and flagged instead.
|
|
973
|
-
*/
|
|
974
|
-
function wholeId(value) {
|
|
975
|
-
return typeof value === "string" && value !== "" && jsonBytes(value) <= MAX_ID_BYTES ? value : undefined;
|
|
976
|
-
}
|
|
977
|
-
/**
|
|
978
|
-
* `value` keeping, at any depth, every id whole, every number and boolean,
|
|
979
|
-
* and — in "short" mode — strings short enough to read at a glance; every
|
|
980
|
-
* path left out goes to `cut`. Every array item is walked in both modes, so
|
|
981
|
-
* no id is dropped to save space — the result's budget, which counts what
|
|
982
|
-
* does not fit, handles that; past the first few items, "short" mode keeps
|
|
983
|
-
* only their ids.
|
|
984
|
-
* Nesting deeper than any reply Slides sends is cut where it starts.
|
|
985
|
-
*/
|
|
986
|
-
function pruned(value, key, path, mode, cut, depth) {
|
|
987
|
-
if (value === null || typeof value === "number" || typeof value === "boolean")
|
|
988
|
-
return value;
|
|
989
|
-
if (typeof value === "string") {
|
|
990
|
-
const keep = ID_KEY.test(key)
|
|
991
|
-
? wholeId(value) !== undefined
|
|
992
|
-
: mode === "short" && jsonBytes(value) <= MAX_NAME_BYTES;
|
|
993
|
-
if (keep)
|
|
994
|
-
return value;
|
|
995
|
-
cut.push(path);
|
|
996
|
-
return undefined;
|
|
997
|
-
}
|
|
998
|
-
if (typeof value !== "object" || depth >= MAX_REPLY_DEPTH) {
|
|
999
|
-
cut.push(path);
|
|
1000
|
-
return undefined;
|
|
1001
|
-
}
|
|
1002
|
-
if (Array.isArray(value)) {
|
|
1003
|
-
// Every item is walked; past the first few, only for ids, whose cut
|
|
1004
|
-
// fields are summed up in one entry rather than one per item.
|
|
1005
|
-
if (mode === "short" && value.length > MAX_REPLY_ITEMS) {
|
|
1006
|
-
cut.push(`${path}[${MAX_REPLY_ITEMS}…${value.length - 1}] (ids only)`);
|
|
1007
|
-
}
|
|
1008
|
-
return value.map((item, index) => {
|
|
1009
|
-
const itemMode = mode === "short" && index >= MAX_REPLY_ITEMS ? "ids" : mode;
|
|
1010
|
-
const sink = itemMode === mode ? cut : [];
|
|
1011
|
-
return pruned(item, key, `${path}[${index}]`, itemMode, sink, depth + 1) ?? null;
|
|
1012
|
-
});
|
|
1013
|
-
}
|
|
1014
|
-
const out = {};
|
|
1015
|
-
for (const [field, item] of Object.entries(value)) {
|
|
1016
|
-
const kept = pruned(item, field, path ? `${path}.${field}` : field, mode, cut, depth + 1);
|
|
1017
|
-
if (kept !== undefined)
|
|
1018
|
-
out[field] = kept;
|
|
1019
|
-
}
|
|
1020
|
-
return out;
|
|
1021
|
-
}
|
|
1022
|
-
/** The paths a summary left out, bounded: the first ten, then a count. */
|
|
1023
|
-
function cutList(cut) {
|
|
1024
|
-
const named = cut.slice(0, 10).map((path) => clampText(path, 160, () => "…"));
|
|
1025
|
-
return cut.length > named.length ? [...named, `(${cut.length - named.length} more)`] : named;
|
|
1026
|
-
}
|
|
1027
|
-
/**
|
|
1028
|
-
* One batchUpdate reply, small enough to return. Slides' replies are tiny —
|
|
1029
|
-
* a new object's id, a count — and pass whole. One that is not is walked:
|
|
1030
|
-
* every id at every depth is kept whole, then short fields while they fit,
|
|
1031
|
-
* and every path left out is named in `cut`, so nothing is dropped without a
|
|
1032
|
-
* word.
|
|
1033
|
-
*/
|
|
1034
|
-
function summarizeReply(reply) {
|
|
1035
|
-
if (jsonBytes(reply) <= MAX_REPLY_BYTES)
|
|
1036
|
-
return reply;
|
|
1037
|
-
let summary = {};
|
|
1038
|
-
for (const mode of ["short", "ids"]) {
|
|
1039
|
-
const cut = [];
|
|
1040
|
-
summary = { ...asRecord(pruned(reply, "", "", mode, cut, 0)), cut: cutList(cut) };
|
|
1041
|
-
if (jsonBytes(summary) <= MAX_REPLY_BYTES)
|
|
1042
|
-
return summary;
|
|
1043
|
-
}
|
|
1044
|
-
// Ids alone over the per-reply share: still returned, since what a write
|
|
1045
|
-
// created is the one thing a caller cannot ask for again.
|
|
1046
|
-
return summary;
|
|
1047
|
-
}
|
|
1048
|
-
/**
|
|
1049
|
-
* The most one host result may carry into an execute_code program
|
|
1050
|
-
* (`MAX_HOST_RESULT_BYTES` in `src/executors/quickjs-runtime.ts`). A write
|
|
1051
|
-
* result past the provider's own 192 KiB budget but under this still reaches
|
|
1052
|
-
* a program; past it, only a direct call does, paging with connecta.result.
|
|
1053
|
-
*/
|
|
1054
|
-
const BRIDGE_BYTES = 256 * 1024;
|
|
1055
|
-
/**
|
|
1056
|
-
* The most a raw batch's result may be when the ids its replies carry
|
|
1057
|
-
* outgrow one result. Every id is still returned, never dropped: a direct
|
|
1058
|
-
* call stashes a result this size and pages it with connecta.result (the stash
|
|
1059
|
-
* holds 8 MiB by default). Past this, the ids are not returned at all, and
|
|
1060
|
-
* the result says where to read them.
|
|
1061
|
-
*/
|
|
1062
|
-
const MAX_IDS_RESULT_BYTES = 4 * 1024 * 1024;
|
|
1063
|
-
/**
|
|
1064
|
-
* A reply at its smallest, however small it already is: every id at every
|
|
1065
|
-
* depth with its numbers and booleans, and how many fields were left out.
|
|
1066
|
-
* A reply with nothing but those passes unchanged.
|
|
1067
|
-
*/
|
|
1068
|
-
function idsOnlyReply(reply) {
|
|
1069
|
-
const cut = [];
|
|
1070
|
-
const ids = asRecord(pruned(reply, "", "", "ids", cut, 0));
|
|
1071
|
-
return cut.length === 0 ? reply : { ...ids, cut: [`(${cut.length} fields left out; ids only)`] };
|
|
1072
|
-
}
|
|
1073
|
-
/**
|
|
1074
|
-
* A batch's replies, in order, after `used` bytes: ids first, then text.
|
|
1075
|
-
* Every reply starts at its ids alone, and replies are then given their
|
|
1076
|
-
* fuller summaries, in order, while the provider's 192 KiB budget holds
|
|
1077
|
-
* them, so text is cut before any id is. Should the ids alone outgrow that
|
|
1078
|
-
* budget, they are all returned anyway (`oversized`) and no text is; nothing
|
|
1079
|
-
* a write created is ever dropped to save space.
|
|
1080
|
-
*/
|
|
1081
|
-
function boundedReplies(replies, used) {
|
|
1082
|
-
const out = replies.map((reply) => idsOnlyReply(asRecord(reply)));
|
|
1083
|
-
const sizes = out.map((reply) => jsonBytes(reply) + 1);
|
|
1084
|
-
let total = used + 2 + sizes.reduce((sum, size) => sum + size, 0);
|
|
1085
|
-
if (total > RESULT_BUDGET_BYTES)
|
|
1086
|
-
return { replies: out, oversized: true };
|
|
1087
|
-
for (const [index, reply] of replies.entries()) {
|
|
1088
|
-
const fuller = summarizeReply(asRecord(reply));
|
|
1089
|
-
const size = jsonBytes(fuller) + 1;
|
|
1090
|
-
if (total - sizes[index] + size > RESULT_BUDGET_BYTES)
|
|
1091
|
-
continue;
|
|
1092
|
-
total += size - sizes[index];
|
|
1093
|
-
out[index] = fuller;
|
|
1094
|
-
sizes[index] = size;
|
|
1095
|
-
}
|
|
1096
|
-
return { replies: out, oversized: false };
|
|
1097
|
-
}
|
|
1098
|
-
/**
|
|
1099
|
-
* The revision a write left the deck at, for the next write to name — whole,
|
|
1100
|
-
* or flagged as not shown when too long to copy, never cut.
|
|
1101
|
-
*/
|
|
1102
|
-
function revisionFields(response) {
|
|
1103
|
-
const revision = asRecord(response["writeControl"])["requiredRevisionId"];
|
|
1104
|
-
const whole = wholeId(revision);
|
|
1105
|
-
if (whole !== undefined)
|
|
1106
|
-
return { revisionId: whole };
|
|
1107
|
-
return typeof revision === "string" && revision !== "" ? { revisionIdNotShown: true } : {};
|
|
1108
|
-
}
|
|
1109
|
-
// --- Comments ---------------------------------------------------------------------
|
|
1110
|
-
/**
|
|
1111
|
-
* What a batch's comment changes came to, as a result field. A batch with no
|
|
1112
|
-
* comment request is settled unless Slides says otherwise. One with any is
|
|
1113
|
-
* settled only on `ALL_SAVED`: anything else — a failure, or no state at all —
|
|
1114
|
-
* is surfaced by name, since its deck changes may have applied while its
|
|
1115
|
-
* comments did not, and the batch is then neither all nor none.
|
|
1116
|
-
*/
|
|
1117
|
-
function commentSaveFields(response, sentComments) {
|
|
1118
|
-
const state = text(response["commentUpdateState"]);
|
|
1119
|
-
const settled = sentComments ? state === "ALL_SAVED" : state === undefined || COMMENTS_SETTLED.has(state);
|
|
1120
|
-
if (settled)
|
|
1121
|
-
return {};
|
|
1122
|
-
return {
|
|
1123
|
-
commentUpdateState: label(state ?? COMMENT_STATE_UNSPECIFIED, MAX_NAME_BYTES, "Slides sent a longer state") ?? "",
|
|
1124
|
-
};
|
|
1125
|
-
}
|
|
1126
|
-
/** What a write whose comments did not all save tells its caller. */
|
|
1127
|
-
const COMMENTS_UNSAVED = "Slides accepted the write but did not report its comment changes saved (commentUpdateState), so it was not all or none: " +
|
|
1128
|
-
"other changes may have applied. Do not repeat it; re-read with list_comments to see what saved.";
|
|
1129
|
-
/** Comment actions that change nothing, and are left unsaid. */
|
|
1130
|
-
const QUIET_ACTIONS = new Set(["COMMENT_ACTION_TYPE_UNSPECIFIED", "NO_COMMENT_ACTION_CHANGE"]);
|
|
1131
|
-
/**
|
|
1132
|
-
* A post as returned: its id and its author's `users/{id}` whole — left out
|
|
1133
|
-
* and flagged on the thread's `idsNotShown`, never cut, should either be too
|
|
1134
|
-
* long to copy — beside the display name, which is not an identity.
|
|
1135
|
-
* `contentHtml` is the same text rendered, and is left behind.
|
|
1136
|
-
*/
|
|
1137
|
-
function postPart(value) {
|
|
1138
|
-
const post = asRecord(value);
|
|
1139
|
-
const author = asRecord(post["author"]);
|
|
1140
|
-
const postId = wholeId(post["postId"]);
|
|
1141
|
-
const user = wholeId(author["user"]);
|
|
1142
|
-
const shownAuthor = compact({
|
|
1143
|
-
user,
|
|
1144
|
-
displayName: label(text(author["displayName"]), MAX_NAME_BYTES, "Slides shows the whole name"),
|
|
1145
|
-
me: author["me"] === true || undefined,
|
|
1146
|
-
anonymous: author["anonymous"] === true || undefined,
|
|
1147
|
-
});
|
|
1148
|
-
const action = text(post["commentAction"]);
|
|
1149
|
-
const whole = "Slides shows all of it";
|
|
1150
|
-
return {
|
|
1151
|
-
row: compact({
|
|
1152
|
-
postId,
|
|
1153
|
-
author: Object.keys(shownAuthor).length > 0 ? shownAuthor : undefined,
|
|
1154
|
-
commentAction: action && !QUIET_ACTIONS.has(action) ? label(action, MAX_NAME_BYTES, whole) : undefined,
|
|
1155
|
-
assigneeEmail: label(text(post["assigneeEmail"]), MAX_COMMENT_BYTES + 64, whole),
|
|
1156
|
-
createTime: label(text(post["createTime"]), MAX_NAME_BYTES, whole),
|
|
1157
|
-
updateTime: label(text(post["updateTime"]), MAX_NAME_BYTES, whole),
|
|
1158
|
-
deleted: post["deleted"] === true || undefined,
|
|
1159
|
-
// Slides gives an imported post no author id; this says why.
|
|
1160
|
-
imported: post["fromImportedPresentation"] === true || undefined,
|
|
1161
|
-
}),
|
|
1162
|
-
content: typeof post["content"] === "string" ? post["content"] : "",
|
|
1163
|
-
idsNotShown: (text(post["postId"]) !== undefined && postId === undefined) ||
|
|
1164
|
-
(text(author["user"]) !== undefined && user === undefined),
|
|
1165
|
-
};
|
|
1166
|
-
}
|
|
1167
|
-
/** A post in a write result: its text bounded, never its ids. */
|
|
1168
|
-
function writtenPost(value, maxBytes = MAX_POST_BYTES) {
|
|
1169
|
-
const post = postPart(value);
|
|
1170
|
-
return compact({
|
|
1171
|
-
...post.row,
|
|
1172
|
-
content: label(text(post.content), maxBytes, "list_comments has the whole post"),
|
|
1173
|
-
});
|
|
1174
|
-
}
|
|
1175
|
-
function anchorPlaces(pages) {
|
|
1176
|
-
const places = new Map();
|
|
1177
|
-
for (const page of pages) {
|
|
1178
|
-
const pageId = text(page["objectId"]);
|
|
1179
|
-
for (const anchor of asArray(page["commentAnchors"]).map(asRecord)) {
|
|
1180
|
-
const anchorId = text(anchor["anchorId"]);
|
|
1181
|
-
if (anchorId === undefined)
|
|
1182
|
-
continue;
|
|
1183
|
-
let place = places.get(anchorId);
|
|
1184
|
-
if (!place)
|
|
1185
|
-
places.set(anchorId, (place = { pages: new Set(), objects: new Set() }));
|
|
1186
|
-
if (pageId)
|
|
1187
|
-
place.pages.add(pageId);
|
|
1188
|
-
for (const object of asArray(anchor["objectAnchors"]).map(asRecord)) {
|
|
1189
|
-
// An anchor on the page itself names the page, already listed.
|
|
1190
|
-
const objectId = text(object["objectId"]);
|
|
1191
|
-
if (objectId && objectId !== pageId)
|
|
1192
|
-
place.objects.add(objectId);
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
return places;
|
|
1197
|
-
}
|
|
1198
|
-
/** Every page a deck read returns anchors on: slides, notes pages, layouts, masters, the notes master. */
|
|
1199
|
-
function commentPages(presentation) {
|
|
1200
|
-
const slides = asArray(presentation["slides"]).map(asRecord);
|
|
1201
|
-
return [
|
|
1202
|
-
...slides,
|
|
1203
|
-
...slides.map((slide) => asRecord(asRecord(slide["slideProperties"])["notesPage"])),
|
|
1204
|
-
...asArray(presentation["layouts"]).map(asRecord),
|
|
1205
|
-
...asArray(presentation["masters"]).map(asRecord),
|
|
1206
|
-
asRecord(presentation["notesMaster"]),
|
|
1207
|
-
];
|
|
1208
|
-
}
|
|
1209
|
-
function threadParts(value, places) {
|
|
1210
|
-
const thread = asRecord(value);
|
|
1211
|
-
const commentId = wholeId(thread["commentId"]);
|
|
1212
|
-
const anchorId = wholeId(thread["anchorId"]);
|
|
1213
|
-
const place = anchorId === undefined ? undefined : places.get(anchorId);
|
|
1214
|
-
const ids = (values) => [...(values ?? [])].map(wholeId).filter((id) => id !== undefined);
|
|
1215
|
-
const pageIds = ids(place?.pages);
|
|
1216
|
-
const objectIds = ids(place?.objects);
|
|
1217
|
-
const replies = asArray(thread["replies"]).map(postPart);
|
|
1218
|
-
const head = thread["headPost"] && typeof thread["headPost"] === "object" ? postPart(thread["headPost"]) : undefined;
|
|
1219
|
-
return {
|
|
1220
|
-
row: compact({
|
|
1221
|
-
commentId,
|
|
1222
|
-
anchorId,
|
|
1223
|
-
status: label(text(thread["status"]), MAX_NAME_BYTES, "Slides shows the status"),
|
|
1224
|
-
// Every id, as everywhere: Slides anchors a group's comment to at most
|
|
1225
|
-
// 100 elements, and the page's budget holds them.
|
|
1226
|
-
pageObjectIds: pageIds.length > 0 ? pageIds : undefined,
|
|
1227
|
-
objectIds: objectIds.length > 0 ? objectIds : undefined,
|
|
1228
|
-
replyCount: replies.length,
|
|
1229
|
-
idsNotShown: (text(thread["commentId"]) !== undefined && commentId === undefined) ||
|
|
1230
|
-
(text(thread["anchorId"]) !== undefined && anchorId === undefined) ||
|
|
1231
|
-
head?.idsNotShown ||
|
|
1232
|
-
replies.some((reply) => reply.idsNotShown) ||
|
|
1233
|
-
undefined,
|
|
1234
|
-
}),
|
|
1235
|
-
quote: typeof thread["plainTextQuote"] === "string" ? thread["plainTextQuote"] : "",
|
|
1236
|
-
head,
|
|
1237
|
-
replies,
|
|
1238
|
-
};
|
|
1239
|
-
}
|
|
1240
|
-
/**
|
|
1241
|
-
* A post row carrying `content` from `offset`, and, when cut — which also
|
|
1242
|
-
* marks its thread `truncated` — the whole length in `contentLength`.
|
|
1243
|
-
*/
|
|
1244
|
-
function postRow(post, content, offset, cutFrom) {
|
|
1245
|
-
return compact({
|
|
1246
|
-
...post.row,
|
|
1247
|
-
content: content === "" && cutFrom === undefined ? undefined : content,
|
|
1248
|
-
contentOffset: offset > 0 ? offset : undefined,
|
|
1249
|
-
contentLength: cutFrom,
|
|
1250
|
-
});
|
|
1251
|
-
}
|
|
1252
|
-
/**
|
|
1253
|
-
* Part `j` of a thread placed into its row with `content` from `offset`:
|
|
1254
|
-
* 0 the quote, 1 the head post, 2 on each reply in turn. `undefined` for a
|
|
1255
|
-
* part the thread does not have. `cutFrom`, the part's whole length, marks it
|
|
1256
|
-
* cut.
|
|
1257
|
-
*/
|
|
1258
|
-
function placePart(thread, row, j, content, offset, cutFrom) {
|
|
1259
|
-
if (j === 0) {
|
|
1260
|
-
if (thread.quote === "")
|
|
1261
|
-
return undefined;
|
|
1262
|
-
return compact({ ...row, quote: content, quoteOffset: offset > 0 ? offset : undefined, quoteLength: cutFrom });
|
|
1263
|
-
}
|
|
1264
|
-
if (j === 1)
|
|
1265
|
-
return thread.head ? { ...row, headPost: postRow(thread.head, content, offset, cutFrom) } : undefined;
|
|
1266
|
-
const reply = thread.replies[j - 2];
|
|
1267
|
-
if (!reply)
|
|
1268
|
-
return undefined;
|
|
1269
|
-
const replies = asArray(row["replies"]);
|
|
1270
|
-
return compact({
|
|
1271
|
-
...row,
|
|
1272
|
-
// A row that continues a thread from a previous page starts its replies past 0.
|
|
1273
|
-
repliesOffset: replies.length === 0 && j > 2 ? j - 2 : row["repliesOffset"],
|
|
1274
|
-
replies: [...replies, postRow(reply, content, offset, cutFrom)],
|
|
1275
|
-
});
|
|
1276
|
-
}
|
|
1277
|
-
function partText(thread, j) {
|
|
1278
|
-
if (j === 0)
|
|
1279
|
-
return thread.quote;
|
|
1280
|
-
if (j === 1)
|
|
1281
|
-
return thread.head?.content ?? "";
|
|
1282
|
-
return thread.replies[j - 2]?.content ?? "";
|
|
1283
|
-
}
|
|
1284
|
-
/**
|
|
1285
|
-
* Thread rows from thread `i`, part `j`, text offset `o`, under `limit` rows
|
|
1286
|
-
* and `room` bytes. Every thread's ids come whole on every row it appears
|
|
1287
|
-
* on; a quote or post too long for what is left is cut at a character, and
|
|
1288
|
-
* the next page continues that thread (`continued`) from the cut — the same
|
|
1289
|
-
* way get_page continues an element's text.
|
|
1290
|
-
*/
|
|
1291
|
-
function threadRows(threads, start, limit, room) {
|
|
1292
|
-
const out = [];
|
|
1293
|
-
let used = 2;
|
|
1294
|
-
let { i, j, o } = start;
|
|
1295
|
-
const fits = (row) => used + jsonBytes(row) + 1 <= room;
|
|
1296
|
-
const unfit = () => new ConnectorCallError("connector_call_failed", "list_comments could not fit any of a comment thread in one result; raw: true pages it in JSON chunks.", {
|
|
1297
|
-
retryable: false,
|
|
1298
|
-
});
|
|
1299
|
-
while (i < threads.length && out.length < limit) {
|
|
1300
|
-
const thread = threads[i];
|
|
1301
|
-
// Where this row starts: a row that places nothing leaves the next page
|
|
1302
|
-
// to start here, so it is not called continued for parts it skipped.
|
|
1303
|
-
const from = { i, j, o };
|
|
1304
|
-
let row = compact({ ...thread.row, continued: j > 0 || o > 0 ? true : undefined });
|
|
1305
|
-
if (!fits(row)) {
|
|
1306
|
-
if (out.length > 0)
|
|
1307
|
-
break;
|
|
1308
|
-
throw unfit();
|
|
1309
|
-
}
|
|
1310
|
-
let placed = false;
|
|
1311
|
-
for (const parts = thread.replies.length + 2; j < parts; j += 1, o = 0) {
|
|
1312
|
-
const whole = partText(thread, j);
|
|
1313
|
-
const rest = whole.slice(o);
|
|
1314
|
-
const next = placePart(thread, row, j, rest, o);
|
|
1315
|
-
if (next === undefined)
|
|
1316
|
-
continue;
|
|
1317
|
-
if (fits(next)) {
|
|
1318
|
-
row = next;
|
|
1319
|
-
placed = true;
|
|
1320
|
-
continue;
|
|
1321
|
-
}
|
|
1322
|
-
// The row as it would be with this part cut, so the text's share is
|
|
1323
|
-
// measured against everything else the row carries.
|
|
1324
|
-
const shell = { ...placePart(thread, row, j, "", o, whole.length), truncated: true };
|
|
1325
|
-
const available = room - used - 1 - (jsonBytes(shell) - 2);
|
|
1326
|
-
const cut = available > 2 && rest !== ""
|
|
1327
|
-
? prefixWithin(rest, available, (dropped) => `\n[… ${dropped} more characters continue on the next page]`)
|
|
1328
|
-
: { text: "", kept: 0 };
|
|
1329
|
-
if (cut.kept === 0) {
|
|
1330
|
-
if (!placed) {
|
|
1331
|
-
if (out.length > 0)
|
|
1332
|
-
return { threads: out, next: from };
|
|
1333
|
-
throw unfit();
|
|
1334
|
-
}
|
|
1335
|
-
out.push({ ...row, truncated: true });
|
|
1336
|
-
return { threads: out, next: { i, j, o } };
|
|
1337
|
-
}
|
|
1338
|
-
out.push({ ...placePart(thread, row, j, cut.text, o, whole.length), truncated: true });
|
|
1339
|
-
return { threads: out, next: { i, j, o: o + cut.kept } };
|
|
1340
|
-
}
|
|
1341
|
-
out.push(row);
|
|
1342
|
-
used += jsonBytes(row) + 1;
|
|
1343
|
-
i += 1;
|
|
1344
|
-
j = 0;
|
|
1345
|
-
o = 0;
|
|
1346
|
-
}
|
|
1347
|
-
return { threads: out, next: i < threads.length ? { i, j, o } : undefined };
|
|
1348
|
-
}
|
|
1349
|
-
/**
|
|
1350
|
-
* The state a list_comments cursor binds to: the revision, the threads
|
|
1351
|
-
* themselves, and the anchors that place them. Slides saves comments apart
|
|
1352
|
-
* from the deck, so a reply added between pages need not move the revision,
|
|
1353
|
-
* and an anchor moved from one element to another need not change a thread;
|
|
1354
|
-
* binding to the exact JSON of both makes either a `conflict`, never a page
|
|
1355
|
-
* that skips, repeats, or places a thread where it no longer is.
|
|
1356
|
-
*/
|
|
1357
|
-
function commentsState(revisionId, comments, pages) {
|
|
1358
|
-
return stateOf(undefined, () => [
|
|
1359
|
-
text(revisionId) ?? null,
|
|
1360
|
-
comments,
|
|
1361
|
-
pages.map((page) => [page["objectId"] ?? null, page["commentAnchors"] ?? null]),
|
|
1362
|
-
]);
|
|
1363
|
-
}
|
|
1364
|
-
/**
|
|
1365
|
-
* A thread as Slides sends it — what raw: true returns, and where the
|
|
1366
|
-
* projection's dropped fields live: `contentHtml` and
|
|
1367
|
-
* `fromCopiedPresentation` — with one key added, `commentAnchors`: every
|
|
1368
|
-
* CommentAnchor on the pages read that bears its anchorId, as Slides sends
|
|
1369
|
-
* it, with the `pageObjectId` it sits on. Their text and cell ranges are the
|
|
1370
|
-
* anchor fields the projection leaves out.
|
|
1371
|
-
*/
|
|
1372
|
-
function rawThread(value, pages) {
|
|
1373
|
-
const thread = asRecord(value);
|
|
1374
|
-
const anchorId = thread["anchorId"];
|
|
1375
|
-
const anchors = pages.flatMap((page) => asArray(page["commentAnchors"])
|
|
1376
|
-
.map(asRecord)
|
|
1377
|
-
.filter((anchor) => anchorId !== undefined && anchor["anchorId"] === anchorId)
|
|
1378
|
-
.map((anchor) => ({ pageObjectId: page["objectId"], ...anchor })));
|
|
1379
|
-
return anchors.length > 0 ? { ...thread, commentAnchors: anchors } : thread;
|
|
1380
|
-
}
|
|
1381
|
-
const COMMENTS_RAW = {
|
|
1382
|
-
tool: "list_comments",
|
|
1383
|
-
propertiesFirst: false,
|
|
1384
|
-
idOf: (item) => ({ commentId: wholeId(item["commentId"]) }),
|
|
1385
|
-
};
|
|
1386
|
-
/** A 403 on a comment read: what it most likely means, beside Google's words. */
|
|
1387
|
-
function commentAccess(error) {
|
|
1388
|
-
if (!(error instanceof ConnectorCallError) || googleOutcomeOf(error)?.status !== 403)
|
|
1389
|
-
return error;
|
|
1390
|
-
return new ConnectorCallError(error.code, `${error.message} Reading comments needs comment access: an account that may only view the deck cannot read them.`, { retryable: error.retryable, cause: error });
|
|
1391
|
-
}
|
|
1392
|
-
/** A comment's text, refused locally past Slides' own cap rather than sent to fail. */
|
|
1393
|
-
function commentText(value, field) {
|
|
1394
|
-
if (value === undefined)
|
|
1395
|
-
return undefined;
|
|
1396
|
-
const content = String(value);
|
|
1397
|
-
const bytes = new TextEncoder().encode(content).length;
|
|
1398
|
-
if (bytes > MAX_COMMENT_BYTES) {
|
|
1399
|
-
throw new ConnectorCallError("invalid_args", `${field} is ${bytes} UTF-8 bytes; Slides accepts at most ${MAX_COMMENT_BYTES}. Nothing was sent.`);
|
|
1400
|
-
}
|
|
1401
|
-
return content;
|
|
1402
|
-
}
|
|
1403
|
-
/**
|
|
1404
|
-
* One comment request as its own batch, and what its result always carries:
|
|
1405
|
-
* the deck, the revision it left, and — whenever Slides does not confirm the
|
|
1406
|
-
* comment saved — the state it reported and what to do about it.
|
|
1407
|
-
*/
|
|
1408
|
-
async function commentWrite(client, ctx, presentationId, request, requiredRevisionId) {
|
|
1409
|
-
const response = await batchUpdate(client, ctx, presentationId, [request], requiredRevisionId);
|
|
1410
|
-
const state = commentSaveFields(response, true);
|
|
1411
|
-
return {
|
|
1412
|
-
reply: asRecord(asArray(response["replies"])[0]),
|
|
1413
|
-
header: compact({ presentationId, ...revisionFields(response), ...state }),
|
|
1414
|
-
saved: state.commentUpdateState === undefined,
|
|
1415
|
-
};
|
|
1416
|
-
}
|
|
1417
|
-
/**
|
|
1418
|
-
* One post added to a thread — a reply, or a status or assignee change —
|
|
1419
|
-
* and its result: the new post's id and author whole, or the write's comment
|
|
1420
|
-
* state when Slides does not confirm it saved.
|
|
1421
|
-
*/
|
|
1422
|
-
async function postToThread(client, ctx, tool, args, post) {
|
|
1423
|
-
const written = await commentWrite(client, ctx, args["presentationId"], {
|
|
1424
|
-
addCommentReply: { commentId: args["commentId"], post: compact(post) },
|
|
1425
|
-
}).catch((error) => {
|
|
1426
|
-
throw afterUnknownCreate(error, "Read the thread with list_comments and look for the post before sending it again; a second one is a second post.");
|
|
1427
|
-
});
|
|
1428
|
-
const reread = "read the thread with list_comments to find the post";
|
|
1429
|
-
const created = asRecord(written.reply["addCommentReply"])["post"];
|
|
1430
|
-
if (wholeId(asRecord(created)["postId"]) === undefined && written.saved) {
|
|
1431
|
-
throw appliedButUnreadable(tool, "Google returned no usable id for the new post", reread);
|
|
1432
|
-
}
|
|
1433
|
-
return appliedDeliverable(compact({
|
|
1434
|
-
...written.header,
|
|
1435
|
-
commentId: args["commentId"],
|
|
1436
|
-
post: created && typeof created === "object" ? writtenPost(created) : undefined,
|
|
1437
|
-
idsNotShown: postPart(created).idsNotShown || undefined,
|
|
1438
|
-
note: written.saved ? undefined : COMMENTS_UNSAVED,
|
|
1439
|
-
}), tool, reread);
|
|
1440
|
-
}
|
|
1441
|
-
// --- Schemas ----------------------------------------------------------------------
|
|
1442
|
-
function input(properties, required) {
|
|
1443
|
-
return { type: "object", properties, required, additionalProperties: false };
|
|
1444
|
-
}
|
|
1445
|
-
const PRESENTATION_ID = {
|
|
1446
|
-
type: "string",
|
|
1447
|
-
minLength: 1,
|
|
1448
|
-
maxLength: 256,
|
|
1449
|
-
pattern: "^[A-Za-z0-9_-]+$",
|
|
1450
|
-
description: "Presentation id: the part after /presentation/d/ in its URL, or from Drive.",
|
|
1451
|
-
};
|
|
1452
|
-
/** Slides object ids: a word character, then word characters, `-`, or `:`. */
|
|
1453
|
-
function objectIdProperty(description) {
|
|
1454
|
-
return { type: "string", minLength: 1, maxLength: 256, pattern: "^[A-Za-z0-9_][A-Za-z0-9_:-]*$", description };
|
|
1455
|
-
}
|
|
1456
|
-
const REVISION_PROPERTY = {
|
|
1457
|
-
type: "string",
|
|
1458
|
-
minLength: 1,
|
|
1459
|
-
maxLength: 512,
|
|
1460
|
-
pattern: "^[^\\s]+$",
|
|
1461
|
-
description: "revisionId from get_presentation; the write is refused, unapplied, if the deck changed since.",
|
|
1462
|
-
};
|
|
1463
|
-
const CURSOR_PROPERTY = {
|
|
1464
|
-
type: "string",
|
|
1465
|
-
minLength: 1,
|
|
1466
|
-
maxLength: 4096,
|
|
1467
|
-
pattern: "^[A-Za-z0-9_-]+$",
|
|
1468
|
-
description: "Opaque page.nextCursor, or a textCursor-style cursor, this connection returned. Pass it back unchanged.",
|
|
1469
|
-
};
|
|
1470
|
-
function limitProperty(maximum, fallback, what) {
|
|
1471
|
-
return {
|
|
1472
|
-
type: "integer",
|
|
1473
|
-
minimum: 1,
|
|
1474
|
-
maximum,
|
|
1475
|
-
description: `${what} per page, 1 to ${maximum}; defaults to ${fallback}. A page also ends before ~192 KB.`,
|
|
1476
|
-
};
|
|
1477
|
-
}
|
|
1478
|
-
const PAGE_SCHEMA = {
|
|
1479
|
-
type: "object",
|
|
1480
|
-
properties: {
|
|
1481
|
-
hasMore: { type: "boolean" },
|
|
1482
|
-
nextCursor: { type: ["string", "null"] },
|
|
1483
|
-
},
|
|
1484
|
-
required: ["hasMore", "nextCursor"],
|
|
1485
|
-
};
|
|
1486
|
-
const DIMENSION_SCHEMA = {
|
|
1487
|
-
type: "object",
|
|
1488
|
-
properties: { magnitude: { type: "number" }, unit: { type: "string" } },
|
|
1489
|
-
};
|
|
1490
|
-
const KIND_SCHEMA = {
|
|
1491
|
-
type: "string",
|
|
1492
|
-
enum: ["shape", "table", "wordArt", "image", "video", "chart", "line", "other"],
|
|
1493
|
-
};
|
|
1494
|
-
const SLIDE_ELEMENT_SCHEMA = {
|
|
1495
|
-
type: "object",
|
|
1496
|
-
properties: {
|
|
1497
|
-
objectId: { type: "string" },
|
|
1498
|
-
kind: KIND_SCHEMA,
|
|
1499
|
-
placeholder: { type: "string" },
|
|
1500
|
-
text: { type: "string" },
|
|
1501
|
-
truncated: { type: "boolean" },
|
|
1502
|
-
textCursor: { type: "string" },
|
|
1503
|
-
altText: { type: "string" },
|
|
1504
|
-
spreadsheetId: { type: "string" },
|
|
1505
|
-
},
|
|
1506
|
-
};
|
|
1507
|
-
const SLIDE_SCHEMA = {
|
|
1508
|
-
type: "object",
|
|
1509
|
-
properties: {
|
|
1510
|
-
objectId: { type: "string" },
|
|
1511
|
-
index: { type: "integer" },
|
|
1512
|
-
layout: { type: "string" },
|
|
1513
|
-
layoutId: { type: "string" },
|
|
1514
|
-
skipped: { type: "boolean" },
|
|
1515
|
-
title: { type: "string" },
|
|
1516
|
-
elements: { type: "array", items: SLIDE_ELEMENT_SCHEMA },
|
|
1517
|
-
omittedElements: { type: "integer" },
|
|
1518
|
-
elementsNotShown: { type: "integer" },
|
|
1519
|
-
elementsCursor: { type: "string" },
|
|
1520
|
-
notesPageId: { type: "string" },
|
|
1521
|
-
notes: { type: "string" },
|
|
1522
|
-
notesCursor: { type: "string" },
|
|
1523
|
-
truncated: { type: "boolean" },
|
|
1524
|
-
rawNotShown: { type: "string" },
|
|
1525
|
-
},
|
|
1526
|
-
};
|
|
1527
|
-
const LAYOUT_ROW_SCHEMA = {
|
|
1528
|
-
type: "object",
|
|
1529
|
-
properties: {
|
|
1530
|
-
objectId: { type: "string" },
|
|
1531
|
-
kind: { type: "string", enum: ["master", "layout"] },
|
|
1532
|
-
name: { type: "string" },
|
|
1533
|
-
displayName: { type: "string" },
|
|
1534
|
-
masterId: { type: "string" },
|
|
1535
|
-
},
|
|
1536
|
-
};
|
|
1537
|
-
const CHUNK_SCHEMA = {
|
|
1538
|
-
type: "object",
|
|
1539
|
-
properties: { json: { type: "string" }, offset: { type: "integer" }, length: { type: "integer" } },
|
|
1540
|
-
};
|
|
1541
|
-
const PAGE_ELEMENT_SCHEMA = {
|
|
1542
|
-
type: "object",
|
|
1543
|
-
properties: {
|
|
1544
|
-
objectId: { type: "string" },
|
|
1545
|
-
kind: KIND_SCHEMA,
|
|
1546
|
-
groupId: { type: "string" },
|
|
1547
|
-
placeholder: { type: "string" },
|
|
1548
|
-
placeholderIndex: { type: "integer" },
|
|
1549
|
-
placeholderParentId: { type: "string" },
|
|
1550
|
-
text: { type: "string" },
|
|
1551
|
-
textOffset: { type: "integer" },
|
|
1552
|
-
textLength: { type: "integer" },
|
|
1553
|
-
truncated: { type: "boolean" },
|
|
1554
|
-
altText: { type: "string" },
|
|
1555
|
-
spreadsheetId: { type: "string" },
|
|
1556
|
-
rawJson: CHUNK_SCHEMA,
|
|
1557
|
-
},
|
|
1558
|
-
};
|
|
1559
|
-
const WRITE_RESULT_PROPERTIES = {
|
|
1560
|
-
presentationId: { type: "string" },
|
|
1561
|
-
revisionId: { type: "string" },
|
|
1562
|
-
revisionIdNotShown: { type: "boolean" },
|
|
1563
|
-
};
|
|
1564
|
-
/** Comment and post ids go in a request body, never a path; Slides validates them. */
|
|
1565
|
-
function commentIdProperty(description) {
|
|
1566
|
-
return { type: "string", minLength: 1, maxLength: 1024, pattern: "^\\S+$", description };
|
|
1567
|
-
}
|
|
1568
|
-
const COMMENT_ID = commentIdProperty("commentId from list_comments or create_comment.");
|
|
1569
|
-
const POST_ID = commentIdProperty("postId of the post, from list_comments.");
|
|
1570
|
-
function commentContentProperty(description) {
|
|
1571
|
-
return { type: "string", minLength: 1, maxLength: MAX_COMMENT_BYTES, description };
|
|
1572
|
-
}
|
|
1573
|
-
const ASSIGNEE_PROPERTY = {
|
|
1574
|
-
type: "string",
|
|
1575
|
-
minLength: 3,
|
|
1576
|
-
maxLength: 320,
|
|
1577
|
-
pattern: "^[^\\s@]+@[^\\s@]+$",
|
|
1578
|
-
description: "Assign the thread to this address; Slides notifies them.",
|
|
1579
|
-
};
|
|
1580
|
-
const POST_SCHEMA = {
|
|
1581
|
-
type: "object",
|
|
1582
|
-
properties: {
|
|
1583
|
-
postId: { type: "string" },
|
|
1584
|
-
author: {
|
|
1585
|
-
type: "object",
|
|
1586
|
-
properties: {
|
|
1587
|
-
user: { type: "string" },
|
|
1588
|
-
displayName: { type: "string" },
|
|
1589
|
-
me: { type: "boolean" },
|
|
1590
|
-
anonymous: { type: "boolean" },
|
|
1591
|
-
},
|
|
1592
|
-
},
|
|
1593
|
-
content: { type: "string" },
|
|
1594
|
-
contentOffset: { type: "integer" },
|
|
1595
|
-
contentLength: { type: "integer" },
|
|
1596
|
-
commentAction: { type: "string" },
|
|
1597
|
-
assigneeEmail: { type: "string" },
|
|
1598
|
-
createTime: { type: "string" },
|
|
1599
|
-
updateTime: { type: "string" },
|
|
1600
|
-
deleted: { type: "boolean" },
|
|
1601
|
-
imported: { type: "boolean" },
|
|
1602
|
-
},
|
|
1603
|
-
};
|
|
1604
|
-
/** What every comment write returns beside its own ids. */
|
|
1605
|
-
const COMMENT_WRITE_PROPERTIES = {
|
|
1606
|
-
...WRITE_RESULT_PROPERTIES,
|
|
1607
|
-
commentUpdateState: { type: "string" },
|
|
1608
|
-
note: { type: "string" },
|
|
1609
|
-
};
|
|
1610
|
-
/** What a post added to a thread returns. */
|
|
1611
|
-
const REPLY_RESULT_SCHEMA = {
|
|
1612
|
-
type: "object",
|
|
1613
|
-
properties: {
|
|
1614
|
-
...COMMENT_WRITE_PROPERTIES,
|
|
1615
|
-
commentId: { type: "string" },
|
|
1616
|
-
post: POST_SCHEMA,
|
|
1617
|
-
idsNotShown: { type: "boolean" },
|
|
1618
|
-
},
|
|
1619
|
-
required: ["presentationId", "commentId"],
|
|
1620
|
-
};
|
|
1621
|
-
// --- Tools ------------------------------------------------------------------------
|
|
1622
|
-
function tools(client) {
|
|
1623
|
-
const readOnly = { readOnlyHint: true };
|
|
1624
|
-
const presentationPath = (id) => `/presentations/${encodeURIComponent(id)}`;
|
|
1625
|
-
const nextPage = (cursor) => ({ hasMore: cursor !== null, nextCursor: cursor });
|
|
1626
|
-
return [
|
|
1627
|
-
{
|
|
1628
|
-
name: "get_presentation",
|
|
1629
|
-
description: "Read a Google Slides deck: title, page size, revisionId, layouts, and each slide's text in reading order with speaker notes, capped per slide. Cannot find decks; that is Drive's job.",
|
|
1630
|
-
annotations: readOnly,
|
|
1631
|
-
inputSchema: input({
|
|
1632
|
-
presentationId: PRESENTATION_ID,
|
|
1633
|
-
limit: limitProperty(MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE, "Slides"),
|
|
1634
|
-
cursor: CURSOR_PROPERTY,
|
|
1635
|
-
maxCharsPerSlide: {
|
|
1636
|
-
type: "integer",
|
|
1637
|
-
minimum: 0,
|
|
1638
|
-
maximum: MAX_SLIDE_CHARS,
|
|
1639
|
-
description: `Text kept per slide (notes apart), 0 to ${MAX_SLIDE_CHARS}; defaults to ${DEFAULT_SLIDE_CHARS}. A cut gives a get_page cursor for the rest.`,
|
|
1640
|
-
},
|
|
1641
|
-
raw: {
|
|
1642
|
-
type: "boolean",
|
|
1643
|
-
description: "Return this page's slides as Slides sends them, every style and transform, in place of the projection. No layouts or masters.",
|
|
1644
|
-
},
|
|
1645
|
-
}, ["presentationId"]),
|
|
1646
|
-
outputSchema: {
|
|
1647
|
-
type: "object",
|
|
1648
|
-
properties: {
|
|
1649
|
-
presentationId: { type: "string" },
|
|
1650
|
-
title: { type: "string" },
|
|
1651
|
-
revisionId: { type: "string" },
|
|
1652
|
-
url: { type: "string" },
|
|
1653
|
-
locale: { type: "string" },
|
|
1654
|
-
pageSize: {
|
|
1655
|
-
type: "object",
|
|
1656
|
-
properties: { width: DIMENSION_SCHEMA, height: DIMENSION_SCHEMA },
|
|
1657
|
-
},
|
|
1658
|
-
slideCount: { type: "integer" },
|
|
1659
|
-
notesMasterId: { type: "string" },
|
|
1660
|
-
layouts: { type: "array", items: LAYOUT_ROW_SCHEMA },
|
|
1661
|
-
layoutsNotShown: { type: "integer" },
|
|
1662
|
-
layoutsCursor: { type: "string" },
|
|
1663
|
-
slides: { type: "array", items: SLIDE_SCHEMA },
|
|
1664
|
-
page: PAGE_SCHEMA,
|
|
1665
|
-
},
|
|
1666
|
-
// ProtoJSON drops empty arrays, and `raw: true` passes Slides' own
|
|
1667
|
-
// slide objects through, so only what connecta always sets is required.
|
|
1668
|
-
required: ["presentationId", "slides", "page"],
|
|
1669
|
-
},
|
|
1670
|
-
handler: async (args, ctx) => {
|
|
1671
|
-
const presentationId = args["presentationId"];
|
|
1672
|
-
const raw = args["raw"] === true;
|
|
1673
|
-
const cursor = decodeCursor(args["cursor"], { k: "deck", p: presentationId, raw });
|
|
1674
|
-
const presentation = asRecord(await client.json({
|
|
1675
|
-
method: "GET",
|
|
1676
|
-
path: presentationPath(presentationId),
|
|
1677
|
-
query: { fields: raw ? undefined : PRESENTATION_FIELDS },
|
|
1678
|
-
}, ctx));
|
|
1679
|
-
const all = asArray(presentation["slides"]);
|
|
1680
|
-
const revisionId = presentation["revisionId"];
|
|
1681
|
-
const state = await stateOf(revisionId, () => all.map((slide) => asRecord(slide)["objectId"] ?? null));
|
|
1682
|
-
assertUnchanged(cursor, state, "deck's slides");
|
|
1683
|
-
const start = cursor?.i ?? 0;
|
|
1684
|
-
const limit = typeof args["limit"] === "number" ? args["limit"] : DEFAULT_PAGE_SIZE;
|
|
1685
|
-
const maxChars = typeof args["maxCharsPerSlide"] === "number" ? args["maxCharsPerSlide"] : DEFAULT_SLIDE_CHARS;
|
|
1686
|
-
const deck = { presentationId, revisionId, layouts: layoutNames(presentation) };
|
|
1687
|
-
const pageSize = asRecord(presentation["pageSize"]);
|
|
1688
|
-
const width = dimension(pageSize["width"]);
|
|
1689
|
-
const height = dimension(pageSize["height"]);
|
|
1690
|
-
// The first projected page previews the layouts, bounded; the rest
|
|
1691
|
-
// continue in list_layouts from layoutsCursor.
|
|
1692
|
-
let preview = {};
|
|
1693
|
-
if (start === 0 && !raw) {
|
|
1694
|
-
const rows = layoutRows(presentation);
|
|
1695
|
-
const shown = rowsWithin(rows, 0, MAX_ROWS, MAX_LAYOUT_PREVIEW_BYTES);
|
|
1696
|
-
preview = compact({
|
|
1697
|
-
layouts: shown.rows,
|
|
1698
|
-
layoutsNotShown: shown.next < rows.length ? rows.length - shown.next : undefined,
|
|
1699
|
-
layoutsCursor: shown.next < rows.length
|
|
1700
|
-
? encodeCursor({
|
|
1701
|
-
k: "layouts",
|
|
1702
|
-
p: presentationId,
|
|
1703
|
-
s: await stateOf(revisionId, () => layoutFingerprint(rows)),
|
|
1704
|
-
i: shown.next,
|
|
1705
|
-
})
|
|
1706
|
-
: undefined,
|
|
1707
|
-
});
|
|
1708
|
-
}
|
|
1709
|
-
const header = compact({
|
|
1710
|
-
presentationId: text(presentation["presentationId"]) ?? presentationId,
|
|
1711
|
-
title: label(text(presentation["title"]), MAX_TITLE_BYTES, "Drive shows the whole title"),
|
|
1712
|
-
// Only an account that may edit the deck is given one.
|
|
1713
|
-
revisionId: text(revisionId),
|
|
1714
|
-
url: editUrl(presentationId),
|
|
1715
|
-
locale: text(presentation["locale"]),
|
|
1716
|
-
pageSize: width && height ? { width, height } : undefined,
|
|
1717
|
-
slideCount: all.length,
|
|
1718
|
-
// The one page no slide, layout, or master links to; get_page reads it.
|
|
1719
|
-
notesMasterId: text(asRecord(presentation["notesMaster"])["objectId"]),
|
|
1720
|
-
...preview,
|
|
1721
|
-
});
|
|
1722
|
-
const issue = (i) => encodeCursor({ k: "deck", p: presentationId, s: state, raw: raw ? 1 : undefined, i });
|
|
1723
|
-
// A page ends at the slide limit or at the byte budget, whichever is
|
|
1724
|
-
// first; the budget counts the header and the longest cursor.
|
|
1725
|
-
let used = jsonBytes({ ...header, slides: [] }) + pageEnvelopeBytes({ k: "deck", p: presentationId, s: state, i: 0 });
|
|
1726
|
-
const slides = [];
|
|
1727
|
-
let next = start;
|
|
1728
|
-
while (next < all.length && slides.length < limit) {
|
|
1729
|
-
const room = RESULT_BUDGET_BYTES - used - 1;
|
|
1730
|
-
let slide = raw ? asRecord(all[next]) : finished(await projectSlide(all[next], next, deck, maxChars));
|
|
1731
|
-
let size = jsonBytes(slide);
|
|
1732
|
-
if (size > room) {
|
|
1733
|
-
if (slides.length > 0)
|
|
1734
|
-
break;
|
|
1735
|
-
slide = raw ? rawNotShown(all[next], size) : await fitSlide(all[next], next, deck, maxChars, room);
|
|
1736
|
-
size = jsonBytes(slide);
|
|
1737
|
-
}
|
|
1738
|
-
slides.push(slide);
|
|
1739
|
-
used += size + 1;
|
|
1740
|
-
next += 1;
|
|
1741
|
-
}
|
|
1742
|
-
return deliverable({ ...header, slides, page: nextPage(next < all.length ? issue(next) : null) }, "get_presentation");
|
|
1743
|
-
},
|
|
1744
|
-
},
|
|
1745
|
-
{
|
|
1746
|
-
name: "get_page",
|
|
1747
|
-
description: "Read one page by objectId — slide, layout, master, or notes page — every element in reading order, with placeholder ids and text continued across pages.",
|
|
1748
|
-
annotations: readOnly,
|
|
1749
|
-
inputSchema: input({
|
|
1750
|
-
presentationId: PRESENTATION_ID,
|
|
1751
|
-
pageObjectId: objectIdProperty("A slide's, layout's, master's, or notes page's objectId."),
|
|
1752
|
-
limit: limitProperty(MAX_ROWS, DEFAULT_ELEMENTS, "Elements"),
|
|
1753
|
-
cursor: CURSOR_PROPERTY,
|
|
1754
|
-
raw: {
|
|
1755
|
-
type: "boolean",
|
|
1756
|
-
description: "The page and its top-level elements as Slides sends them; one too large is sent in JSON chunks.",
|
|
1757
|
-
},
|
|
1758
|
-
}, ["presentationId", "pageObjectId"]),
|
|
1759
|
-
outputSchema: {
|
|
1760
|
-
type: "object",
|
|
1761
|
-
properties: {
|
|
1762
|
-
presentationId: { type: "string" },
|
|
1763
|
-
pageObjectId: { type: "string" },
|
|
1764
|
-
revisionId: { type: "string" },
|
|
1765
|
-
pageType: { type: "string" },
|
|
1766
|
-
layoutId: { type: "string" },
|
|
1767
|
-
masterId: { type: "string" },
|
|
1768
|
-
notesPageId: { type: "string" },
|
|
1769
|
-
skipped: { type: "boolean" },
|
|
1770
|
-
speakerNotesObjectId: { type: "string" },
|
|
1771
|
-
name: { type: "string" },
|
|
1772
|
-
displayName: { type: "string" },
|
|
1773
|
-
elementCount: { type: "integer" },
|
|
1774
|
-
properties: { type: "object" },
|
|
1775
|
-
propertiesJson: CHUNK_SCHEMA,
|
|
1776
|
-
elements: { type: "array", items: PAGE_ELEMENT_SCHEMA },
|
|
1777
|
-
page: PAGE_SCHEMA,
|
|
1778
|
-
},
|
|
1779
|
-
required: ["presentationId", "pageObjectId", "elements", "page"],
|
|
1780
|
-
},
|
|
1781
|
-
handler: async (args, ctx) => {
|
|
1782
|
-
const presentationId = args["presentationId"];
|
|
1783
|
-
const pageObjectId = args["pageObjectId"];
|
|
1784
|
-
const raw = args["raw"] === true;
|
|
1785
|
-
const cursor = decodeCursor(args["cursor"], { k: "page", p: presentationId, g: pageObjectId, raw });
|
|
1786
|
-
const page = asRecord(await client.json({
|
|
1787
|
-
method: "GET",
|
|
1788
|
-
path: `${presentationPath(presentationId)}/pages/${encodeURIComponent(pageObjectId)}`,
|
|
1789
|
-
query: { fields: raw ? undefined : PAGE_FIELDS },
|
|
1790
|
-
}, ctx));
|
|
1791
|
-
const items = raw ? rawItems(page) : [];
|
|
1792
|
-
const leaves = raw ? [] : readingOrder(page["pageElements"]);
|
|
1793
|
-
const state = await stateOf(page["revisionId"], () => (raw ? rawFingerprint(items) : leafFingerprint(leaves)));
|
|
1794
|
-
assertUnchanged(cursor, state, "page");
|
|
1795
|
-
const header = compact({
|
|
1796
|
-
presentationId,
|
|
1797
|
-
pageObjectId: text(page["objectId"]) ?? pageObjectId,
|
|
1798
|
-
revisionId: text(page["revisionId"]),
|
|
1799
|
-
...describePage(page),
|
|
1800
|
-
elementCount: raw ? items.length - 1 : leaves.length,
|
|
1801
|
-
});
|
|
1802
|
-
const room = RESULT_BUDGET_BYTES -
|
|
1803
|
-
jsonBytes({ ...header, elements: [] }) -
|
|
1804
|
-
pageEnvelopeBytes({
|
|
1805
|
-
k: "page",
|
|
1806
|
-
p: presentationId,
|
|
1807
|
-
g: pageObjectId,
|
|
1808
|
-
s: state,
|
|
1809
|
-
raw: raw ? 1 : undefined,
|
|
1810
|
-
i: 0,
|
|
1811
|
-
}) -
|
|
1812
|
-
64;
|
|
1813
|
-
const limit = typeof args["limit"] === "number" ? args["limit"] : DEFAULT_ELEMENTS;
|
|
1814
|
-
const start = { i: cursor?.i ?? 0, o: cursor?.o ?? 0 };
|
|
1815
|
-
const rows = raw ? rawRows(items, start, limit, room) : projectedRows(leaves, start, limit, room);
|
|
1816
|
-
const nextCursor = rows.next
|
|
1817
|
-
? encodeCursor({
|
|
1818
|
-
k: "page",
|
|
1819
|
-
p: presentationId,
|
|
1820
|
-
g: pageObjectId,
|
|
1821
|
-
s: state,
|
|
1822
|
-
raw: raw ? 1 : undefined,
|
|
1823
|
-
i: rows.next.i,
|
|
1824
|
-
o: rows.next.o ? rows.next.o : undefined,
|
|
1825
|
-
})
|
|
1826
|
-
: null;
|
|
1827
|
-
return deliverable(compact({
|
|
1828
|
-
...header,
|
|
1829
|
-
properties: rows.properties,
|
|
1830
|
-
propertiesJson: rows.propertiesJson,
|
|
1831
|
-
elements: rows.elements,
|
|
1832
|
-
page: nextPage(nextCursor),
|
|
1833
|
-
}), "get_page");
|
|
1834
|
-
},
|
|
1835
|
-
},
|
|
1836
|
-
{
|
|
1837
|
-
name: "list_layouts",
|
|
1838
|
-
description: "List a deck's masters, each followed by its layouts, with the ids create_slide and get_page take. Names only; get_page reads a layout's placeholders.",
|
|
1839
|
-
annotations: readOnly,
|
|
1840
|
-
inputSchema: input({
|
|
1841
|
-
presentationId: PRESENTATION_ID,
|
|
1842
|
-
limit: limitProperty(MAX_ROWS, DEFAULT_LAYOUTS, "Masters and layouts"),
|
|
1843
|
-
cursor: CURSOR_PROPERTY,
|
|
1844
|
-
}, ["presentationId"]),
|
|
1845
|
-
outputSchema: {
|
|
1846
|
-
type: "object",
|
|
1847
|
-
properties: {
|
|
1848
|
-
presentationId: { type: "string" },
|
|
1849
|
-
revisionId: { type: "string" },
|
|
1850
|
-
notesMasterId: { type: "string" },
|
|
1851
|
-
total: { type: "integer" },
|
|
1852
|
-
layouts: { type: "array", items: LAYOUT_ROW_SCHEMA },
|
|
1853
|
-
page: PAGE_SCHEMA,
|
|
1854
|
-
},
|
|
1855
|
-
required: ["presentationId", "layouts", "page"],
|
|
1856
|
-
},
|
|
1857
|
-
handler: async (args, ctx) => {
|
|
1858
|
-
const presentationId = args["presentationId"];
|
|
1859
|
-
const cursor = decodeCursor(args["cursor"], { k: "layouts", p: presentationId, raw: false });
|
|
1860
|
-
const presentation = asRecord(await client.json({ method: "GET", path: presentationPath(presentationId), query: { fields: LAYOUT_LIST_FIELDS } }, ctx));
|
|
1861
|
-
const rows = layoutRows(presentation);
|
|
1862
|
-
const state = await stateOf(presentation["revisionId"], () => layoutFingerprint(rows));
|
|
1863
|
-
assertUnchanged(cursor, state, "deck's layouts");
|
|
1864
|
-
const header = compact({
|
|
1865
|
-
presentationId,
|
|
1866
|
-
revisionId: text(presentation["revisionId"]),
|
|
1867
|
-
notesMasterId: text(asRecord(presentation["notesMaster"])["objectId"]),
|
|
1868
|
-
total: rows.length,
|
|
1869
|
-
});
|
|
1870
|
-
const room = RESULT_BUDGET_BYTES -
|
|
1871
|
-
jsonBytes({ ...header, layouts: [] }) -
|
|
1872
|
-
pageEnvelopeBytes({ k: "layouts", p: presentationId, s: state, i: 0 });
|
|
1873
|
-
const limit = typeof args["limit"] === "number" ? args["limit"] : DEFAULT_LAYOUTS;
|
|
1874
|
-
const shown = rowsWithin(rows, cursor?.i ?? 0, limit, room);
|
|
1875
|
-
const next = shown.next < rows.length ? encodeCursor({ k: "layouts", p: presentationId, s: state, i: shown.next }) : null;
|
|
1876
|
-
return deliverable({ ...header, layouts: shown.rows, page: nextPage(next) }, "list_layouts");
|
|
1877
|
-
},
|
|
1878
|
-
},
|
|
1879
|
-
{
|
|
1880
|
-
name: "list_comments",
|
|
1881
|
-
description: "List a deck's comment threads, or one page's: anchor, status, quoted text, head post, and replies, each author by user id. Long text continues across pages.",
|
|
1882
|
-
annotations: readOnly,
|
|
1883
|
-
inputSchema: input({
|
|
1884
|
-
presentationId: PRESENTATION_ID,
|
|
1885
|
-
pageObjectId: objectIdProperty("Only this slide's or notes page's threads; omit for the whole deck."),
|
|
1886
|
-
limit: limitProperty(MAX_THREADS, DEFAULT_THREADS, "Threads"),
|
|
1887
|
-
cursor: CURSOR_PROPERTY,
|
|
1888
|
-
raw: {
|
|
1889
|
-
type: "boolean",
|
|
1890
|
-
description: "Each thread as Slides sends it, HTML and copy flags kept, with its anchors' ranges; one too large is sent in JSON chunks.",
|
|
1891
|
-
},
|
|
1892
|
-
}, ["presentationId"]),
|
|
1893
|
-
outputSchema: {
|
|
1894
|
-
type: "object",
|
|
1895
|
-
properties: {
|
|
1896
|
-
presentationId: { type: "string" },
|
|
1897
|
-
pageObjectId: { type: "string" },
|
|
1898
|
-
revisionId: { type: "string" },
|
|
1899
|
-
total: { type: "integer" },
|
|
1900
|
-
threads: {
|
|
1901
|
-
type: "array",
|
|
1902
|
-
items: {
|
|
1903
|
-
type: "object",
|
|
1904
|
-
properties: {
|
|
1905
|
-
commentId: { type: "string" },
|
|
1906
|
-
anchorId: { type: "string" },
|
|
1907
|
-
status: { type: "string" },
|
|
1908
|
-
pageObjectIds: { type: "array", items: { type: "string" } },
|
|
1909
|
-
objectIds: { type: "array", items: { type: "string" } },
|
|
1910
|
-
replyCount: { type: "integer" },
|
|
1911
|
-
quote: { type: "string" },
|
|
1912
|
-
quoteOffset: { type: "integer" },
|
|
1913
|
-
quoteLength: { type: "integer" },
|
|
1914
|
-
headPost: POST_SCHEMA,
|
|
1915
|
-
replies: { type: "array", items: POST_SCHEMA },
|
|
1916
|
-
repliesOffset: { type: "integer" },
|
|
1917
|
-
continued: { type: "boolean" },
|
|
1918
|
-
truncated: { type: "boolean" },
|
|
1919
|
-
idsNotShown: { type: "boolean" },
|
|
1920
|
-
// raw: true passes Slides' own threads through, with their
|
|
1921
|
-
// commentAnchors, or a chunk of one too large for a page.
|
|
1922
|
-
rawJson: CHUNK_SCHEMA,
|
|
1923
|
-
},
|
|
1924
|
-
},
|
|
1925
|
-
},
|
|
1926
|
-
page: PAGE_SCHEMA,
|
|
1927
|
-
},
|
|
1928
|
-
required: ["presentationId", "total", "threads", "page"],
|
|
1929
|
-
},
|
|
1930
|
-
handler: async (args, ctx) => {
|
|
1931
|
-
const presentationId = args["presentationId"];
|
|
1932
|
-
const pageObjectId = args["pageObjectId"];
|
|
1933
|
-
const raw = args["raw"] === true;
|
|
1934
|
-
const scope = pageObjectId === undefined ? {} : { g: pageObjectId };
|
|
1935
|
-
const cursor = decodeCursor(args["cursor"], { k: "comments", p: presentationId, ...scope, raw });
|
|
1936
|
-
const source = asRecord(await client
|
|
1937
|
-
.json({
|
|
1938
|
-
method: "GET",
|
|
1939
|
-
path: pageObjectId === undefined
|
|
1940
|
-
? presentationPath(presentationId)
|
|
1941
|
-
: `${presentationPath(presentationId)}/pages/${encodeURIComponent(pageObjectId)}`,
|
|
1942
|
-
query: {
|
|
1943
|
-
commentsViewMode: COMMENTS_INCLUDED,
|
|
1944
|
-
fields: pageObjectId === undefined ? COMMENT_DECK_FIELDS : COMMENT_PAGE_FIELDS,
|
|
1945
|
-
},
|
|
1946
|
-
}, ctx)
|
|
1947
|
-
.catch((error) => {
|
|
1948
|
-
throw commentAccess(error);
|
|
1949
|
-
}));
|
|
1950
|
-
// Asked for INCLUDED, Slides either includes every thread or refuses;
|
|
1951
|
-
// a reply in any other mode could be missing threads, so it is not
|
|
1952
|
-
// passed off as the list.
|
|
1953
|
-
const mode = text(source["commentsViewMode"]);
|
|
1954
|
-
if (mode !== undefined && mode !== COMMENTS_INCLUDED) {
|
|
1955
|
-
throw new ConnectorCallError("connector_call_failed", `Slides answered in comments view mode ${label(mode, MAX_NAME_BYTES, "")}, not ${COMMENTS_INCLUDED}, so its threads may be incomplete. It only read, so nothing was changed.`, { retryable: false });
|
|
1956
|
-
}
|
|
1957
|
-
const comments = asArray(source["comments"]);
|
|
1958
|
-
const pages = pageObjectId === undefined ? commentPages(source) : [source];
|
|
1959
|
-
const state = await commentsState(source["revisionId"], comments, pages);
|
|
1960
|
-
assertUnchanged(cursor, state, pageObjectId === undefined ? "deck's comments" : "page's comments");
|
|
1961
|
-
const header = compact({
|
|
1962
|
-
presentationId,
|
|
1963
|
-
pageObjectId,
|
|
1964
|
-
revisionId: text(source["revisionId"]),
|
|
1965
|
-
total: comments.length,
|
|
1966
|
-
});
|
|
1967
|
-
const room = RESULT_BUDGET_BYTES -
|
|
1968
|
-
jsonBytes({ ...header, threads: [] }) -
|
|
1969
|
-
pageEnvelopeBytes({
|
|
1970
|
-
k: "comments",
|
|
1971
|
-
p: presentationId,
|
|
1972
|
-
...scope,
|
|
1973
|
-
s: state,
|
|
1974
|
-
raw: raw ? 1 : undefined,
|
|
1975
|
-
i: 0,
|
|
1976
|
-
j: 0,
|
|
1977
|
-
}) -
|
|
1978
|
-
64;
|
|
1979
|
-
const limit = typeof args["limit"] === "number" ? args["limit"] : DEFAULT_THREADS;
|
|
1980
|
-
let shown;
|
|
1981
|
-
if (raw) {
|
|
1982
|
-
// Slides' own threads, chunked like get_page's raw elements.
|
|
1983
|
-
const rows = rawRows(comments.map((thread) => rawThread(thread, pages)), { i: cursor?.i ?? 0, o: cursor?.o ?? 0 }, limit, room, COMMENTS_RAW);
|
|
1984
|
-
shown = { threads: rows.elements, next: rows.next };
|
|
1985
|
-
}
|
|
1986
|
-
else {
|
|
1987
|
-
const places = anchorPlaces(pages);
|
|
1988
|
-
const threads = comments.map((thread) => threadParts(thread, places));
|
|
1989
|
-
shown = threadRows(threads, { i: cursor?.i ?? 0, j: cursor?.j ?? 0, o: cursor?.o ?? 0 }, limit, room);
|
|
1990
|
-
}
|
|
1991
|
-
const next = shown.next
|
|
1992
|
-
? encodeCursor({
|
|
1993
|
-
k: "comments",
|
|
1994
|
-
p: presentationId,
|
|
1995
|
-
...scope,
|
|
1996
|
-
s: state,
|
|
1997
|
-
raw: raw ? 1 : undefined,
|
|
1998
|
-
i: shown.next.i,
|
|
1999
|
-
j: shown.next.j ? shown.next.j : undefined,
|
|
2000
|
-
o: shown.next.o ? shown.next.o : undefined,
|
|
2001
|
-
})
|
|
2002
|
-
: null;
|
|
2003
|
-
return deliverable({ ...header, threads: shown.threads, page: nextPage(next) }, "list_comments", "pass a smaller limit, or one page's pageObjectId");
|
|
2004
|
-
},
|
|
2005
|
-
},
|
|
2006
|
-
{
|
|
2007
|
-
name: "get_slide_thumbnail",
|
|
2008
|
-
description: "Get a PNG thumbnail link for one slide: a URL valid about 30 minutes that opens as the user, plus its size. Never downloads the image.",
|
|
2009
|
-
annotations: readOnly,
|
|
2010
|
-
inputSchema: input({
|
|
2011
|
-
presentationId: PRESENTATION_ID,
|
|
2012
|
-
slideObjectId: objectIdProperty("Slide objectId from get_presentation."),
|
|
2013
|
-
size: {
|
|
2014
|
-
type: "string",
|
|
2015
|
-
enum: ["SMALL", "MEDIUM", "LARGE", "WIDTH2000_PX"],
|
|
2016
|
-
description: "Width: SMALL 200px, MEDIUM 800px, LARGE 1600px, WIDTH2000_PX 2000px; Slides chooses when omitted.",
|
|
2017
|
-
},
|
|
2018
|
-
}, ["presentationId", "slideObjectId"]),
|
|
2019
|
-
outputSchema: {
|
|
2020
|
-
type: "object",
|
|
2021
|
-
properties: {
|
|
2022
|
-
contentUrl: { type: "string" },
|
|
2023
|
-
width: { type: "integer" },
|
|
2024
|
-
height: { type: "integer" },
|
|
2025
|
-
},
|
|
2026
|
-
required: ["contentUrl"],
|
|
2027
|
-
},
|
|
2028
|
-
handler: async (args, ctx) => {
|
|
2029
|
-
const thumbnail = asRecord(await client.json({
|
|
2030
|
-
method: "GET",
|
|
2031
|
-
path: `/presentations/${encodeURIComponent(args["presentationId"])}/pages/${encodeURIComponent(args["slideObjectId"])}/thumbnail`,
|
|
2032
|
-
query: {
|
|
2033
|
-
"thumbnailProperties.mimeType": "PNG",
|
|
2034
|
-
"thumbnailProperties.thumbnailSize": args["size"],
|
|
2035
|
-
},
|
|
2036
|
-
}, ctx));
|
|
2037
|
-
const contentUrl = text(thumbnail["contentUrl"]) ?? "";
|
|
2038
|
-
// A link is whole or useless, so one too long to pass on is refused,
|
|
2039
|
-
// never cut.
|
|
2040
|
-
if (jsonBytes(contentUrl) > MAX_URL_BYTES) {
|
|
2041
|
-
throw new ConnectorCallError("connector_call_failed", `Google returned a thumbnail link of ${jsonBytes(contentUrl)} bytes, longer than this connection passes on (${MAX_URL_BYTES}). It only read, so nothing was changed; open the slide in Slides instead.`, { retryable: false });
|
|
2042
|
-
}
|
|
2043
|
-
return deliverable(compact({
|
|
2044
|
-
contentUrl,
|
|
2045
|
-
width: typeof thumbnail["width"] === "number" ? thumbnail["width"] : undefined,
|
|
2046
|
-
height: typeof thumbnail["height"] === "number" ? thumbnail["height"] : undefined,
|
|
2047
|
-
}), "get_slide_thumbnail", "open the slide in Slides instead");
|
|
2048
|
-
},
|
|
2049
|
-
},
|
|
2050
|
-
{
|
|
2051
|
-
name: "create_presentation",
|
|
2052
|
-
description: "Create an empty Google Slides deck with one title slide in the user's My Drive root. It cannot place the deck in a folder or copy a template.",
|
|
2053
|
-
// Additive: a new file changes nothing that existed. Not read-only, so
|
|
2054
|
-
// program calls require a writable endpoint; other writes use
|
|
2055
|
-
// call_destructive_tool. The host controls approval.
|
|
2056
|
-
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
2057
|
-
inputSchema: input({
|
|
2058
|
-
title: {
|
|
2059
|
-
type: "string",
|
|
2060
|
-
minLength: 1,
|
|
2061
|
-
maxLength: 1000,
|
|
2062
|
-
pattern: "^[^\\r\\n]*$",
|
|
2063
|
-
description: "The deck's title, as Drive shows it.",
|
|
2064
|
-
},
|
|
2065
|
-
}, ["title"]),
|
|
2066
|
-
outputSchema: {
|
|
2067
|
-
type: "object",
|
|
2068
|
-
properties: {
|
|
2069
|
-
...WRITE_RESULT_PROPERTIES,
|
|
2070
|
-
title: { type: "string" },
|
|
2071
|
-
url: { type: "string" },
|
|
2072
|
-
slideObjectIds: { type: "array", items: { type: "string" } },
|
|
2073
|
-
slideObjectIdsNotShown: { type: "integer" },
|
|
2074
|
-
},
|
|
2075
|
-
required: ["presentationId"],
|
|
2076
|
-
},
|
|
2077
|
-
handler: async (args, ctx) => {
|
|
2078
|
-
const presentation = asRecord(await client
|
|
2079
|
-
.json({ method: "POST", path: "/presentations", body: { title: args["title"] } }, ctx)
|
|
2080
|
-
.catch((error) => {
|
|
2081
|
-
throw afterUnknownCreate(error, "Search Drive for a deck with this title before creating it again; a second create makes a second deck.");
|
|
2082
|
-
}));
|
|
2083
|
-
const lookInDrive = "search Drive for the deck by its title";
|
|
2084
|
-
const id = wholeId(presentation["presentationId"]);
|
|
2085
|
-
if (id === undefined) {
|
|
2086
|
-
throw appliedButUnreadable("create_presentation", "Google returned no usable id for the new deck", lookInDrive);
|
|
2087
|
-
}
|
|
2088
|
-
const revisionId = presentation["revisionId"];
|
|
2089
|
-
const slideIds = asArray(presentation["slides"]).map((slide) => asRecord(slide)["objectId"]);
|
|
2090
|
-
const shownIds = slideIds.map(wholeId).filter((value) => value !== undefined);
|
|
2091
|
-
return appliedDeliverable(compact({
|
|
2092
|
-
presentationId: id,
|
|
2093
|
-
revisionId: wholeId(revisionId),
|
|
2094
|
-
revisionIdNotShown: revisionId !== undefined && wholeId(revisionId) === undefined ? true : undefined,
|
|
2095
|
-
title: label(text(presentation["title"]), MAX_TITLE_BYTES, "Drive shows the whole title"),
|
|
2096
|
-
url: editUrl(id),
|
|
2097
|
-
slideObjectIds: shownIds,
|
|
2098
|
-
slideObjectIdsNotShown: shownIds.length < slideIds.length ? slideIds.length - shownIds.length : undefined,
|
|
2099
|
-
}), "create_presentation", lookInDrive);
|
|
2100
|
-
},
|
|
2101
|
-
},
|
|
2102
|
-
{
|
|
2103
|
-
name: "create_slide",
|
|
2104
|
-
description: "Add one empty slide to a deck, from a predefined layout or one of its own layouts, at an index or the end. Adds no text; nothing existing changes.",
|
|
2105
|
-
// Additive: later slides move down one place, and nothing is lost.
|
|
2106
|
-
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
2107
|
-
inputSchema: input({
|
|
2108
|
-
presentationId: PRESENTATION_ID,
|
|
2109
|
-
layout: {
|
|
2110
|
-
type: "string",
|
|
2111
|
-
enum: [
|
|
2112
|
-
"BLANK",
|
|
2113
|
-
"CAPTION_ONLY",
|
|
2114
|
-
"TITLE",
|
|
2115
|
-
"TITLE_AND_BODY",
|
|
2116
|
-
"TITLE_AND_TWO_COLUMNS",
|
|
2117
|
-
"TITLE_ONLY",
|
|
2118
|
-
"SECTION_HEADER",
|
|
2119
|
-
"SECTION_TITLE_AND_DESCRIPTION",
|
|
2120
|
-
"ONE_COLUMN_TEXT",
|
|
2121
|
-
"MAIN_POINT",
|
|
2122
|
-
"BIG_NUMBER",
|
|
2123
|
-
],
|
|
2124
|
-
description: "Predefined layout; the deck's theme must have it. Omit both layout and layoutId for BLANK.",
|
|
2125
|
-
},
|
|
2126
|
-
layoutId: objectIdProperty("A layouts[].objectId from get_presentation, instead of layout."),
|
|
2127
|
-
insertionIndex: {
|
|
2128
|
-
type: "integer",
|
|
2129
|
-
minimum: 0,
|
|
2130
|
-
maximum: 100_000,
|
|
2131
|
-
description: "0-based position, as get_presentation numbers slides; omit to append.",
|
|
2132
|
-
},
|
|
2133
|
-
}, ["presentationId"]),
|
|
2134
|
-
outputSchema: {
|
|
2135
|
-
type: "object",
|
|
2136
|
-
properties: { ...WRITE_RESULT_PROPERTIES, slideObjectId: { type: "string" } },
|
|
2137
|
-
required: ["presentationId", "slideObjectId"],
|
|
2138
|
-
},
|
|
2139
|
-
handler: async (args, ctx) => {
|
|
2140
|
-
if (args["layout"] !== undefined && args["layoutId"] !== undefined) {
|
|
2141
|
-
throw new ConnectorCallError("invalid_args", "Pass layout or layoutId, not both.");
|
|
2142
|
-
}
|
|
2143
|
-
const reference = args["layoutId"] !== undefined
|
|
2144
|
-
? { layoutId: args["layoutId"] }
|
|
2145
|
-
: args["layout"] !== undefined
|
|
2146
|
-
? { predefinedLayout: args["layout"] }
|
|
2147
|
-
: undefined;
|
|
2148
|
-
const response = await batchUpdate(client, ctx, args["presentationId"], [
|
|
2149
|
-
{ createSlide: compact({ insertionIndex: args["insertionIndex"], slideLayoutReference: reference }) },
|
|
2150
|
-
]).catch((error) => {
|
|
2151
|
-
throw afterUnknownCreate(error, "Read the deck with get_presentation and look for the new slide before adding another.");
|
|
2152
|
-
});
|
|
2153
|
-
const reread = "read the deck with get_presentation to find the new slide";
|
|
2154
|
-
const slideObjectId = wholeId(asRecord(asRecord(asArray(response["replies"])[0])["createSlide"])["objectId"]);
|
|
2155
|
-
if (slideObjectId === undefined) {
|
|
2156
|
-
throw appliedButUnreadable("create_slide", "Google returned no usable id for the new slide", reread);
|
|
2157
|
-
}
|
|
2158
|
-
return appliedDeliverable(compact({ presentationId: args["presentationId"], ...revisionFields(response), slideObjectId }), "create_slide", reread);
|
|
2159
|
-
},
|
|
2160
|
-
},
|
|
2161
|
-
{
|
|
2162
|
-
name: "replace_all_text",
|
|
2163
|
-
description: "Replace every occurrence of literal text across a deck's slides, or the slides named, in one atomic write. No regex; formatting of the replaced text is kept.",
|
|
2164
|
-
// Destructive: the replaced text is gone, everywhere it matched.
|
|
2165
|
-
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
2166
|
-
inputSchema: input({
|
|
2167
|
-
presentationId: PRESENTATION_ID,
|
|
2168
|
-
replacements: {
|
|
2169
|
-
type: "array",
|
|
2170
|
-
minItems: 1,
|
|
2171
|
-
maxItems: MAX_REPLACEMENTS,
|
|
2172
|
-
description: `Applied in order, 1 to ${MAX_REPLACEMENTS} (connecta's bound).`,
|
|
2173
|
-
items: {
|
|
2174
|
-
type: "object",
|
|
2175
|
-
additionalProperties: false,
|
|
2176
|
-
required: ["find", "replace"],
|
|
2177
|
-
description: "One find-and-replace.",
|
|
2178
|
-
properties: {
|
|
2179
|
-
find: { type: "string", minLength: 1, maxLength: 1000, description: "Literal text to find." },
|
|
2180
|
-
replace: { type: "string", maxLength: 10_000, description: "Replacement; empty deletes the match." },
|
|
2181
|
-
matchCase: { type: "boolean", description: "Case-sensitive; defaults to true." },
|
|
2182
|
-
},
|
|
2183
|
-
},
|
|
2184
|
-
},
|
|
2185
|
-
slideObjectIds: {
|
|
2186
|
-
type: "array",
|
|
2187
|
-
minItems: 1,
|
|
2188
|
-
maxItems: 500,
|
|
2189
|
-
items: objectIdProperty("A slide objectId."),
|
|
2190
|
-
description: "Limit to these slides; omit for every slide.",
|
|
2191
|
-
},
|
|
2192
|
-
requiredRevisionId: REVISION_PROPERTY,
|
|
2193
|
-
}, ["presentationId", "replacements"]),
|
|
2194
|
-
outputSchema: {
|
|
2195
|
-
type: "object",
|
|
2196
|
-
properties: {
|
|
2197
|
-
...WRITE_RESULT_PROPERTIES,
|
|
2198
|
-
occurrencesChanged: { type: "integer" },
|
|
2199
|
-
replacements: {
|
|
2200
|
-
type: "array",
|
|
2201
|
-
items: {
|
|
2202
|
-
type: "object",
|
|
2203
|
-
properties: { find: { type: "string" }, occurrencesChanged: { type: "integer" } },
|
|
2204
|
-
},
|
|
2205
|
-
},
|
|
2206
|
-
},
|
|
2207
|
-
required: ["presentationId", "occurrencesChanged", "replacements"],
|
|
2208
|
-
},
|
|
2209
|
-
handler: async (args, ctx) => {
|
|
2210
|
-
const replacements = asArray(args["replacements"]).map(asRecord);
|
|
2211
|
-
const response = await batchUpdate(client, ctx, args["presentationId"], replacements.map((replacement) => ({
|
|
2212
|
-
replaceAllText: compact({
|
|
2213
|
-
containsText: { text: replacement["find"], matchCase: replacement["matchCase"] !== false },
|
|
2214
|
-
replaceText: replacement["replace"],
|
|
2215
|
-
pageObjectIds: args["slideObjectIds"],
|
|
2216
|
-
}),
|
|
2217
|
-
})), args["requiredRevisionId"]);
|
|
2218
|
-
const replies = asArray(response["replies"]);
|
|
2219
|
-
const counted = replacements.map((replacement, index) => {
|
|
2220
|
-
// Proto3 omits a zero count.
|
|
2221
|
-
const changed = asRecord(asRecord(replies[index])["replaceAllText"])["occurrencesChanged"];
|
|
2222
|
-
// Echoed for reading the counts, not in full: fifty long finds
|
|
2223
|
-
// would make a large result of text the caller already has.
|
|
2224
|
-
const find = String(replacement["find"]);
|
|
2225
|
-
return {
|
|
2226
|
-
find: find.length > MAX_ECHO_CHARS ? `${headOf(find, MAX_ECHO_CHARS)}…` : find,
|
|
2227
|
-
occurrencesChanged: typeof changed === "number" ? changed : 0,
|
|
2228
|
-
};
|
|
2229
|
-
});
|
|
2230
|
-
return appliedDeliverable(compact({
|
|
2231
|
-
presentationId: args["presentationId"],
|
|
2232
|
-
...revisionFields(response),
|
|
2233
|
-
occurrencesChanged: counted.reduce((sum, entry) => sum + entry.occurrencesChanged, 0),
|
|
2234
|
-
replacements: counted,
|
|
2235
|
-
}), "replace_all_text", "read the deck with get_presentation to see the replaced text");
|
|
2236
|
-
},
|
|
2237
|
-
},
|
|
2238
|
-
{
|
|
2239
|
-
name: "create_comment",
|
|
2240
|
-
description: "Comment on a slide, a notes page, an element, a range of its text, or a table cell, optionally assigning it. Adds a thread; Slides notifies as in the editor.",
|
|
2241
|
-
// Additive: a new thread changes nothing that existed. Not read-only,
|
|
2242
|
-
// so program calls require a writable endpoint; other writes use
|
|
2243
|
-
// call_destructive_tool. The host controls approval.
|
|
2244
|
-
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
2245
|
-
inputSchema: input({
|
|
2246
|
-
presentationId: PRESENTATION_ID,
|
|
2247
|
-
objectId: objectIdProperty("The page or element to comment on: the shape or table, for text or a cell."),
|
|
2248
|
-
content: commentContentProperty(`Plain text; Slides' cap is ${MAX_COMMENT_BYTES} UTF-8 bytes.`),
|
|
2249
|
-
textRange: {
|
|
2250
|
-
type: "object",
|
|
2251
|
-
additionalProperties: false,
|
|
2252
|
-
required: ["startIndex", "endIndex"],
|
|
2253
|
-
description: "Anchor to this text of the shape, or of the cell: 0-based, end exclusive.",
|
|
2254
|
-
properties: {
|
|
2255
|
-
startIndex: { type: "integer", minimum: 0, maximum: 10_000_000, description: "First character." },
|
|
2256
|
-
endIndex: { type: "integer", minimum: 1, maximum: 10_000_000, description: "One past the last." },
|
|
2257
|
-
},
|
|
2258
|
-
},
|
|
2259
|
-
cell: {
|
|
2260
|
-
type: "object",
|
|
2261
|
-
additionalProperties: false,
|
|
2262
|
-
required: ["rowIndex", "columnIndex"],
|
|
2263
|
-
description: "A table cell, 0-based: with textRange, that text in it; else the whole cell.",
|
|
2264
|
-
properties: {
|
|
2265
|
-
rowIndex: { type: "integer", minimum: 0, maximum: 100_000, description: "Row." },
|
|
2266
|
-
columnIndex: { type: "integer", minimum: 0, maximum: 100_000, description: "Column." },
|
|
2267
|
-
},
|
|
2268
|
-
},
|
|
2269
|
-
assigneeEmail: ASSIGNEE_PROPERTY,
|
|
2270
|
-
requiredRevisionId: {
|
|
2271
|
-
...REVISION_PROPERTY,
|
|
2272
|
-
description: "Refuse, unapplied, if the deck changed since this revision.",
|
|
2273
|
-
},
|
|
2274
|
-
}, ["presentationId", "objectId", "content"]),
|
|
2275
|
-
outputSchema: {
|
|
2276
|
-
type: "object",
|
|
2277
|
-
properties: {
|
|
2278
|
-
...COMMENT_WRITE_PROPERTIES,
|
|
2279
|
-
commentId: { type: "string" },
|
|
2280
|
-
anchorId: { type: "string" },
|
|
2281
|
-
status: { type: "string" },
|
|
2282
|
-
quote: { type: "string" },
|
|
2283
|
-
headPost: POST_SCHEMA,
|
|
2284
|
-
replies: { type: "array", items: POST_SCHEMA },
|
|
2285
|
-
idsNotShown: { type: "boolean" },
|
|
2286
|
-
},
|
|
2287
|
-
required: ["presentationId"],
|
|
2288
|
-
},
|
|
2289
|
-
handler: async (args, ctx) => {
|
|
2290
|
-
const content = commentText(args["content"], "content");
|
|
2291
|
-
const objectId = args["objectId"];
|
|
2292
|
-
const range = args["textRange"] === undefined ? undefined : asRecord(args["textRange"]);
|
|
2293
|
-
if (range && Number(range["endIndex"]) <= Number(range["startIndex"])) {
|
|
2294
|
-
throw new ConnectorCallError("invalid_args", "textRange.endIndex must be greater than startIndex. Nothing was sent.");
|
|
2295
|
-
}
|
|
2296
|
-
const textRange = range && {
|
|
2297
|
-
type: "FIXED_RANGE",
|
|
2298
|
-
startIndex: range["startIndex"],
|
|
2299
|
-
endIndex: range["endIndex"],
|
|
2300
|
-
};
|
|
2301
|
-
const cell = args["cell"] === undefined ? undefined : asRecord(args["cell"]);
|
|
2302
|
-
const location = cell && { rowIndex: cell["rowIndex"], columnIndex: cell["columnIndex"] };
|
|
2303
|
-
// One anchor: the object itself, text in a shape, text in a cell, or a whole cell.
|
|
2304
|
-
const anchor = location && textRange
|
|
2305
|
-
? { tableCellTextAnchor: { objectId, cellLocation: location, textRange } }
|
|
2306
|
-
: location
|
|
2307
|
-
? { tableAnchor: { objectId, tableRange: { location, rowSpan: 1, columnSpan: 1 } } }
|
|
2308
|
-
: textRange
|
|
2309
|
-
? { shapeTextAnchor: { objectId, textRange } }
|
|
2310
|
-
: { objectId };
|
|
2311
|
-
const written = await commentWrite(client, ctx, args["presentationId"], { insertComment: compact({ ...anchor, content, assigneeEmailAddress: args["assigneeEmail"] }) }, args["requiredRevisionId"]).catch((error) => {
|
|
2312
|
-
throw afterUnknownCreate(error, "List the deck's comments with list_comments and look for it before commenting again; a second insert makes a second thread.");
|
|
2313
|
-
});
|
|
2314
|
-
const reread = "list the deck's comments with list_comments to find it";
|
|
2315
|
-
const thread = asRecord(asRecord(written.reply["insertComment"])["commentThread"]);
|
|
2316
|
-
const commentId = wholeId(thread["commentId"]);
|
|
2317
|
-
if (commentId === undefined && written.saved) {
|
|
2318
|
-
throw appliedButUnreadable("create_comment", "Google returned no usable id for the new comment", reread);
|
|
2319
|
-
}
|
|
2320
|
-
const parts = threadParts(thread, new Map());
|
|
2321
|
-
const replies = asArray(thread["replies"]);
|
|
2322
|
-
return appliedDeliverable(compact({
|
|
2323
|
-
...written.header,
|
|
2324
|
-
commentId,
|
|
2325
|
-
anchorId: wholeId(thread["anchorId"]),
|
|
2326
|
-
status: parts.row["status"],
|
|
2327
|
-
quote: label(text(parts.quote), MAX_POST_BYTES, "list_comments has the whole quote"),
|
|
2328
|
-
headPost: thread["headPost"] ? writtenPost(thread["headPost"]) : undefined,
|
|
2329
|
-
// A new thread has none; any Slides sends keep their ids.
|
|
2330
|
-
replies: replies.length > 0 ? replies.map((reply) => writtenPost(reply, MAX_NAME_BYTES)) : undefined,
|
|
2331
|
-
idsNotShown: parts.row["idsNotShown"],
|
|
2332
|
-
note: written.saved ? undefined : COMMENTS_UNSAVED,
|
|
2333
|
-
}), "create_comment", reread);
|
|
2334
|
-
},
|
|
2335
|
-
},
|
|
2336
|
-
{
|
|
2337
|
-
name: "create_comment_reply",
|
|
2338
|
-
description: "Reply to a comment thread. Adds a post and changes no existing one; to resolve, reopen, or reassign, use update_comment_thread.",
|
|
2339
|
-
// Additive: a reply is a new post, and the thread's status and
|
|
2340
|
-
// assignee stay as they were.
|
|
2341
|
-
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
2342
|
-
inputSchema: input({
|
|
2343
|
-
presentationId: PRESENTATION_ID,
|
|
2344
|
-
commentId: COMMENT_ID,
|
|
2345
|
-
content: commentContentProperty(`Plain text, up to ${MAX_COMMENT_BYTES} UTF-8 bytes.`),
|
|
2346
|
-
}, ["presentationId", "commentId", "content"]),
|
|
2347
|
-
outputSchema: REPLY_RESULT_SCHEMA,
|
|
2348
|
-
handler: async (args, ctx) => postToThread(client, ctx, "create_comment_reply", args, { content: commentText(args["content"], "content") }),
|
|
2349
|
-
},
|
|
2350
|
-
{
|
|
2351
|
-
name: "update_comment_thread",
|
|
2352
|
-
description: "Resolve, reopen, or reassign a comment thread with a post. Pass exactly one of status or assigneeEmail; reassignment requires content. Replaces its status or assignee; Slides notifies an assignee.",
|
|
2353
|
-
// Destructive: the thread's status or assignee is replaced. The post
|
|
2354
|
-
// that records it cannot be deleted afterwards.
|
|
2355
|
-
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
2356
|
-
// Keep the field schema flat: discovery renders oneOf before properties,
|
|
2357
|
-
// losing these fields for required-only branches. The handler enforces
|
|
2358
|
-
// exactly one change and text on reassignment before dispatch instead.
|
|
2359
|
-
inputSchema: input({
|
|
2360
|
-
presentationId: PRESENTATION_ID,
|
|
2361
|
-
commentId: COMMENT_ID,
|
|
2362
|
-
status: { type: "string", enum: ["RESOLVED", "OPEN"], description: "Resolve the thread, or reopen it." },
|
|
2363
|
-
assigneeEmail: { ...ASSIGNEE_PROPERTY, description: "Reassign an assigned thread instead; needs content." },
|
|
2364
|
-
content: commentContentProperty(`Note posted with it, up to ${MAX_COMMENT_BYTES} UTF-8 bytes; required to reassign.`),
|
|
2365
|
-
}, ["presentationId", "commentId"]),
|
|
2366
|
-
outputSchema: REPLY_RESULT_SCHEMA,
|
|
2367
|
-
handler: async (args, ctx) => {
|
|
2368
|
-
// Exactly one change per post. Slides requires text on reassignment;
|
|
2369
|
-
// only RESOLVE and REOPEN may go without.
|
|
2370
|
-
if ((args["status"] === undefined) === (args["assigneeEmail"] === undefined)) {
|
|
2371
|
-
throw new ConnectorCallError("invalid_args", "Pass exactly one of status and assigneeEmail. Nothing was sent.");
|
|
2372
|
-
}
|
|
2373
|
-
if (args["assigneeEmail"] !== undefined && args["content"] === undefined) {
|
|
2374
|
-
throw new ConnectorCallError("invalid_args", "A reassignment needs content: Slides requires text on any post that does not resolve or reopen. Nothing was sent.");
|
|
2375
|
-
}
|
|
2376
|
-
return postToThread(client, ctx, "update_comment_thread", args, {
|
|
2377
|
-
content: commentText(args["content"], "content"),
|
|
2378
|
-
commentAction: args["status"] === undefined ? undefined : args["status"] === "RESOLVED" ? "RESOLVE" : "REOPEN",
|
|
2379
|
-
assigneeEmail: args["assigneeEmail"],
|
|
2380
|
-
});
|
|
2381
|
-
},
|
|
2382
|
-
},
|
|
2383
|
-
{
|
|
2384
|
-
name: "update_comment_post",
|
|
2385
|
-
description: "Replace the text of one post in a comment thread, the head post or a reply. Only its author can; the old text is gone.",
|
|
2386
|
-
// Destructive: the post's previous text is overwritten.
|
|
2387
|
-
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
2388
|
-
inputSchema: input({
|
|
2389
|
-
presentationId: PRESENTATION_ID,
|
|
2390
|
-
commentId: COMMENT_ID,
|
|
2391
|
-
postId: POST_ID,
|
|
2392
|
-
content: commentContentProperty(`The new plain text, up to ${MAX_COMMENT_BYTES} UTF-8 bytes.`),
|
|
2393
|
-
}, ["presentationId", "commentId", "postId", "content"]),
|
|
2394
|
-
outputSchema: {
|
|
2395
|
-
type: "object",
|
|
2396
|
-
properties: { ...COMMENT_WRITE_PROPERTIES, commentId: { type: "string" }, postId: { type: "string" } },
|
|
2397
|
-
required: ["presentationId", "commentId", "postId"],
|
|
2398
|
-
},
|
|
2399
|
-
handler: async (args, ctx) => {
|
|
2400
|
-
const content = commentText(args["content"], "content");
|
|
2401
|
-
const written = await commentWrite(client, ctx, args["presentationId"], {
|
|
2402
|
-
updateCommentPost: { commentId: args["commentId"], postId: args["postId"], content },
|
|
2403
|
-
});
|
|
2404
|
-
return appliedDeliverable(compact({
|
|
2405
|
-
...written.header,
|
|
2406
|
-
commentId: args["commentId"],
|
|
2407
|
-
postId: args["postId"],
|
|
2408
|
-
note: written.saved ? undefined : COMMENTS_UNSAVED,
|
|
2409
|
-
}), "update_comment_post", "read the thread with list_comments to see the post");
|
|
2410
|
-
},
|
|
2411
|
-
},
|
|
2412
|
-
{
|
|
2413
|
-
name: "delete_comment",
|
|
2414
|
-
description: "Delete a whole comment thread, every reply with it. Only the author of its head post can, and the API cannot restore it.",
|
|
2415
|
-
// Destructive: the thread and its replies are gone.
|
|
2416
|
-
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
2417
|
-
inputSchema: input({ presentationId: PRESENTATION_ID, commentId: COMMENT_ID }, ["presentationId", "commentId"]),
|
|
2418
|
-
outputSchema: {
|
|
2419
|
-
type: "object",
|
|
2420
|
-
properties: { ...COMMENT_WRITE_PROPERTIES, commentId: { type: "string" } },
|
|
2421
|
-
required: ["presentationId", "commentId"],
|
|
2422
|
-
},
|
|
2423
|
-
handler: async (args, ctx) => {
|
|
2424
|
-
const written = await commentWrite(client, ctx, args["presentationId"], {
|
|
2425
|
-
deleteComment: { commentId: args["commentId"] },
|
|
2426
|
-
});
|
|
2427
|
-
return appliedDeliverable(compact({
|
|
2428
|
-
...written.header,
|
|
2429
|
-
commentId: args["commentId"],
|
|
2430
|
-
note: written.saved ? undefined : COMMENTS_UNSAVED,
|
|
2431
|
-
}), "delete_comment", "list the deck's comments with list_comments to see whether it is gone");
|
|
2432
|
-
},
|
|
2433
|
-
},
|
|
2434
|
-
{
|
|
2435
|
-
name: "delete_comment_reply",
|
|
2436
|
-
description: "Delete one reply from a comment thread. Only its author can, and not a reply that resolved, reopened, or assigned the thread.",
|
|
2437
|
-
// Destructive: the reply is gone.
|
|
2438
|
-
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
2439
|
-
inputSchema: input({
|
|
2440
|
-
presentationId: PRESENTATION_ID,
|
|
2441
|
-
commentId: COMMENT_ID,
|
|
2442
|
-
postId: { ...POST_ID, description: "postId of the reply, from list_comments." },
|
|
2443
|
-
}, ["presentationId", "commentId", "postId"]),
|
|
2444
|
-
outputSchema: {
|
|
2445
|
-
type: "object",
|
|
2446
|
-
properties: { ...COMMENT_WRITE_PROPERTIES, commentId: { type: "string" }, postId: { type: "string" } },
|
|
2447
|
-
required: ["presentationId", "commentId", "postId"],
|
|
2448
|
-
},
|
|
2449
|
-
handler: async (args, ctx) => {
|
|
2450
|
-
const written = await commentWrite(client, ctx, args["presentationId"], {
|
|
2451
|
-
deleteCommentReply: { commentId: args["commentId"], postId: args["postId"] },
|
|
2452
|
-
});
|
|
2453
|
-
return appliedDeliverable(compact({
|
|
2454
|
-
...written.header,
|
|
2455
|
-
commentId: args["commentId"],
|
|
2456
|
-
postId: args["postId"],
|
|
2457
|
-
note: written.saved ? undefined : COMMENTS_UNSAVED,
|
|
2458
|
-
}), "delete_comment_reply", "read the thread with list_comments to see whether it is gone");
|
|
2459
|
-
},
|
|
2460
|
-
},
|
|
2461
|
-
{
|
|
2462
|
-
name: "batch_update_presentation",
|
|
2463
|
-
description: "Send raw Slides batchUpdate requests to a deck at the revision it was read at; all apply or none, comments apart. Always destructive. Returns every new id; past 256 KiB, only a direct call can page them.",
|
|
2464
|
-
// Destructive: a raw request can delete or overwrite anything in the deck.
|
|
2465
|
-
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
2466
|
-
inputSchema: input({
|
|
2467
|
-
presentationId: PRESENTATION_ID,
|
|
2468
|
-
requiredRevisionId: REVISION_PROPERTY,
|
|
2469
|
-
requests: {
|
|
2470
|
-
type: "array",
|
|
2471
|
-
minItems: 1,
|
|
2472
|
-
maxItems: MAX_BATCH_REQUESTS,
|
|
2473
|
-
description: `Slides Request objects, applied in order, all or none but comments; 1 to ${MAX_BATCH_REQUESTS} (connecta's bound).`,
|
|
2474
|
-
items: {
|
|
2475
|
-
type: "object",
|
|
2476
|
-
minProperties: 1,
|
|
2477
|
-
maxProperties: 1,
|
|
2478
|
-
description: 'One Request with one key, as the Slides reference defines it: {"insertText": {...}}.',
|
|
2479
|
-
},
|
|
2480
|
-
},
|
|
2481
|
-
}, ["presentationId", "requiredRevisionId", "requests"]),
|
|
2482
|
-
outputSchema: {
|
|
2483
|
-
type: "object",
|
|
2484
|
-
properties: {
|
|
2485
|
-
...WRITE_RESULT_PROPERTIES,
|
|
2486
|
-
// Slides' own reply kinds pass through beside `cut`, the one key
|
|
2487
|
-
// connecta adds: the paths a summarized reply left out.
|
|
2488
|
-
replies: {
|
|
2489
|
-
type: "array",
|
|
2490
|
-
items: { type: "object", properties: { cut: { type: "array", items: { type: "string" } } } },
|
|
2491
|
-
},
|
|
2492
|
-
repliesNotShown: { type: "integer" },
|
|
2493
|
-
commentUpdateState: { type: "string" },
|
|
2494
|
-
note: { type: "string" },
|
|
2495
|
-
},
|
|
2496
|
-
required: ["presentationId", "replies"],
|
|
2497
|
-
},
|
|
2498
|
-
handler: async (args, ctx) => {
|
|
2499
|
-
const requests = asArray(args["requests"]);
|
|
2500
|
-
const kinds = requests.map((request, index) => [index, Object.keys(asRecord(request))[0] ?? ""]);
|
|
2501
|
-
const unknown = kinds.filter(([, kind]) => !REQUEST_KINDS.has(kind));
|
|
2502
|
-
if (unknown.length > 0) {
|
|
2503
|
-
throw new ConnectorCallError("invalid_args", `Not a Slides request kind: ${unknown
|
|
2504
|
-
.slice(0, 5)
|
|
2505
|
-
.map(([index, kind]) => `requests[${index}] "${kind.slice(0, 64)}"`)
|
|
2506
|
-
.join(", ")}. Each request is one of Slides' Request kinds, such as insertText, deleteObject, or createShape; nothing was sent.`);
|
|
2507
|
-
}
|
|
2508
|
-
const response = await batchUpdate(client, ctx, args["presentationId"], requests, args["requiredRevisionId"]);
|
|
2509
|
-
// Comment changes save apart from the rest of a batch, and Slides
|
|
2510
|
-
// reports them on their own: a batch that sent any is settled only
|
|
2511
|
-
// when Slides says ALL_SAVED, and one that is not stops being called
|
|
2512
|
-
// all or none.
|
|
2513
|
-
const saved = commentSaveFields(response, kinds.some(([, kind]) => COMMENT_KINDS.has(kind)));
|
|
2514
|
-
const header = compact({ presentationId: args["presentationId"], ...revisionFields(response), ...saved });
|
|
2515
|
-
// One reply per request, in order; most are empty, the create replies
|
|
2516
|
-
// carry the new ids. Bounded, ids first: Slides accepted the batch, so
|
|
2517
|
-
// what it created must reach the caller.
|
|
2518
|
-
const comments = kinds.some(([, kind]) => COMMENT_KINDS.has(kind));
|
|
2519
|
-
const reread = `re-read with get_presentation or get_page for deck changes${comments ? ", and list_comments for comments" : ""}`;
|
|
2520
|
-
const all = asArray(response["replies"]);
|
|
2521
|
-
const shown = boundedReplies(all, jsonBytes(header) + 1024);
|
|
2522
|
-
// Neutral about what applied: the comment state, when unconfirmed,
|
|
2523
|
-
// says that apart; these say only what the result left out.
|
|
2524
|
-
const unsaved = saved.commentUpdateState !== undefined ? COMMENTS_UNSAVED : undefined;
|
|
2525
|
-
const notes = (...entries) => {
|
|
2526
|
-
const said = entries.filter((entry) => entry !== undefined);
|
|
2527
|
-
return said.length > 0 ? `${said.join(" ")} To see the deck as it is now, ${reread}.` : undefined;
|
|
2528
|
-
};
|
|
2529
|
-
const result = compact({
|
|
2530
|
-
...header,
|
|
2531
|
-
replies: shown.replies,
|
|
2532
|
-
note: notes(unsaved, shown.replies.some((reply) => "cut" in reply)
|
|
2533
|
-
? `Large reply fields are named in cut; every id is kept${shown.oversized ? ", and no other text, since the ids alone pass this tool's 192 KiB budget" : ""}.`
|
|
2534
|
-
: undefined),
|
|
2535
|
-
});
|
|
2536
|
-
const bytes = jsonBytes(result);
|
|
2537
|
-
if (bytes <= BRIDGE_BYTES)
|
|
2538
|
-
return result;
|
|
2539
|
-
if (bytes <= MAX_IDS_RESULT_BYTES) {
|
|
2540
|
-
return {
|
|
2541
|
-
...result,
|
|
2542
|
-
note: `${result.note ?? ""} At ${bytes} bytes this result is more than execute_code can carry (${BRIDGE_BYTES}): call the tool directly and page it with connecta.result.`.trim(),
|
|
2543
|
-
};
|
|
2544
|
-
}
|
|
2545
|
-
// Past any result: the save state and how to recover the ids stay;
|
|
2546
|
-
// the replies do not. Slides accepted the batch, so it is not to be sent again.
|
|
2547
|
-
return compact({
|
|
2548
|
-
...header,
|
|
2549
|
-
replies: [],
|
|
2550
|
-
repliesNotShown: all.length,
|
|
2551
|
-
note: notes(unsaved, `Slides accepted the batch, but its ${all.length} replies carry ${bytes} bytes of ids, more than any result can carry, so none is shown. Do not send it again; ${comments ? "list_comments has the comments and posts it created, and " : ""}get_presentation or get_page has the objects.`),
|
|
2552
|
-
});
|
|
2553
|
-
},
|
|
2554
|
-
},
|
|
2555
|
-
];
|
|
2556
|
-
}
|
|
2557
|
-
// --- Guide ------------------------------------------------------------------------
|
|
2558
|
-
function usageGuide(purpose, instructions) {
|
|
2559
|
-
const extra = instructions?.trim();
|
|
2560
|
-
return `${skill.fragments.guide_0}${purpose}${skill.fragments.guide_1}${extra ? `\n## ${skill.instructionsHeading}\n\n${extra}\n` : ""}`;
|
|
2561
|
-
}
|
|
2562
|
-
// --- Construction -----------------------------------------------------------------
|
|
46
|
+
import { bearer } from "../../http/oauth.js";
|
|
47
|
+
import { defineApiProvider } from "../../http/provider.js";
|
|
48
|
+
import { GOOGLE_GRANT_RENEWAL, googleGrant, tagWorkspace, workspaceOf } from "../_shared/google/kit.js";
|
|
49
|
+
import { GOOGLE_WORKSPACE_OPTIONS } from "../_shared/google/workspace.js";
|
|
50
|
+
import { errors, SLIDES, SlidesApi, probe, safety, result, SLIDES_INDEX, SLIDES_VERDICTS, SLIDES_NAMED, slidesBinding, } from "./api.js";
|
|
51
|
+
import { SLIDES_API_BASE_URL, SLIDES_MAX_RESPONSE_BYTES } from "./lib/constants.js";
|
|
52
|
+
import { slidesTools } from "./tools.js";
|
|
53
|
+
export { SLIDES_API_BASE_URL, SLIDES_SCOPES } from "./lib/constants.js";
|
|
2563
54
|
/**
|
|
2564
55
|
* A maintained Google Slides connection acting as each signed-in Workspace
|
|
2565
56
|
* user through a delegated service account.
|
|
@@ -2586,7 +77,7 @@ function usageGuide(purpose, instructions) {
|
|
|
2586
77
|
*
|
|
2587
78
|
* ```ts
|
|
2588
79
|
* slides("decks", {
|
|
2589
|
-
* purpose: "
|
|
80
|
+
* purpose: "Team presentations",
|
|
2590
81
|
* serviceAccount: env.GOOGLE_SERVICE_ACCOUNT_JSON,
|
|
2591
82
|
* subject: (identity) => accounts[identity.principal?.id ?? ""],
|
|
2592
83
|
* });
|
|
@@ -2599,47 +90,49 @@ function usageGuide(purpose, instructions) {
|
|
|
2599
90
|
* `delete_comment`, `delete_comment_reply`, and `batch_update_presentation`
|
|
2600
91
|
* are destructive.
|
|
2601
92
|
*/
|
|
2602
|
-
export const slides =
|
|
93
|
+
export const slides = defineApiProvider({
|
|
2603
94
|
name: "slides",
|
|
2604
95
|
title: "Google Slides",
|
|
2605
|
-
kind: "api",
|
|
2606
96
|
readme: "Google Slides",
|
|
2607
97
|
bundle: {
|
|
2608
|
-
|
|
2609
|
-
"
|
|
2610
|
-
"note": "./providers/slides starts at 29,685 B gzip (#683): a hand-written api() surface on the shared Workspace delegation layer, like ./providers/gmail, and larger than it for what keeps every read deliverable and recoverable — reading-order projection, element- and character-level continuation, raw JSON chunking, and revision-bound cursors. The cap uses the existing baseline + 60,000 B policy. Comments (#696) — a thread read that pages text across results with a raw mode, six comment writes, partial-save reporting, and ids-first raw replies — move the measured size to 35,074 B; the cap stays at 89,685 B, set from the 29,685 B first measurement.",
|
|
98
|
+
baselineGzip: 386289,
|
|
99
|
+
note: "./providers/slides round-1 measurement is 386,289 B gzip standalone (#703), Node 26.12.0 and zlib-ng 1.3.1. Same-toolchain merged main f9bf5afb measures 362,798 B: +23,491 B. The build report gives the current measurement. This includes the shared Effect provider kit, paid once per deployment; standalone provider sizes are not additive. Provider entries are reported, not capped (#899). Combined Google merge snapshot over main db979604 measures 386,289 B gzip before recording these facts, Node 22.23.3, esbuild 0.28.1, neutral Worker conditions and gzip level 9; no cap changes (#703).",
|
|
2611
100
|
},
|
|
2612
101
|
skill,
|
|
2613
102
|
options: GOOGLE_WORKSPACE_OPTIONS,
|
|
2614
|
-
|
|
103
|
+
description: (options) => `Google Slides as the signed-in Workspace user: read decks slide by slide, create them, edit their text, and comment — ${options.purpose}`,
|
|
104
|
+
vendor: SlidesApi,
|
|
105
|
+
baseUrl: (options) => options.baseUrl?.trim() || SLIDES_API_BASE_URL,
|
|
106
|
+
maxResponseBytes: SLIDES_MAX_RESPONSE_BYTES,
|
|
107
|
+
grant: (options) => googleGrant(SLIDES, workspaceOf("slides", options)),
|
|
108
|
+
grantRenewal: GOOGLE_GRANT_RENEWAL,
|
|
109
|
+
authenticate: bearer,
|
|
110
|
+
errors,
|
|
111
|
+
safety: () => safety,
|
|
112
|
+
result,
|
|
113
|
+
idempotencyKey: false,
|
|
114
|
+
hatch: {
|
|
115
|
+
name: "slides",
|
|
116
|
+
title: "Google Slides",
|
|
117
|
+
index: SLIDES_INDEX,
|
|
118
|
+
verdicts: SLIDES_VERDICTS,
|
|
119
|
+
named: SLIDES_NAMED,
|
|
120
|
+
pathExample: "/presentations/deck1",
|
|
121
|
+
pageCursor: "argument",
|
|
122
|
+
binding: ({ connection }) => slidesBinding(connection),
|
|
123
|
+
},
|
|
124
|
+
probe,
|
|
125
|
+
guide: (options) => ({
|
|
126
|
+
context: [
|
|
127
|
+
`Purpose: ${options.purpose}`,
|
|
128
|
+
"Workspace delegation is configured. Each call acts as the account deployment code maps the caller to; an agent uses its pool owner unless one shared account is configured. No argument selects a subject.",
|
|
129
|
+
"Content windows: get_presentation defaults to 20 slides (maximum 100); get_page to 50 elements (maximum 500); list_layouts to 100 rows (maximum 500); list_comments to 50 threads (maximum 500). Slides has no vendor list pagination.",
|
|
130
|
+
"Generic thumbnails/preview links are refused; get_slide_thumbnail is the reviewed short-lived-link workflow. The generic batch retains only the 49 shipped request kinds; no extra preview methods are admitted.",
|
|
131
|
+
...(options.readOnly ? ["This connection is read-only; writes are refused."] : []),
|
|
132
|
+
],
|
|
133
|
+
summary: "Each caller's decks: text, notes, layouts, comments, guarded edits and indexed Slides JSON operations.",
|
|
134
|
+
required: true,
|
|
135
|
+
}),
|
|
136
|
+
tools: slidesTools,
|
|
137
|
+
tagConnector: tagWorkspace("slides"),
|
|
2615
138
|
});
|
|
2616
|
-
function slidesConnector(id, options) {
|
|
2617
|
-
const connection = workspaceConnection("slides", options);
|
|
2618
|
-
const client = googleWorkspaceClient({
|
|
2619
|
-
provider: "Google Slides",
|
|
2620
|
-
api: "Google Slides API",
|
|
2621
|
-
baseUrl: options.baseUrl?.trim() || SLIDES_API_BASE_URL,
|
|
2622
|
-
scopes: SLIDES_SCOPES,
|
|
2623
|
-
maxResponseBytes: SLIDES_MAX_RESPONSE_BYTES,
|
|
2624
|
-
// A deck is a Drive file: a 404 is the same answer for one that does not
|
|
2625
|
-
// exist and one this account may not open.
|
|
2626
|
-
notFound: "ambiguous",
|
|
2627
|
-
connection,
|
|
2628
|
-
});
|
|
2629
|
-
return workspaceConnector(connection, api(id, {
|
|
2630
|
-
...defined({
|
|
2631
|
-
callAdmission: options.callAdmission,
|
|
2632
|
-
maxResultBytes: options.maxResultBytes,
|
|
2633
|
-
}),
|
|
2634
|
-
title: options.title ?? "Google Slides",
|
|
2635
|
-
description: `Google Slides as the signed-in Workspace user: read decks slide by slide, create them, edit their text, and comment — ${connection.purpose}`,
|
|
2636
|
-
usageGuide: {
|
|
2637
|
-
content: usageGuide(connection.purpose, options.instructions),
|
|
2638
|
-
summary: "Each caller's own decks: per-slide text and notes, comments, new slides, text replacement, raw batchUpdate.",
|
|
2639
|
-
// Required: whose decks they are, that listing is Drive's, and the
|
|
2640
|
-
// revision discipline on writes are conventions no schema can carry.
|
|
2641
|
-
required: true,
|
|
2642
|
-
},
|
|
2643
|
-
tools: tools(client),
|
|
2644
|
-
}));
|
|
2645
|
-
}
|