@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
|
@@ -0,0 +1,1968 @@
|
|
|
1
|
+
// Google Slides named workflows on real kit schemas, with bound content continuation.
|
|
2
|
+
import { Effect, Option, Schema } from "effect";
|
|
3
|
+
import { ConnectorCallError } from "../../errors.js";
|
|
4
|
+
import { RESULT_BUDGET_BYTES, clampRedactedText, clampText, jsonBytes } from "../_shared/google/result-size.js";
|
|
5
|
+
import { googleClient, googleOutcomeOf, googleFailureMessage } from "../_shared/google/kit.js";
|
|
6
|
+
import { encodeForAgent, redactBeforeCut, redactSentSecrets } from "../../sent-secrets.js";
|
|
7
|
+
import { REQUEST_KINDS, COMMENT_KINDS, COMMENTS_UNSAVED } from "./lib/requests.js";
|
|
8
|
+
import { SLIDES_INDEX } from "./api.js";
|
|
9
|
+
import * as contracts from "./lib/schemas.js";
|
|
10
|
+
import { read, write } from "../../http/tools.js";
|
|
11
|
+
import { Operation } from "../../runtime/call.js";
|
|
12
|
+
import { fieldsInput, fieldsProblem, selectFields } from "../../http/fields.js";
|
|
13
|
+
import { Refusal } from "../../http/errors.js";
|
|
14
|
+
import { boundCursor } from "../../http/paging.js";
|
|
15
|
+
/** Slides per page of get_presentation. Slides itself returns the whole deck. */
|
|
16
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
17
|
+
/** Elements per page of get_page, and rows per page of list_layouts. */
|
|
18
|
+
const MAX_ROWS = 500;
|
|
19
|
+
const DEFAULT_ELEMENTS = 50;
|
|
20
|
+
const DEFAULT_LAYOUTS = 100;
|
|
21
|
+
/** Text kept per slide (its elements together, and its notes apart). */
|
|
22
|
+
const DEFAULT_SLIDE_CHARS = 4_000;
|
|
23
|
+
/** Requests per raw batch: connecta's bound; Slides publishes none. */
|
|
24
|
+
/**
|
|
25
|
+
* Every kind of Request Slides' batchUpdate accepts, from its Discovery
|
|
26
|
+
* document's `Request` schema. The raw hatch checks each request's one key
|
|
27
|
+
* against this list, and nothing inside it: an unknown kind is a typo or a
|
|
28
|
+
* new kind to review here, and refusing it locally costs no round trip. The
|
|
29
|
+
* contents are Slides' to validate. Too many names for a schema enum inside
|
|
30
|
+
* the H7 budget, so the handler holds the list. A kind Google adds moves
|
|
31
|
+
* the batchUpdate contract `src/providers/slides/drift.json` records.
|
|
32
|
+
*/
|
|
33
|
+
/** What get_presentation's first page may spend on its layout preview. */
|
|
34
|
+
const MAX_LAYOUT_PREVIEW_BYTES = 24 * 1024;
|
|
35
|
+
/** Byte bounds for short labels: names, titles, alt text. */
|
|
36
|
+
const MAX_NAME_BYTES = 256;
|
|
37
|
+
const MAX_TITLE_BYTES = 4 * 1024;
|
|
38
|
+
const MAX_ALT_TEXT_BYTES = 4 * 1024;
|
|
39
|
+
/** A slide title in get_presentation, in characters, before maxCharsPerSlide. */
|
|
40
|
+
const MAX_TITLE_CHARS = 500;
|
|
41
|
+
/** The longest thumbnail link passed on; a real one is well under 2 KB. */
|
|
42
|
+
const MAX_URL_BYTES = 16 * 1024;
|
|
43
|
+
/** One batchUpdate reply kept whole up to this; a larger one is summarized. */
|
|
44
|
+
const MAX_REPLY_BYTES = 2 * 1024;
|
|
45
|
+
/** How deep, and how many array items, a reply summary walks. */
|
|
46
|
+
const MAX_REPLY_DEPTH = 12;
|
|
47
|
+
const MAX_REPLY_ITEMS = 20;
|
|
48
|
+
/** An id or revision longer than this is not copied into a result. */
|
|
49
|
+
const MAX_ID_BYTES = 1024;
|
|
50
|
+
/** replace_all_text echoes each find this far, in order with its count. */
|
|
51
|
+
const MAX_ECHO_CHARS = 100;
|
|
52
|
+
/**
|
|
53
|
+
* The comment kinds of Slides' Request. A batch carrying any of them has
|
|
54
|
+
* comment changes Slides saves apart from the deck's, and only `ALL_SAVED`
|
|
55
|
+
* says they all landed (#696).
|
|
56
|
+
*/
|
|
57
|
+
/** The comment save states that say every comment change in a batch landed, or none was asked for. */
|
|
58
|
+
const COMMENTS_SETTLED = new Set(["NO_UPDATES_REQUESTED", "ALL_SAVED"]);
|
|
59
|
+
/**
|
|
60
|
+
* `commentUpdateState`'s zero value. ProtoJSON omits a zero enum, so a batch
|
|
61
|
+
* with comment requests whose reply has no state is reported as this one:
|
|
62
|
+
* Slides did not say the comments saved.
|
|
63
|
+
*/
|
|
64
|
+
const COMMENT_STATE_UNSPECIFIED = "COMMENT_UPDATE_STATE_UNSPECIFIED";
|
|
65
|
+
/**
|
|
66
|
+
* The only view mode that cannot hide comments silently. The default omits
|
|
67
|
+
* them, and DEFAULT_FOR_CURRENT_ACCESS omits them for a view-only account,
|
|
68
|
+
* which would read as a deck with none; INCLUDED refuses that account with a
|
|
69
|
+
* 403 instead.
|
|
70
|
+
*/
|
|
71
|
+
const COMMENTS_INCLUDED = "COMMENTS_VIEW_MODE_INCLUDED";
|
|
72
|
+
/** Slides' cap on a post's text and an assignee's address, in UTF-8 bytes. */
|
|
73
|
+
const MAX_COMMENT_BYTES = 2048;
|
|
74
|
+
/** Comment threads per page of list_comments. */
|
|
75
|
+
const DEFAULT_THREADS = 50;
|
|
76
|
+
/** A post's text in a write result; Slides caps what it accepts well below this. */
|
|
77
|
+
const MAX_POST_BYTES = 8 * 1024;
|
|
78
|
+
/** EMU per point, the two units a Slides transform uses. */
|
|
79
|
+
const EMU_PER_PT = 12_700;
|
|
80
|
+
/**
|
|
81
|
+
* The partial responses the projected reads ask for: ids, names, and text,
|
|
82
|
+
* without the styles that are most of a deck's bytes. Each element kind keeps
|
|
83
|
+
* one field Slides always sets, so its presence survives the mask. Groups stay
|
|
84
|
+
* whole, because a mask cannot recurse into their children. get_presentation
|
|
85
|
+
* and get_page ask for the same element fields, so both read a page's
|
|
86
|
+
* elements identically — the same reading order, the same text — and a
|
|
87
|
+
* cursor one issues the other honors.
|
|
88
|
+
*/
|
|
89
|
+
const TEXT_ONLY = "text(textElements(textRun(content),autoText(content)))";
|
|
90
|
+
const ELEMENT_FIELDS = "objectId,title,description,transform,elementGroup,line(lineType),image(contentUrl),video(source)," +
|
|
91
|
+
`sheetsChart(spreadsheetId),wordArt,table(rows,columns,tableRows(tableCells(${TEXT_ONLY}))),` +
|
|
92
|
+
`shape(shapeType,placeholder(type,index,parentObjectId),${TEXT_ONLY})`;
|
|
93
|
+
const LAYOUT_FIELDS = "masters(objectId,masterProperties(displayName)),layouts(objectId,layoutProperties(name,displayName,masterObjectId))," +
|
|
94
|
+
"notesMaster(objectId)";
|
|
95
|
+
const PRESENTATION_FIELDS = [
|
|
96
|
+
"presentationId",
|
|
97
|
+
"title",
|
|
98
|
+
"revisionId",
|
|
99
|
+
"locale",
|
|
100
|
+
"pageSize",
|
|
101
|
+
LAYOUT_FIELDS,
|
|
102
|
+
`slides(objectId,slideProperties(layoutObjectId,isSkipped,notesPage(objectId,notesProperties,pageElements(${ELEMENT_FIELDS}))),` +
|
|
103
|
+
`pageElements(${ELEMENT_FIELDS}))`,
|
|
104
|
+
].join(",");
|
|
105
|
+
const PAGE_FIELDS = [
|
|
106
|
+
"objectId",
|
|
107
|
+
"pageType",
|
|
108
|
+
"revisionId",
|
|
109
|
+
"slideProperties(layoutObjectId,masterObjectId,isSkipped,notesPage(objectId))",
|
|
110
|
+
"layoutProperties(name,displayName,masterObjectId)",
|
|
111
|
+
"masterProperties(displayName)",
|
|
112
|
+
"notesProperties(speakerNotesObjectId)",
|
|
113
|
+
`pageElements(${ELEMENT_FIELDS})`,
|
|
114
|
+
].join(",");
|
|
115
|
+
const LAYOUT_LIST_FIELDS = `presentationId,revisionId,${LAYOUT_FIELDS}`;
|
|
116
|
+
/**
|
|
117
|
+
* A deck's threads, and the anchors on every page a comment can sit on that
|
|
118
|
+
* place them: slides, their notes pages, layouts, masters, the notes master.
|
|
119
|
+
*/
|
|
120
|
+
const COMMENT_DECK_FIELDS = "presentationId,revisionId,commentsViewMode,comments," +
|
|
121
|
+
"slides(objectId,commentAnchors,slideProperties(notesPage(objectId,commentAnchors)))," +
|
|
122
|
+
"layouts(objectId,commentAnchors),masters(objectId,commentAnchors),notesMaster(objectId,commentAnchors)";
|
|
123
|
+
const COMMENT_PAGE_FIELDS = "objectId,revisionId,commentsViewMode,comments,commentAnchors";
|
|
124
|
+
function asRecord(value) {
|
|
125
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
126
|
+
}
|
|
127
|
+
function asArray(value) {
|
|
128
|
+
return Array.isArray(value) ? value : [];
|
|
129
|
+
}
|
|
130
|
+
function compact(value) {
|
|
131
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
132
|
+
}
|
|
133
|
+
function text(value) {
|
|
134
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
135
|
+
}
|
|
136
|
+
function editUrl(presentationId) {
|
|
137
|
+
return `https://docs.google.com/presentation/d/${encodeURIComponent(presentationId)}/edit`;
|
|
138
|
+
}
|
|
139
|
+
// --- Result size --------------------------------------------------------------------
|
|
140
|
+
/**
|
|
141
|
+
* The postcondition every read result meets: one result, under the shared
|
|
142
|
+
* budget, whichever way it was called. Each read is built to fit, so this
|
|
143
|
+
* failing means a shape the building missed; it refuses, explicitly, rather
|
|
144
|
+
* than hand back a result neither delivery route can carry.
|
|
145
|
+
*/
|
|
146
|
+
function deliverable(result, tool, advice = "read a smaller part, or through slides_api_read, which pages in chunks") {
|
|
147
|
+
const bytes = jsonBytes(result);
|
|
148
|
+
if (bytes > RESULT_BUDGET_BYTES) {
|
|
149
|
+
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 });
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The same postcondition for a write's result, which comes after Google
|
|
155
|
+
* answered 2xx: the write has applied, so a result too large to deliver is
|
|
156
|
+
* refused with that said, and with where to see the outcome — never as if
|
|
157
|
+
* nothing changed, and never as something to retry.
|
|
158
|
+
*/
|
|
159
|
+
function appliedDeliverable(result, tool, reread) {
|
|
160
|
+
const bytes = jsonBytes(result);
|
|
161
|
+
if (bytes > RESULT_BUDGET_BYTES)
|
|
162
|
+
throw appliedButUnreadable(tool, `its result is ${bytes} bytes, more than one result can carry`, reread);
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
function appliedButUnreadable(tool, why, reread) {
|
|
166
|
+
return new ConnectorCallError("connector_call_failed", `${tool} applied — Google answered 2xx — but ${why}. Do not repeat it; ${reread}.`, { retryable: false });
|
|
167
|
+
}
|
|
168
|
+
/** A label cut to `maxBytes` of JSON, saying where the whole of it is. */
|
|
169
|
+
function label(ctx, value, maxBytes, whole) {
|
|
170
|
+
return value === undefined
|
|
171
|
+
? undefined
|
|
172
|
+
: clampText(ctx, value, maxBytes, (dropped) => `[… ${dropped} more characters; ${whole}]`);
|
|
173
|
+
}
|
|
174
|
+
/** The first `max` UTF-16 units of `value`, never ending half a surrogate pair. */
|
|
175
|
+
function headOf(value, max) {
|
|
176
|
+
if (value.length <= max)
|
|
177
|
+
return value;
|
|
178
|
+
const code = value.charCodeAt(max - 1);
|
|
179
|
+
return value.slice(0, code >= 0xd800 && code <= 0xdbff ? max - 1 : max);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The longest prefix of `value` whose JSON fits `maxBytes` together with the
|
|
183
|
+
* marker its cut earns, as text and as the number of characters it keeps.
|
|
184
|
+
* `value` is text whose sent secrets were replaced before any page of it was
|
|
185
|
+
* cut, so a continuation's offsets count the same text.
|
|
186
|
+
*/
|
|
187
|
+
function prefixWithin(value, maxBytes, marker) {
|
|
188
|
+
let dropped = 0;
|
|
189
|
+
const clamped = clampRedactedText(value, maxBytes, (count) => {
|
|
190
|
+
dropped = count;
|
|
191
|
+
return marker(count);
|
|
192
|
+
});
|
|
193
|
+
if (clamped === value)
|
|
194
|
+
return { text: value, kept: value.length };
|
|
195
|
+
if (clamped === "")
|
|
196
|
+
return { text: "", kept: 0 };
|
|
197
|
+
return { text: clamped, kept: value.length - dropped };
|
|
198
|
+
}
|
|
199
|
+
const CURSOR_TOOLS = {
|
|
200
|
+
deck: "get_presentation",
|
|
201
|
+
layouts: "list_layouts",
|
|
202
|
+
page: "get_page",
|
|
203
|
+
comments: "list_comments",
|
|
204
|
+
};
|
|
205
|
+
function cursorText(cursor) {
|
|
206
|
+
return JSON.stringify(compact(cursor));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* A cursor of connecta's claims: the presentation and page the caller named,
|
|
210
|
+
* or a page Slides reported, redacted ({@link redactSentSecrets}); a state
|
|
211
|
+
* that holds Slides' revision only when it carries no sent value
|
|
212
|
+
* ({@link stateOf}); and offsets. An agent decodes it and no later pass reads
|
|
213
|
+
* through its encoding.
|
|
214
|
+
*/
|
|
215
|
+
function encodeCursor(ctx, cursor) {
|
|
216
|
+
return encodeForAgent(ctx, cursorText(cursor), { url: true, claims: true });
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* A cursor this tool issued for this deck and page, in this mode, or a
|
|
220
|
+
* refusal that says which of those it is not. The state is checked later,
|
|
221
|
+
* against the deck as it is read.
|
|
222
|
+
*/
|
|
223
|
+
function decodeCursor(value, expected) {
|
|
224
|
+
if (value === undefined)
|
|
225
|
+
return undefined;
|
|
226
|
+
const refuse = (why) => new ConnectorCallError("invalid_args", `${why} Omit cursor to start from the beginning.`);
|
|
227
|
+
let cursor;
|
|
228
|
+
try {
|
|
229
|
+
const base64 = String(value).replace(/-/g, "+").replace(/_/g, "/");
|
|
230
|
+
const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
|
|
231
|
+
cursor = JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))));
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
throw refuse("cursor is not one this connection issued; pass page.nextCursor back unchanged.");
|
|
235
|
+
}
|
|
236
|
+
const counter = (field) => field === undefined || (Number.isSafeInteger(field) && field >= 0);
|
|
237
|
+
if (!cursor ||
|
|
238
|
+
typeof cursor !== "object" ||
|
|
239
|
+
typeof cursor.p !== "string" ||
|
|
240
|
+
typeof cursor.s !== "string" ||
|
|
241
|
+
!Number.isSafeInteger(cursor.i) ||
|
|
242
|
+
cursor.i < 0 ||
|
|
243
|
+
!counter(cursor.o) ||
|
|
244
|
+
!counter(cursor.j)) {
|
|
245
|
+
throw refuse("cursor is not one this connection issued; pass page.nextCursor back unchanged.");
|
|
246
|
+
}
|
|
247
|
+
if (cursor.k !== expected.k) {
|
|
248
|
+
const tool = Object.hasOwn(CURSOR_TOOLS, cursor.k) ? CURSOR_TOOLS[cursor.k] : undefined;
|
|
249
|
+
throw refuse(tool
|
|
250
|
+
? `cursor belongs to another tool (${tool}).`
|
|
251
|
+
: "cursor is not one this connection issued; pass page.nextCursor back unchanged.");
|
|
252
|
+
}
|
|
253
|
+
if (cursor.p !== expected.p)
|
|
254
|
+
throw refuse("cursor continues a different presentation.");
|
|
255
|
+
if ((cursor.g ?? undefined) !== expected.g)
|
|
256
|
+
throw refuse("cursor continues a different page.");
|
|
257
|
+
return cursor;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* List cursors can bind to the revision. Text offsets also bind to a SHA-256
|
|
261
|
+
* of the redacted representation, even with a revision: a different request's
|
|
262
|
+
* secret set can change that text without changing the deck. Viewers without
|
|
263
|
+
* a revision always bind to the content their paging depends on.
|
|
264
|
+
*/
|
|
265
|
+
async function stateOf(ctx, revisionId, fingerprint, bindRepresentation = false) {
|
|
266
|
+
// A revision that holds a sent value is bound by its digest instead: a cursor carries no downstream text.
|
|
267
|
+
if (!bindRepresentation &&
|
|
268
|
+
typeof revisionId === "string" &&
|
|
269
|
+
revisionId !== "" &&
|
|
270
|
+
redactSentSecrets(ctx, revisionId) === revisionId)
|
|
271
|
+
return `r:${revisionId}`;
|
|
272
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([revisionId ?? null, fingerprint()]))));
|
|
273
|
+
return `f:${Array.from(digest.subarray(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
274
|
+
}
|
|
275
|
+
/** A cursor's deck changed under it: continuing could skip or repeat. */
|
|
276
|
+
function assertUnchanged(cursor, state, what) {
|
|
277
|
+
if (cursor && cursor.s !== state) {
|
|
278
|
+
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.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** A shape's or cell's text: runs and auto text in order, soft breaks as newlines. */
|
|
282
|
+
function textOf(ctx, content) {
|
|
283
|
+
let out = "";
|
|
284
|
+
for (const element of asArray(asRecord(content)["textElements"]).map(asRecord)) {
|
|
285
|
+
const run = asRecord(element["textRun"])["content"] ?? asRecord(element["autoText"])["content"];
|
|
286
|
+
if (typeof run === "string")
|
|
287
|
+
out += run;
|
|
288
|
+
}
|
|
289
|
+
return redactBeforeCut(ctx, out).replaceAll("\u000b", "\n").replace(/\s+$/, "");
|
|
290
|
+
}
|
|
291
|
+
/** A table as tab-separated cells, one row per line. */
|
|
292
|
+
function tableText(ctx, table) {
|
|
293
|
+
return asArray(table["tableRows"])
|
|
294
|
+
.map((row) => asArray(asRecord(row)["tableCells"])
|
|
295
|
+
.map((cell) => textOf(ctx, asRecord(cell)["text"]).replace(/[\t\n]+/g, " "))
|
|
296
|
+
.join("\t")
|
|
297
|
+
.replace(/\t+$/, ""))
|
|
298
|
+
.join("\n")
|
|
299
|
+
.replace(/\s+$/, "");
|
|
300
|
+
}
|
|
301
|
+
const IDENTITY = [1, 0, 0, 1, 0, 0];
|
|
302
|
+
/**
|
|
303
|
+
* A transform as a matrix. ProtoJSON omits a zero, and a zero scale is real —
|
|
304
|
+
* a group turned 90° has none — so an omitted coefficient of a present
|
|
305
|
+
* transform is 0. Only a transform that is absent altogether is the identity.
|
|
306
|
+
*/
|
|
307
|
+
function matrixOf(transform) {
|
|
308
|
+
if (!transform || typeof transform !== "object" || Array.isArray(transform))
|
|
309
|
+
return IDENTITY;
|
|
310
|
+
const t = transform;
|
|
311
|
+
const n = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
312
|
+
const unit = t["unit"] === "PT" ? EMU_PER_PT : 1;
|
|
313
|
+
return [
|
|
314
|
+
n(t["scaleX"]),
|
|
315
|
+
n(t["shearY"]),
|
|
316
|
+
n(t["shearX"]),
|
|
317
|
+
n(t["scaleY"]),
|
|
318
|
+
n(t["translateX"]) * unit,
|
|
319
|
+
n(t["translateY"]) * unit,
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
/** `parent` preconcatenated with `child`, as Slides composes group transforms. */
|
|
323
|
+
function compose(parent, child) {
|
|
324
|
+
const [a, b, c, d, e, f] = parent;
|
|
325
|
+
const [ca, cb, cc, cd, ce, cf] = child;
|
|
326
|
+
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];
|
|
327
|
+
}
|
|
328
|
+
function kindOf(element) {
|
|
329
|
+
if (element["shape"])
|
|
330
|
+
return "shape";
|
|
331
|
+
if (element["table"])
|
|
332
|
+
return "table";
|
|
333
|
+
if (element["wordArt"])
|
|
334
|
+
return "wordArt";
|
|
335
|
+
if (element["image"])
|
|
336
|
+
return "image";
|
|
337
|
+
if (element["video"])
|
|
338
|
+
return "video";
|
|
339
|
+
if (element["sheetsChart"])
|
|
340
|
+
return "chart";
|
|
341
|
+
if (element["line"])
|
|
342
|
+
return "line";
|
|
343
|
+
return "other";
|
|
344
|
+
}
|
|
345
|
+
function collect(ctx, elements, parent, groupId, depth, out) {
|
|
346
|
+
for (const element of asArray(elements).map(asRecord)) {
|
|
347
|
+
const matrix = compose(parent, matrixOf(element["transform"]));
|
|
348
|
+
const group = asRecord(element["elementGroup"]);
|
|
349
|
+
if (Array.isArray(group["children"]) && depth < 20) {
|
|
350
|
+
collect(ctx, group["children"], matrix, text(element["objectId"]), depth + 1, out);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const kind = kindOf(element);
|
|
354
|
+
out.push({
|
|
355
|
+
element,
|
|
356
|
+
groupId,
|
|
357
|
+
kind,
|
|
358
|
+
text: kind === "shape"
|
|
359
|
+
? textOf(ctx, asRecord(element["shape"])["text"])
|
|
360
|
+
: kind === "table"
|
|
361
|
+
? tableText(ctx, asRecord(element["table"]))
|
|
362
|
+
: kind === "wordArt"
|
|
363
|
+
? (text(asRecord(element["wordArt"])["renderedText"]) ?? "")
|
|
364
|
+
: "",
|
|
365
|
+
x: matrix[4],
|
|
366
|
+
y: matrix[5],
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* A page's leaf elements in reading order: top to bottom, then left to right,
|
|
372
|
+
* by absolute position rounded to the point, so a hairline offset does not
|
|
373
|
+
* reorder a row. The index into this list is what a get_page cursor counts.
|
|
374
|
+
*/
|
|
375
|
+
function readingOrder(ctx, elements) {
|
|
376
|
+
const out = [];
|
|
377
|
+
collect(ctx, elements, IDENTITY, undefined, 0, out);
|
|
378
|
+
// Text is cut, and continued from an offset, only with every secret the
|
|
379
|
+
// request sent replaced: a cut through an echo leaves a fragment.
|
|
380
|
+
for (const leaf of out)
|
|
381
|
+
leaf.text = redactBeforeCut(ctx, leaf.text);
|
|
382
|
+
return out.sort((left, right) => Math.round(left.y / EMU_PER_PT) - Math.round(right.y / EMU_PER_PT) ||
|
|
383
|
+
Math.round(left.x / EMU_PER_PT) - Math.round(right.x / EMU_PER_PT));
|
|
384
|
+
}
|
|
385
|
+
/** What a projected page's cursor depends on when there is no revision. */
|
|
386
|
+
function leafFingerprint(leaves) {
|
|
387
|
+
// The text itself, not its length: a continuation resumes at a character
|
|
388
|
+
// offset, and an edit that keeps the length still moves what is there.
|
|
389
|
+
return leaves.map((leaf) => [leaf.element["objectId"] ?? null, leaf.kind, leaf.text]);
|
|
390
|
+
}
|
|
391
|
+
function placeholderOf(leaf) {
|
|
392
|
+
return leaf.kind === "shape" ? asRecord(asRecord(leaf.element["shape"])["placeholder"]) : {};
|
|
393
|
+
}
|
|
394
|
+
function altTextOf(element) {
|
|
395
|
+
return [text(element["title"]), text(element["description"])].filter(Boolean).join(": ") || undefined;
|
|
396
|
+
}
|
|
397
|
+
/** The fields of an element both readers share: what it is, and how to address it. */
|
|
398
|
+
function describeLeaf(ctx, leaf, raw) {
|
|
399
|
+
const placeholder = placeholderOf(leaf);
|
|
400
|
+
return compact({
|
|
401
|
+
objectId: text(leaf.element["objectId"]),
|
|
402
|
+
kind: leaf.kind,
|
|
403
|
+
placeholder: text(placeholder["type"]),
|
|
404
|
+
altText: label(ctx, altTextOf(leaf.element), MAX_ALT_TEXT_BYTES, raw),
|
|
405
|
+
// A linked chart's data lives in Sheets; the id is how to reach it.
|
|
406
|
+
spreadsheetId: leaf.kind === "chart" ? text(asRecord(leaf.element["sheetsChart"])["spreadsheetId"]) : undefined,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
const FULL_STRUCTURE = "slides_api_read has the complete structure";
|
|
410
|
+
// --- get_presentation ---------------------------------------------------------------
|
|
411
|
+
const TITLE_PLACEHOLDERS = new Set(["TITLE", "CENTERED_TITLE"]);
|
|
412
|
+
/** Why text was cut, as the marker tells the reader. */
|
|
413
|
+
const RAISE_CAP = "raise maxCharsPerSlide, or continue with get_page from textCursor";
|
|
414
|
+
const OVER_RESULT = "this slide is larger than one result can carry; continue with get_page from textCursor";
|
|
415
|
+
/**
|
|
416
|
+
* One slide's projection: its elements' text in reading order under a shared
|
|
417
|
+
* per-slide character budget, its speaker notes under the same budget apart,
|
|
418
|
+
* and, for every cut, the get_page cursor that reads on from the cut.
|
|
419
|
+
*/
|
|
420
|
+
async function projectSlide(ctx, value, index, deck, maxChars, why = RAISE_CAP) {
|
|
421
|
+
const slide = asRecord(value);
|
|
422
|
+
const slideId = text(slide["objectId"]) ?? "";
|
|
423
|
+
// Slides' ids enter a cursor only redacted, as every downstream value a cursor holds does.
|
|
424
|
+
const cursorPage = redactSentSecrets(ctx, slideId);
|
|
425
|
+
const properties = asRecord(slide["slideProperties"]);
|
|
426
|
+
const leaves = readingOrder(ctx, slide["pageElements"]);
|
|
427
|
+
let state;
|
|
428
|
+
const slideState = () => (state ??= stateOf(ctx, deck.revisionId, () => leafFingerprint(leaves), true));
|
|
429
|
+
let budget = maxChars;
|
|
430
|
+
let truncated = false;
|
|
431
|
+
let omitted = 0;
|
|
432
|
+
let title;
|
|
433
|
+
const elements = [];
|
|
434
|
+
for (const [position, leaf] of leaves.entries()) {
|
|
435
|
+
const placeholder = text(placeholderOf(leaf)["type"]);
|
|
436
|
+
const alt = altTextOf(leaf.element);
|
|
437
|
+
// Decorative lines and empty boxes carry nothing to read; they are
|
|
438
|
+
// counted, not listed. An empty placeholder is kept: its id is where
|
|
439
|
+
// insertText fills a new slide's title or body.
|
|
440
|
+
if (leaf.kind === "line" ||
|
|
441
|
+
(!leaf.text && !alt && !placeholder && (leaf.kind === "shape" || leaf.kind === "wordArt"))) {
|
|
442
|
+
omitted += 1;
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (placeholder && TITLE_PLACEHOLDERS.has(placeholder) && title === undefined && leaf.text) {
|
|
446
|
+
const line = leaf.text.replace(/\n+/g, " ");
|
|
447
|
+
const keep = headOf(line, Math.min(MAX_TITLE_CHARS, maxChars));
|
|
448
|
+
title =
|
|
449
|
+
keep.length < line.length
|
|
450
|
+
? `${keep}\n[… ${line.length - keep.length} more characters; the title element's text has them]`
|
|
451
|
+
: line;
|
|
452
|
+
}
|
|
453
|
+
const row = describeLeaf(ctx, leaf, FULL_STRUCTURE);
|
|
454
|
+
if (row["altText"] !== alt)
|
|
455
|
+
truncated = true;
|
|
456
|
+
if (leaf.text) {
|
|
457
|
+
const kept = headOf(leaf.text, Math.max(budget, 0));
|
|
458
|
+
budget -= kept.length;
|
|
459
|
+
if (kept.length < leaf.text.length) {
|
|
460
|
+
truncated = true;
|
|
461
|
+
row["text"] = `${kept}\n[… ${leaf.text.length - kept.length} more characters truncated; ${why}]`;
|
|
462
|
+
row["truncated"] = true;
|
|
463
|
+
row["textCursor"] = encodeCursor(ctx, {
|
|
464
|
+
k: "page",
|
|
465
|
+
p: deck.presentationId,
|
|
466
|
+
g: cursorPage,
|
|
467
|
+
s: await slideState(),
|
|
468
|
+
i: position,
|
|
469
|
+
o: kept.length,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
row["text"] = kept;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
row["position"] = position;
|
|
477
|
+
elements.push(row);
|
|
478
|
+
}
|
|
479
|
+
const notesPage = asRecord(properties["notesPage"]);
|
|
480
|
+
const notesPageId = text(notesPage["objectId"]);
|
|
481
|
+
const notesId = text(asRecord(notesPage["notesProperties"])["speakerNotesObjectId"]);
|
|
482
|
+
const notesLeaves = readingOrder(ctx, notesPage["pageElements"]);
|
|
483
|
+
const notesAt = notesLeaves.findIndex((leaf) => notesId !== undefined && leaf.element["objectId"] === notesId);
|
|
484
|
+
const notesText = notesAt >= 0 ? notesLeaves[notesAt].text : "";
|
|
485
|
+
let notes;
|
|
486
|
+
let notesCursor;
|
|
487
|
+
if (notesText) {
|
|
488
|
+
const kept = headOf(notesText, maxChars);
|
|
489
|
+
if (kept.length < notesText.length) {
|
|
490
|
+
truncated = true;
|
|
491
|
+
notes = `${kept}\n[… ${notesText.length - kept.length} more characters truncated; ${why.replace("textCursor", "notesCursor")}]`;
|
|
492
|
+
notesCursor = encodeCursor(ctx, {
|
|
493
|
+
k: "page",
|
|
494
|
+
p: deck.presentationId,
|
|
495
|
+
g: redactSentSecrets(ctx, notesPageId ?? ""),
|
|
496
|
+
s: await stateOf(ctx, deck.revisionId, () => leafFingerprint(notesLeaves), true),
|
|
497
|
+
i: notesAt,
|
|
498
|
+
o: kept.length,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
notes = notesText;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const layoutId = text(properties["layoutObjectId"]);
|
|
506
|
+
return compact({
|
|
507
|
+
objectId: slideId || undefined,
|
|
508
|
+
index,
|
|
509
|
+
layout: layoutId ? deck.layouts.get(layoutId) : undefined,
|
|
510
|
+
layoutId,
|
|
511
|
+
skipped: properties["isSkipped"] === true || undefined,
|
|
512
|
+
title,
|
|
513
|
+
elements,
|
|
514
|
+
omittedElements: omitted > 0 ? omitted : undefined,
|
|
515
|
+
notesPageId,
|
|
516
|
+
notes,
|
|
517
|
+
notesCursor,
|
|
518
|
+
truncated: truncated || undefined,
|
|
519
|
+
// Kept for fitting: where each listed element sits among the page's
|
|
520
|
+
// leaves, which an elementsCursor counts. Removed before returning.
|
|
521
|
+
_state: slideState,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
/** A projected slide as returned: the fitting bookkeeping gone. */
|
|
525
|
+
function finished(slide) {
|
|
526
|
+
const { _state, ...rest } = slide;
|
|
527
|
+
return {
|
|
528
|
+
...rest,
|
|
529
|
+
elements: asArray(rest["elements"]).map((element) => {
|
|
530
|
+
const { position: _position, ...row } = asRecord(element);
|
|
531
|
+
return row;
|
|
532
|
+
}),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* A page's first slide that is too large for the `room` left, made to fit:
|
|
537
|
+
* its text halved until it does, then — for a slide of so many elements that
|
|
538
|
+
* it does not fit with no text — its last elements left out, counted, and
|
|
539
|
+
* named by the get_page cursor that lists them. Every cut says it was the
|
|
540
|
+
* result's size, not maxCharsPerSlide. A later slide that does not fit starts
|
|
541
|
+
* the next page whole instead.
|
|
542
|
+
*/
|
|
543
|
+
async function fitSlide(ctx, value, index, deck, maxChars, room) {
|
|
544
|
+
for (let chars = Math.floor(maxChars / 2);; chars = Math.floor(chars / 2)) {
|
|
545
|
+
const slide = await projectSlide(ctx, value, index, deck, chars, OVER_RESULT);
|
|
546
|
+
if (jsonBytes(finished(slide)) <= room)
|
|
547
|
+
return finished(slide);
|
|
548
|
+
if (chars > 0)
|
|
549
|
+
continue;
|
|
550
|
+
const elements = asArray(slide["elements"]).map(asRecord);
|
|
551
|
+
const state = await slide["_state"]();
|
|
552
|
+
const keeping = (count) => finished({
|
|
553
|
+
...slide,
|
|
554
|
+
elements: elements.slice(0, count),
|
|
555
|
+
elementsNotShown: elements.length - count,
|
|
556
|
+
elementsCursor: encodeCursor(ctx, {
|
|
557
|
+
k: "page",
|
|
558
|
+
p: deck.presentationId,
|
|
559
|
+
g: redactSentSecrets(ctx, text(asRecord(value)["objectId"]) ?? ""),
|
|
560
|
+
s: state,
|
|
561
|
+
i: count < elements.length ? Number(elements[count]["position"]) : 0,
|
|
562
|
+
}),
|
|
563
|
+
truncated: true,
|
|
564
|
+
});
|
|
565
|
+
let low = 0;
|
|
566
|
+
let high = elements.length;
|
|
567
|
+
while (low < high) {
|
|
568
|
+
const middle = Math.ceil((low + high) / 2);
|
|
569
|
+
if (jsonBytes(keeping(middle)) <= room)
|
|
570
|
+
low = middle;
|
|
571
|
+
else
|
|
572
|
+
high = middle - 1;
|
|
573
|
+
}
|
|
574
|
+
// The result's postcondition refuses a slide that still does not fit.
|
|
575
|
+
return keeping(low);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// --- Layouts ------------------------------------------------------------------------
|
|
579
|
+
/** Masters, each followed by its layouts, as rows create_slide and get_page take ids from. */
|
|
580
|
+
function layoutRows(ctx, presentation) {
|
|
581
|
+
const whole = "get_page on this id through slides_api_read has the whole name";
|
|
582
|
+
const masters = asArray(presentation["masters"]).map(asRecord);
|
|
583
|
+
const layouts = asArray(presentation["layouts"]).map(asRecord);
|
|
584
|
+
const layoutRow = (layout) => {
|
|
585
|
+
const properties = asRecord(layout["layoutProperties"]);
|
|
586
|
+
return compact({
|
|
587
|
+
objectId: text(layout["objectId"]),
|
|
588
|
+
kind: "layout",
|
|
589
|
+
name: label(ctx, text(properties["name"]), MAX_NAME_BYTES, whole),
|
|
590
|
+
displayName: label(ctx, text(properties["displayName"]), MAX_NAME_BYTES, whole),
|
|
591
|
+
masterId: text(properties["masterObjectId"]),
|
|
592
|
+
});
|
|
593
|
+
};
|
|
594
|
+
const rows = [];
|
|
595
|
+
const placed = new Set();
|
|
596
|
+
for (const master of masters) {
|
|
597
|
+
const id = text(master["objectId"]);
|
|
598
|
+
rows.push(compact({
|
|
599
|
+
objectId: id,
|
|
600
|
+
kind: "master",
|
|
601
|
+
displayName: label(ctx, text(asRecord(master["masterProperties"])["displayName"]), MAX_NAME_BYTES, whole),
|
|
602
|
+
}));
|
|
603
|
+
for (const layout of layouts) {
|
|
604
|
+
if (id !== undefined && asRecord(layout["layoutProperties"])["masterObjectId"] === id) {
|
|
605
|
+
rows.push(layoutRow(layout));
|
|
606
|
+
placed.add(layout);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
for (const layout of layouts)
|
|
611
|
+
if (!placed.has(layout))
|
|
612
|
+
rows.push(layoutRow(layout));
|
|
613
|
+
return rows;
|
|
614
|
+
}
|
|
615
|
+
/** What a list_layouts cursor depends on when there is no revision. */
|
|
616
|
+
function layoutFingerprint(rows) {
|
|
617
|
+
return rows.map((row) => row["objectId"] ?? null);
|
|
618
|
+
}
|
|
619
|
+
/** A layout's name as a person picks it, bounded: its display name, else its kind. */
|
|
620
|
+
function layoutNames(ctx, presentation) {
|
|
621
|
+
const names = new Map();
|
|
622
|
+
for (const layout of asArray(presentation["layouts"]).map(asRecord)) {
|
|
623
|
+
const id = text(layout["objectId"]);
|
|
624
|
+
const properties = asRecord(layout["layoutProperties"]);
|
|
625
|
+
const name = label(ctx, text(properties["displayName"]) ?? text(properties["name"]), MAX_NAME_BYTES, "get_page on layoutId through slides_api_read has the whole name");
|
|
626
|
+
if (id && name)
|
|
627
|
+
names.set(id, name);
|
|
628
|
+
}
|
|
629
|
+
return names;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Rows from `start` that fit `room` bytes and `limit` rows, as a page of
|
|
633
|
+
* them and the index the next page starts at.
|
|
634
|
+
*/
|
|
635
|
+
function rowsWithin(rows, start, limit, room) {
|
|
636
|
+
const out = [];
|
|
637
|
+
let used = 2;
|
|
638
|
+
let next = start;
|
|
639
|
+
while (next < rows.length && out.length < limit) {
|
|
640
|
+
const size = jsonBytes(rows[next]) + 1;
|
|
641
|
+
if (used + size > room)
|
|
642
|
+
break;
|
|
643
|
+
out.push(rows[next]);
|
|
644
|
+
used += size;
|
|
645
|
+
next += 1;
|
|
646
|
+
}
|
|
647
|
+
return { rows: out, next };
|
|
648
|
+
}
|
|
649
|
+
function dimension(value) {
|
|
650
|
+
const record = asRecord(value);
|
|
651
|
+
return typeof record["magnitude"] === "number"
|
|
652
|
+
? { magnitude: record["magnitude"], unit: text(record["unit"]) ?? "EMU" }
|
|
653
|
+
: undefined;
|
|
654
|
+
}
|
|
655
|
+
/** The size of a page object carrying the longest cursor a result may hold. */
|
|
656
|
+
function pageEnvelopeBytes(cursor) {
|
|
657
|
+
const longest = { ...cursor, i: 999_999, o: 99_999_999, j: cursor.j === undefined ? undefined : 999_999 };
|
|
658
|
+
const units = Math.ceil((new TextEncoder().encode(cursorText(longest)).length * 4) / 3);
|
|
659
|
+
return 1024 + jsonBytes({ page: { hasMore: true, nextCursor: "x".repeat(units) } });
|
|
660
|
+
}
|
|
661
|
+
// --- get_page -------------------------------------------------------------------------
|
|
662
|
+
/** What get_page says about the page itself, by the page's kind. */
|
|
663
|
+
function describePage(ctx, page) {
|
|
664
|
+
const whole = "slides_api_read has the whole name";
|
|
665
|
+
const slide = asRecord(page["slideProperties"]);
|
|
666
|
+
const layout = asRecord(page["layoutProperties"]);
|
|
667
|
+
return compact({
|
|
668
|
+
pageType: text(page["pageType"]),
|
|
669
|
+
layoutId: text(slide["layoutObjectId"]),
|
|
670
|
+
masterId: text(slide["masterObjectId"]) ?? text(layout["masterObjectId"]),
|
|
671
|
+
notesPageId: text(asRecord(slide["notesPage"])["objectId"]),
|
|
672
|
+
skipped: slide["isSkipped"] === true || undefined,
|
|
673
|
+
speakerNotesObjectId: text(asRecord(page["notesProperties"])["speakerNotesObjectId"]),
|
|
674
|
+
name: label(ctx, text(layout["name"]), MAX_NAME_BYTES, whole),
|
|
675
|
+
displayName: label(ctx, text(layout["displayName"]) ?? text(asRecord(page["masterProperties"])["displayName"]), MAX_NAME_BYTES, whole),
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Projected rows of a page from element `i`, text offset `o`: every leaf,
|
|
680
|
+
* in reading order, with its text continued across pages wherever one page
|
|
681
|
+
* cannot hold it.
|
|
682
|
+
*/
|
|
683
|
+
function projectedRows(ctx, leaves, start, limit, room) {
|
|
684
|
+
const elements = [];
|
|
685
|
+
let used = 2;
|
|
686
|
+
let index = start.i;
|
|
687
|
+
let offset = start.o;
|
|
688
|
+
while (index < leaves.length && elements.length < limit) {
|
|
689
|
+
const leaf = leaves[index];
|
|
690
|
+
const placeholder = placeholderOf(leaf);
|
|
691
|
+
const row = {
|
|
692
|
+
...describeLeaf(ctx, leaf, FULL_STRUCTURE),
|
|
693
|
+
...compact({
|
|
694
|
+
groupId: leaf.groupId,
|
|
695
|
+
placeholderIndex: typeof placeholder["index"] === "number" ? placeholder["index"] : undefined,
|
|
696
|
+
placeholderParentId: text(placeholder["parentObjectId"]),
|
|
697
|
+
textOffset: offset > 0 ? offset : undefined,
|
|
698
|
+
}),
|
|
699
|
+
};
|
|
700
|
+
const rest = leaf.text.slice(offset);
|
|
701
|
+
if (!rest) {
|
|
702
|
+
const size = jsonBytes(row) + 1;
|
|
703
|
+
if (used + size > room)
|
|
704
|
+
break;
|
|
705
|
+
elements.push(row);
|
|
706
|
+
used += size;
|
|
707
|
+
index += 1;
|
|
708
|
+
offset = 0;
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
// The row as it would be if its text were cut, so the text's share is
|
|
712
|
+
// measured against everything else the row carries.
|
|
713
|
+
const shell = { ...row, text: "", truncated: true, textLength: leaf.text.length };
|
|
714
|
+
const available = room - used - 1 - (jsonBytes(shell) - 2);
|
|
715
|
+
const cut = available > 2
|
|
716
|
+
? prefixWithin(rest, available, (dropped) => `\n[… ${dropped} more characters continue on the next page]`)
|
|
717
|
+
: { text: "", kept: 0 };
|
|
718
|
+
if (cut.kept === 0 && elements.length > 0)
|
|
719
|
+
break;
|
|
720
|
+
if (cut.kept === rest.length) {
|
|
721
|
+
const whole = { ...row, text: rest };
|
|
722
|
+
used += jsonBytes(whole) + 1;
|
|
723
|
+
elements.push(whole);
|
|
724
|
+
index += 1;
|
|
725
|
+
offset = 0;
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (cut.kept === 0) {
|
|
729
|
+
throw new ConnectorCallError("connector_call_failed", "get_page could not fit any of an element's text in one result.", { retryable: false });
|
|
730
|
+
}
|
|
731
|
+
elements.push({ ...shell, text: cut.text });
|
|
732
|
+
return { elements, next: { i: index, o: offset + cut.kept } };
|
|
733
|
+
}
|
|
734
|
+
return { elements, next: index < leaves.length ? { i: index, o: offset } : undefined };
|
|
735
|
+
}
|
|
736
|
+
// --- Writing ----------------------------------------------------------------------
|
|
737
|
+
/**
|
|
738
|
+
* One batchUpdate. A write that names `requiredRevisionId` is revision
|
|
739
|
+
* guarded: the shared layer turns Slides' precondition refusal into a
|
|
740
|
+
* `conflict`, so a deck someone changed since the read is re-read, not
|
|
741
|
+
* retried. Nothing was applied in that case: a batch Slides refuses applies
|
|
742
|
+
* none of its requests. One it accepts applies its deck changes together,
|
|
743
|
+
* and its comment changes apart (see {@link commentSaveFields}).
|
|
744
|
+
*/
|
|
745
|
+
function batchUpdate(client, presentationId, requests, requiredRevisionId) {
|
|
746
|
+
return Effect.gen(function* () {
|
|
747
|
+
return asRecord(yield* client.json({
|
|
748
|
+
method: "POST",
|
|
749
|
+
template: "/presentations/{presentationId}:batchUpdate",
|
|
750
|
+
path: `/presentations/${encodeURIComponent(presentationId)}:batchUpdate`,
|
|
751
|
+
body: compact({
|
|
752
|
+
requests,
|
|
753
|
+
writeControl: requiredRevisionId === undefined ? undefined : { requiredRevisionId },
|
|
754
|
+
}),
|
|
755
|
+
}, { revisionGuarded: requiredRevisionId !== undefined }));
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* A create whose outcome Google left uncertain — sent with no answer back, a
|
|
760
|
+
* redirect, a server failure after it was sent, or a 2xx whose answer broke —
|
|
761
|
+
* keeps the shared verdict and adds what to look for before creating again,
|
|
762
|
+
* since a second create makes a second deck or slide. Only a request that
|
|
763
|
+
* never left, or that Google explicitly refused, is known not to have
|
|
764
|
+
* applied, so every other dispatched outcome gets the advice, whatever phase
|
|
765
|
+
* name the shared layer gives it. The advice claims nothing about whether it
|
|
766
|
+
* applied; the shared verdict says that, and says "probably applied" only
|
|
767
|
+
* after a recorded 2xx. Any other failure passes through.
|
|
768
|
+
*/
|
|
769
|
+
function afterUnknownCreate(connection, failure, lookFor) {
|
|
770
|
+
const outcome = googleOutcomeOf(failure);
|
|
771
|
+
return outcome?.dispatched === true && outcome.phase !== "refused"
|
|
772
|
+
? googleFailureMessage(connection, failure, (message) => `${message} ${lookFor}`)
|
|
773
|
+
: Effect.fail(failure);
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* A field that names something to address later: objectId, commentId,
|
|
777
|
+
* postId, anchorId, … — and a post author's `user`, the `users/{id}` that
|
|
778
|
+
* identifies who wrote it.
|
|
779
|
+
*/
|
|
780
|
+
const ID_KEY = /^(id|user|[a-z][A-Za-z0-9]*Id)$/;
|
|
781
|
+
/**
|
|
782
|
+
* An id as copied from Google into a result: whole, or not at all. A cut id
|
|
783
|
+
* names nothing, so one too long to return is dropped and flagged instead.
|
|
784
|
+
*/
|
|
785
|
+
function wholeId(value) {
|
|
786
|
+
return typeof value === "string" && value !== "" && jsonBytes(value) <= MAX_ID_BYTES ? value : undefined;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* `value` keeping, at any depth, every id whole, every number and boolean,
|
|
790
|
+
* and — in "short" mode — strings short enough to read at a glance; every
|
|
791
|
+
* path left out goes to `cut`. Every array item is walked in both modes, so
|
|
792
|
+
* no id is dropped to save space — the result's budget, which counts what
|
|
793
|
+
* does not fit, handles that; past the first few items, "short" mode keeps
|
|
794
|
+
* only their ids.
|
|
795
|
+
* Nesting deeper than any reply Slides sends is cut where it starts.
|
|
796
|
+
*/
|
|
797
|
+
function pruned(value, key, path, mode, cut, depth) {
|
|
798
|
+
if (value === null || typeof value === "number" || typeof value === "boolean")
|
|
799
|
+
return value;
|
|
800
|
+
if (typeof value === "string") {
|
|
801
|
+
const keep = ID_KEY.test(key)
|
|
802
|
+
? wholeId(value) !== undefined
|
|
803
|
+
: mode === "short" && jsonBytes(value) <= MAX_NAME_BYTES;
|
|
804
|
+
if (keep)
|
|
805
|
+
return value;
|
|
806
|
+
cut.push(path);
|
|
807
|
+
return undefined;
|
|
808
|
+
}
|
|
809
|
+
if (typeof value !== "object" || depth >= MAX_REPLY_DEPTH) {
|
|
810
|
+
cut.push(path);
|
|
811
|
+
return undefined;
|
|
812
|
+
}
|
|
813
|
+
if (Array.isArray(value)) {
|
|
814
|
+
// Every item is walked; past the first few, only for ids, whose cut
|
|
815
|
+
// fields are summed up in one entry rather than one per item.
|
|
816
|
+
if (mode === "short" && value.length > MAX_REPLY_ITEMS) {
|
|
817
|
+
cut.push(`${path}[${MAX_REPLY_ITEMS}…${value.length - 1}] (ids only)`);
|
|
818
|
+
}
|
|
819
|
+
return value.map((item, index) => {
|
|
820
|
+
const itemMode = mode === "short" && index >= MAX_REPLY_ITEMS ? "ids" : mode;
|
|
821
|
+
const sink = itemMode === mode ? cut : [];
|
|
822
|
+
return pruned(item, key, `${path}[${index}]`, itemMode, sink, depth + 1) ?? null;
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
const out = {};
|
|
826
|
+
for (const [field, item] of Object.entries(value)) {
|
|
827
|
+
const kept = pruned(item, field, path ? `${path}.${field}` : field, mode, cut, depth + 1);
|
|
828
|
+
if (kept !== undefined)
|
|
829
|
+
out[field] = kept;
|
|
830
|
+
}
|
|
831
|
+
return out;
|
|
832
|
+
}
|
|
833
|
+
/** The paths a summary left out, bounded: the first ten, then a count. */
|
|
834
|
+
function cutList(ctx, cut) {
|
|
835
|
+
const named = cut.slice(0, 10).map((path) => clampText(ctx, path, 160, () => "…"));
|
|
836
|
+
return cut.length > named.length ? [...named, `(${cut.length - named.length} more)`] : named;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* One batchUpdate reply, small enough to return. Slides' replies are tiny —
|
|
840
|
+
* a new object's id, a count — and pass whole. One that is not is walked:
|
|
841
|
+
* every id at every depth is kept whole, then short fields while they fit,
|
|
842
|
+
* and every path left out is named in `cut`, so nothing is dropped without a
|
|
843
|
+
* word.
|
|
844
|
+
*/
|
|
845
|
+
function summarizeReply(ctx, reply) {
|
|
846
|
+
if (jsonBytes(reply) <= MAX_REPLY_BYTES)
|
|
847
|
+
return reply;
|
|
848
|
+
let summary = {};
|
|
849
|
+
for (const mode of ["short", "ids"]) {
|
|
850
|
+
const cut = [];
|
|
851
|
+
summary = { ...asRecord(pruned(reply, "", "", mode, cut, 0)), cut: cutList(ctx, cut) };
|
|
852
|
+
if (jsonBytes(summary) <= MAX_REPLY_BYTES)
|
|
853
|
+
return summary;
|
|
854
|
+
}
|
|
855
|
+
// Ids alone over the per-reply share: still returned, since what a write
|
|
856
|
+
// created is the one thing a caller cannot ask for again.
|
|
857
|
+
return summary;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* The most one host result may carry into an execute_code program
|
|
861
|
+
* (`MAX_HOST_RESULT_BYTES` in `src/executors/quickjs-runtime.ts`). A write
|
|
862
|
+
* result past the provider's own 192 KiB budget but under this still reaches
|
|
863
|
+
* a program; past it, only a direct call does, paging with connecta.result.
|
|
864
|
+
*/
|
|
865
|
+
const BRIDGE_BYTES = 256 * 1024;
|
|
866
|
+
/**
|
|
867
|
+
* The most a raw batch's result may be when the ids its replies carry
|
|
868
|
+
* outgrow one result. Every id is still returned, never dropped: a direct
|
|
869
|
+
* call stashes a result this size and pages it with connecta.result (the stash
|
|
870
|
+
* holds 8 MiB by default). Past this, the ids are not returned at all, and
|
|
871
|
+
* the result says where to read them.
|
|
872
|
+
*/
|
|
873
|
+
const MAX_IDS_RESULT_BYTES = 4 * 1024 * 1024;
|
|
874
|
+
/**
|
|
875
|
+
* A reply at its smallest, however small it already is: every id at every
|
|
876
|
+
* depth with its numbers and booleans, and how many fields were left out.
|
|
877
|
+
* A reply with nothing but those passes unchanged.
|
|
878
|
+
*/
|
|
879
|
+
function idsOnlyReply(reply) {
|
|
880
|
+
const cut = [];
|
|
881
|
+
const ids = asRecord(pruned(reply, "", "", "ids", cut, 0));
|
|
882
|
+
return cut.length === 0 ? reply : { ...ids, cut: [`(${cut.length} fields left out; ids only)`] };
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* A batch's replies, in order, after `used` bytes: ids first, then text.
|
|
886
|
+
* Every reply starts at its ids alone, and replies are then given their
|
|
887
|
+
* fuller summaries, in order, while the provider's 192 KiB budget holds
|
|
888
|
+
* them, so text is cut before any id is. Should the ids alone outgrow that
|
|
889
|
+
* budget, they are all returned anyway (`oversized`) and no text is; nothing
|
|
890
|
+
* a write created is ever dropped to save space.
|
|
891
|
+
*/
|
|
892
|
+
function boundedReplies(ctx, replies, used) {
|
|
893
|
+
const out = replies.map((reply) => idsOnlyReply(asRecord(reply)));
|
|
894
|
+
const sizes = out.map((reply) => jsonBytes(reply) + 1);
|
|
895
|
+
let total = used + 2 + sizes.reduce((sum, size) => sum + size, 0);
|
|
896
|
+
if (total > RESULT_BUDGET_BYTES)
|
|
897
|
+
return { replies: out, oversized: true };
|
|
898
|
+
for (const [index, reply] of replies.entries()) {
|
|
899
|
+
const fuller = summarizeReply(ctx, asRecord(reply));
|
|
900
|
+
const size = jsonBytes(fuller) + 1;
|
|
901
|
+
if (total - sizes[index] + size > RESULT_BUDGET_BYTES)
|
|
902
|
+
continue;
|
|
903
|
+
total += size - sizes[index];
|
|
904
|
+
out[index] = fuller;
|
|
905
|
+
sizes[index] = size;
|
|
906
|
+
}
|
|
907
|
+
return { replies: out, oversized: false };
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* The revision a write left the deck at, for the next write to name — whole,
|
|
911
|
+
* or flagged as not shown when too long to copy, never cut.
|
|
912
|
+
*/
|
|
913
|
+
function revisionFields(response) {
|
|
914
|
+
const revision = asRecord(response["writeControl"])["requiredRevisionId"];
|
|
915
|
+
const whole = wholeId(revision);
|
|
916
|
+
if (whole !== undefined)
|
|
917
|
+
return { revisionId: whole };
|
|
918
|
+
return typeof revision === "string" && revision !== "" ? { revisionIdNotShown: true } : {};
|
|
919
|
+
}
|
|
920
|
+
// --- Comments ---------------------------------------------------------------------
|
|
921
|
+
/**
|
|
922
|
+
* What a batch's comment changes came to, as a result field. A batch with no
|
|
923
|
+
* comment request is settled unless Slides says otherwise. One with any is
|
|
924
|
+
* settled only on `ALL_SAVED`: anything else — a failure, or no state at all —
|
|
925
|
+
* is surfaced by name, since its deck changes may have applied while its
|
|
926
|
+
* comments did not, and the batch is then neither all nor none.
|
|
927
|
+
*/
|
|
928
|
+
function commentSaveFields(ctx, response, sentComments) {
|
|
929
|
+
const state = text(response["commentUpdateState"]);
|
|
930
|
+
const settled = sentComments ? state === "ALL_SAVED" : state === undefined || COMMENTS_SETTLED.has(state);
|
|
931
|
+
if (settled)
|
|
932
|
+
return {};
|
|
933
|
+
return {
|
|
934
|
+
commentUpdateState: label(ctx, state ?? COMMENT_STATE_UNSPECIFIED, MAX_NAME_BYTES, "Slides sent a longer state") ?? "",
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
/** Comment actions that change nothing, and are left unsaid. */
|
|
938
|
+
const QUIET_ACTIONS = new Set(["COMMENT_ACTION_TYPE_UNSPECIFIED", "NO_COMMENT_ACTION_CHANGE"]);
|
|
939
|
+
/**
|
|
940
|
+
* A post as returned: its id and its author's `users/{id}` whole — left out
|
|
941
|
+
* and flagged on the thread's `idsNotShown`, never cut, should either be too
|
|
942
|
+
* long to copy — beside the display name, which is not an identity.
|
|
943
|
+
* `contentHtml` is the same text rendered, and is left behind.
|
|
944
|
+
*/
|
|
945
|
+
function postPart(ctx, value) {
|
|
946
|
+
const post = asRecord(value);
|
|
947
|
+
const author = asRecord(post["author"]);
|
|
948
|
+
const postId = wholeId(post["postId"]);
|
|
949
|
+
const user = wholeId(author["user"]);
|
|
950
|
+
const shownAuthor = compact({
|
|
951
|
+
user,
|
|
952
|
+
displayName: label(ctx, text(author["displayName"]), MAX_NAME_BYTES, "Slides shows the whole name"),
|
|
953
|
+
me: author["me"] === true || undefined,
|
|
954
|
+
anonymous: author["anonymous"] === true || undefined,
|
|
955
|
+
});
|
|
956
|
+
const action = text(post["commentAction"]);
|
|
957
|
+
const whole = "Slides shows all of it";
|
|
958
|
+
return {
|
|
959
|
+
row: compact({
|
|
960
|
+
postId,
|
|
961
|
+
author: Object.keys(shownAuthor).length > 0 ? shownAuthor : undefined,
|
|
962
|
+
commentAction: action && !QUIET_ACTIONS.has(action) ? label(ctx, action, MAX_NAME_BYTES, whole) : undefined,
|
|
963
|
+
assigneeEmail: label(ctx, text(post["assigneeEmail"]), MAX_COMMENT_BYTES + 64, whole),
|
|
964
|
+
createTime: label(ctx, text(post["createTime"]), MAX_NAME_BYTES, whole),
|
|
965
|
+
updateTime: label(ctx, text(post["updateTime"]), MAX_NAME_BYTES, whole),
|
|
966
|
+
deleted: post["deleted"] === true || undefined,
|
|
967
|
+
// Slides gives an imported post no author id; this says why.
|
|
968
|
+
imported: post["fromImportedPresentation"] === true || undefined,
|
|
969
|
+
}),
|
|
970
|
+
// Paged from offsets, so replaced before any cut (see readingOrder).
|
|
971
|
+
content: typeof post["content"] === "string" ? redactBeforeCut(ctx, post["content"]) : "",
|
|
972
|
+
idsNotShown: (text(post["postId"]) !== undefined && postId === undefined) ||
|
|
973
|
+
(text(author["user"]) !== undefined && user === undefined),
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
/** A post in a write result: its text bounded, never its ids. */
|
|
977
|
+
function writtenPost(ctx, value, maxBytes = MAX_POST_BYTES) {
|
|
978
|
+
const post = postPart(ctx, value);
|
|
979
|
+
return compact({
|
|
980
|
+
...post.row,
|
|
981
|
+
content: label(ctx, text(post.content), maxBytes, "list_comments has the whole post"),
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
function anchorPlaces(pages) {
|
|
985
|
+
const places = new Map();
|
|
986
|
+
for (const page of pages) {
|
|
987
|
+
const pageId = text(page["objectId"]);
|
|
988
|
+
for (const anchor of asArray(page["commentAnchors"]).map(asRecord)) {
|
|
989
|
+
const anchorId = text(anchor["anchorId"]);
|
|
990
|
+
if (anchorId === undefined)
|
|
991
|
+
continue;
|
|
992
|
+
let place = places.get(anchorId);
|
|
993
|
+
if (!place)
|
|
994
|
+
places.set(anchorId, (place = { pages: new Set(), objects: new Set() }));
|
|
995
|
+
if (pageId)
|
|
996
|
+
place.pages.add(pageId);
|
|
997
|
+
for (const object of asArray(anchor["objectAnchors"]).map(asRecord)) {
|
|
998
|
+
// An anchor on the page itself names the page, already listed.
|
|
999
|
+
const objectId = text(object["objectId"]);
|
|
1000
|
+
if (objectId && objectId !== pageId)
|
|
1001
|
+
place.objects.add(objectId);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return places;
|
|
1006
|
+
}
|
|
1007
|
+
/** Every page a deck read returns anchors on: slides, notes pages, layouts, masters, the notes master. */
|
|
1008
|
+
function commentPages(presentation) {
|
|
1009
|
+
const slides = asArray(presentation["slides"]).map(asRecord);
|
|
1010
|
+
return [
|
|
1011
|
+
...slides,
|
|
1012
|
+
...slides.map((slide) => asRecord(asRecord(slide["slideProperties"])["notesPage"])),
|
|
1013
|
+
...asArray(presentation["layouts"]).map(asRecord),
|
|
1014
|
+
...asArray(presentation["masters"]).map(asRecord),
|
|
1015
|
+
asRecord(presentation["notesMaster"]),
|
|
1016
|
+
];
|
|
1017
|
+
}
|
|
1018
|
+
function threadParts(ctx, value, places) {
|
|
1019
|
+
const thread = asRecord(value);
|
|
1020
|
+
const commentId = wholeId(thread["commentId"]);
|
|
1021
|
+
const anchorId = wholeId(thread["anchorId"]);
|
|
1022
|
+
const place = anchorId === undefined ? undefined : places.get(anchorId);
|
|
1023
|
+
const ids = (values) => [...(values ?? [])].map(wholeId).filter((id) => id !== undefined);
|
|
1024
|
+
const pageIds = ids(place?.pages);
|
|
1025
|
+
const objectIds = ids(place?.objects);
|
|
1026
|
+
const replies = asArray(thread["replies"]).map((reply) => postPart(ctx, reply));
|
|
1027
|
+
const head = thread["headPost"] && typeof thread["headPost"] === "object" ? postPart(ctx, thread["headPost"]) : undefined;
|
|
1028
|
+
return {
|
|
1029
|
+
row: compact({
|
|
1030
|
+
commentId,
|
|
1031
|
+
anchorId,
|
|
1032
|
+
status: label(ctx, text(thread["status"]), MAX_NAME_BYTES, "Slides shows the status"),
|
|
1033
|
+
// Every id, as everywhere: Slides anchors a group's comment to at most
|
|
1034
|
+
// 100 elements, and the page's budget holds them.
|
|
1035
|
+
pageObjectIds: pageIds.length > 0 ? pageIds : undefined,
|
|
1036
|
+
objectIds: objectIds.length > 0 ? objectIds : undefined,
|
|
1037
|
+
replyCount: replies.length,
|
|
1038
|
+
idsNotShown: (text(thread["commentId"]) !== undefined && commentId === undefined) ||
|
|
1039
|
+
(text(thread["anchorId"]) !== undefined && anchorId === undefined) ||
|
|
1040
|
+
head?.idsNotShown ||
|
|
1041
|
+
replies.some((reply) => reply.idsNotShown) ||
|
|
1042
|
+
undefined,
|
|
1043
|
+
}),
|
|
1044
|
+
quote: typeof thread["plainTextQuote"] === "string" ? redactBeforeCut(ctx, thread["plainTextQuote"]) : "",
|
|
1045
|
+
head,
|
|
1046
|
+
replies,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* A post row carrying `content` from `offset`, and, when cut — which also
|
|
1051
|
+
* marks its thread `truncated` — the whole length in `contentLength`.
|
|
1052
|
+
*/
|
|
1053
|
+
function postRow(post, content, offset, cutFrom) {
|
|
1054
|
+
return compact({
|
|
1055
|
+
...post.row,
|
|
1056
|
+
content: content === "" && cutFrom === undefined ? undefined : content,
|
|
1057
|
+
contentOffset: offset > 0 ? offset : undefined,
|
|
1058
|
+
contentLength: cutFrom,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Part `j` of a thread placed into its row with `content` from `offset`:
|
|
1063
|
+
* 0 the quote, 1 the head post, 2 on each reply in turn. `undefined` for a
|
|
1064
|
+
* part the thread does not have. `cutFrom`, the part's whole length, marks it
|
|
1065
|
+
* cut.
|
|
1066
|
+
*/
|
|
1067
|
+
function placePart(thread, row, j, content, offset, cutFrom) {
|
|
1068
|
+
if (j === 0) {
|
|
1069
|
+
if (thread.quote === "")
|
|
1070
|
+
return undefined;
|
|
1071
|
+
return compact({ ...row, quote: content, quoteOffset: offset > 0 ? offset : undefined, quoteLength: cutFrom });
|
|
1072
|
+
}
|
|
1073
|
+
if (j === 1)
|
|
1074
|
+
return thread.head ? { ...row, headPost: postRow(thread.head, content, offset, cutFrom) } : undefined;
|
|
1075
|
+
const reply = thread.replies[j - 2];
|
|
1076
|
+
if (!reply)
|
|
1077
|
+
return undefined;
|
|
1078
|
+
const replies = asArray(row["replies"]);
|
|
1079
|
+
return compact({
|
|
1080
|
+
...row,
|
|
1081
|
+
// A row that continues a thread from a previous page starts its replies past 0.
|
|
1082
|
+
repliesOffset: replies.length === 0 && j > 2 ? j - 2 : row["repliesOffset"],
|
|
1083
|
+
replies: [...replies, postRow(reply, content, offset, cutFrom)],
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
function partText(thread, j) {
|
|
1087
|
+
if (j === 0)
|
|
1088
|
+
return thread.quote;
|
|
1089
|
+
if (j === 1)
|
|
1090
|
+
return thread.head?.content ?? "";
|
|
1091
|
+
return thread.replies[j - 2]?.content ?? "";
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Thread rows from thread `i`, part `j`, text offset `o`, under `limit` rows
|
|
1095
|
+
* and `room` bytes. Every thread's ids come whole on every row it appears
|
|
1096
|
+
* on; a quote or post too long for what is left is cut at a character, and
|
|
1097
|
+
* the next page continues that thread (`continued`) from the cut — the same
|
|
1098
|
+
* way get_page continues an element's text.
|
|
1099
|
+
*/
|
|
1100
|
+
function threadRows(threads, start, limit, room) {
|
|
1101
|
+
const out = [];
|
|
1102
|
+
let used = 2;
|
|
1103
|
+
let { i, j, o } = start;
|
|
1104
|
+
const fits = (row) => used + jsonBytes(row) + 1 <= room;
|
|
1105
|
+
const unfit = () => new ConnectorCallError("connector_call_failed", "list_comments could not fit any of a comment thread in one result; slides_api_read pages it in JSON chunks.", {
|
|
1106
|
+
retryable: false,
|
|
1107
|
+
});
|
|
1108
|
+
while (i < threads.length && out.length < limit) {
|
|
1109
|
+
const thread = threads[i];
|
|
1110
|
+
// Where this row starts: a row that places nothing leaves the next page
|
|
1111
|
+
// to start here, so it is not called continued for parts it skipped.
|
|
1112
|
+
const from = { i, j, o };
|
|
1113
|
+
let row = compact({ ...thread.row, continued: j > 0 || o > 0 ? true : undefined });
|
|
1114
|
+
if (!fits(row)) {
|
|
1115
|
+
if (out.length > 0)
|
|
1116
|
+
break;
|
|
1117
|
+
throw unfit();
|
|
1118
|
+
}
|
|
1119
|
+
let placed = false;
|
|
1120
|
+
for (const parts = thread.replies.length + 2; j < parts; j += 1, o = 0) {
|
|
1121
|
+
const whole = partText(thread, j);
|
|
1122
|
+
const rest = whole.slice(o);
|
|
1123
|
+
const next = placePart(thread, row, j, rest, o);
|
|
1124
|
+
if (next === undefined)
|
|
1125
|
+
continue;
|
|
1126
|
+
if (fits(next)) {
|
|
1127
|
+
row = next;
|
|
1128
|
+
placed = true;
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
// The row as it would be with this part cut, so the text's share is
|
|
1132
|
+
// measured against everything else the row carries.
|
|
1133
|
+
const shell = { ...placePart(thread, row, j, "", o, whole.length), truncated: true };
|
|
1134
|
+
const available = room - used - 1 - (jsonBytes(shell) - 2);
|
|
1135
|
+
const cut = available > 2 && rest !== ""
|
|
1136
|
+
? prefixWithin(rest, available, (dropped) => `\n[… ${dropped} more characters continue on the next page]`)
|
|
1137
|
+
: { text: "", kept: 0 };
|
|
1138
|
+
if (cut.kept === 0) {
|
|
1139
|
+
if (!placed) {
|
|
1140
|
+
if (out.length > 0)
|
|
1141
|
+
return { threads: out, next: from };
|
|
1142
|
+
throw unfit();
|
|
1143
|
+
}
|
|
1144
|
+
out.push({ ...row, truncated: true });
|
|
1145
|
+
return { threads: out, next: { i, j, o } };
|
|
1146
|
+
}
|
|
1147
|
+
out.push({ ...placePart(thread, row, j, cut.text, o, whole.length), truncated: true });
|
|
1148
|
+
return { threads: out, next: { i, j, o: o + cut.kept } };
|
|
1149
|
+
}
|
|
1150
|
+
out.push(row);
|
|
1151
|
+
used += jsonBytes(row) + 1;
|
|
1152
|
+
i += 1;
|
|
1153
|
+
j = 0;
|
|
1154
|
+
o = 0;
|
|
1155
|
+
}
|
|
1156
|
+
return { threads: out, next: i < threads.length ? { i, j, o } : undefined };
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* The state a list_comments cursor binds to: the revision, the threads
|
|
1160
|
+
* themselves, and the anchors that place them. Slides saves comments apart
|
|
1161
|
+
* from the deck, so a reply added between pages need not move the revision,
|
|
1162
|
+
* and an anchor moved from one element to another need not change a thread;
|
|
1163
|
+
* binding to the exact JSON of both makes either a `conflict`, never a page
|
|
1164
|
+
* that skips, repeats, or places a thread where it no longer is.
|
|
1165
|
+
*/
|
|
1166
|
+
function commentsState(ctx, revisionId, comments, pages) {
|
|
1167
|
+
return stateOf(ctx, undefined, () => [
|
|
1168
|
+
text(revisionId) ?? null,
|
|
1169
|
+
comments,
|
|
1170
|
+
pages.map((page) => [page["objectId"] ?? null, page["commentAnchors"] ?? null]),
|
|
1171
|
+
]);
|
|
1172
|
+
}
|
|
1173
|
+
/** A 403 on a comment read: what it most likely means, beside Google's words. */
|
|
1174
|
+
function commentAccess(connection, failure) {
|
|
1175
|
+
return googleOutcomeOf(failure)?.status === 403
|
|
1176
|
+
? googleFailureMessage(connection, failure, (message) => `${message} Reading comments needs comment access: an account that may only view the deck cannot read them.`)
|
|
1177
|
+
: Effect.fail(failure);
|
|
1178
|
+
}
|
|
1179
|
+
/** A comment's text, refused locally past Slides' own cap rather than sent to fail. */
|
|
1180
|
+
function commentText(value, field) {
|
|
1181
|
+
if (value === undefined)
|
|
1182
|
+
return undefined;
|
|
1183
|
+
const content = String(value);
|
|
1184
|
+
const bytes = new TextEncoder().encode(content).length;
|
|
1185
|
+
if (bytes > MAX_COMMENT_BYTES) {
|
|
1186
|
+
throw new ConnectorCallError("invalid_args", `${field} is ${bytes} UTF-8 bytes; Slides accepts at most ${MAX_COMMENT_BYTES}. Nothing was sent.`);
|
|
1187
|
+
}
|
|
1188
|
+
return content;
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* One comment request as its own batch, and what its result always carries:
|
|
1192
|
+
* the deck, the revision it left, and — whenever Slides does not confirm the
|
|
1193
|
+
* comment saved — the state it reported and what to do about it.
|
|
1194
|
+
*/
|
|
1195
|
+
function commentWrite(client, ctx, presentationId, request, requiredRevisionId) {
|
|
1196
|
+
return Effect.gen(function* () {
|
|
1197
|
+
const response = yield* batchUpdate(client, presentationId, [request], requiredRevisionId);
|
|
1198
|
+
const state = commentSaveFields(ctx, response, true);
|
|
1199
|
+
return {
|
|
1200
|
+
reply: asRecord(asArray(response["replies"])[0]),
|
|
1201
|
+
header: compact({ presentationId, ...revisionFields(response), ...state }),
|
|
1202
|
+
saved: state.commentUpdateState === undefined,
|
|
1203
|
+
};
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* One post added to a thread — a reply, or a status or assignee change —
|
|
1208
|
+
* and its result: the new post's id and author whole, or the write's comment
|
|
1209
|
+
* state when Slides does not confirm it saved.
|
|
1210
|
+
*/
|
|
1211
|
+
function postToThread(connection, client, ctx, tool, args, post) {
|
|
1212
|
+
return Effect.gen(function* () {
|
|
1213
|
+
const written = yield* commentWrite(client, ctx, args["presentationId"], {
|
|
1214
|
+
addCommentReply: { commentId: args["commentId"], post: compact(post) },
|
|
1215
|
+
}).pipe(Effect.catch((failure) => afterUnknownCreate(connection, failure, "Read the thread with list_comments and look for the post before sending it again; a second one is a second post.")));
|
|
1216
|
+
const reread = "read the thread with list_comments to find the post";
|
|
1217
|
+
const created = asRecord(written.reply["addCommentReply"])["post"];
|
|
1218
|
+
if (wholeId(asRecord(created)["postId"]) === undefined && written.saved) {
|
|
1219
|
+
throw appliedButUnreadable(tool, "Google returned no usable id for the new post", reread);
|
|
1220
|
+
}
|
|
1221
|
+
return appliedDeliverable(compact({
|
|
1222
|
+
...written.header,
|
|
1223
|
+
commentId: args["commentId"],
|
|
1224
|
+
post: created && typeof created === "object" ? writtenPost(ctx, created) : undefined,
|
|
1225
|
+
idsNotShown: postPart(ctx, created).idsNotShown || undefined,
|
|
1226
|
+
note: written.saved ? undefined : COMMENTS_UNSAVED,
|
|
1227
|
+
}), tool, reread);
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
export function slidesTools({ connection }) {
|
|
1231
|
+
const client = googleClient(connection, SLIDES_INDEX);
|
|
1232
|
+
const readOnly = { readOnlyHint: true };
|
|
1233
|
+
const presentationPath = (id) => `/presentations/${encodeURIComponent(id)}`;
|
|
1234
|
+
const nextPage = (cursor) => cursor === null ? { hasMore: false } : { hasMore: true, nextCursor: cursor };
|
|
1235
|
+
const declarations = [
|
|
1236
|
+
{
|
|
1237
|
+
name: "get_presentation",
|
|
1238
|
+
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.",
|
|
1239
|
+
annotations: readOnly,
|
|
1240
|
+
input: contracts.get_presentation.input,
|
|
1241
|
+
output: contracts.get_presentation.output,
|
|
1242
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1243
|
+
const presentationId = args["presentationId"];
|
|
1244
|
+
const cursor = decodeCursor(args["cursor"], { k: "deck", p: presentationId });
|
|
1245
|
+
const presentation = asRecord(yield* client.json({
|
|
1246
|
+
method: "GET",
|
|
1247
|
+
template: "/presentations/{presentationsId}",
|
|
1248
|
+
path: presentationPath(presentationId),
|
|
1249
|
+
query: { fields: PRESENTATION_FIELDS },
|
|
1250
|
+
}));
|
|
1251
|
+
const all = asArray(presentation["slides"]);
|
|
1252
|
+
const revisionId = presentation["revisionId"];
|
|
1253
|
+
const state = yield* Effect.promise(() => stateOf(ctx, revisionId, () => all.map((slide) => asRecord(slide)["objectId"] ?? null)));
|
|
1254
|
+
assertUnchanged(cursor, state, "deck's slides");
|
|
1255
|
+
const start = cursor?.i ?? 0;
|
|
1256
|
+
const limit = typeof args["limit"] === "number" ? args["limit"] : DEFAULT_PAGE_SIZE;
|
|
1257
|
+
const maxChars = typeof args["maxCharsPerSlide"] === "number" ? args["maxCharsPerSlide"] : DEFAULT_SLIDE_CHARS;
|
|
1258
|
+
const deck = { presentationId, revisionId, layouts: layoutNames(ctx, presentation) };
|
|
1259
|
+
const pageSize = asRecord(presentation["pageSize"]);
|
|
1260
|
+
const width = dimension(pageSize["width"]);
|
|
1261
|
+
const height = dimension(pageSize["height"]);
|
|
1262
|
+
// The first projected page previews the layouts, bounded; the rest
|
|
1263
|
+
// continue in list_layouts from layoutsCursor.
|
|
1264
|
+
let preview = {};
|
|
1265
|
+
if (start === 0) {
|
|
1266
|
+
const rows = layoutRows(ctx, presentation);
|
|
1267
|
+
const shown = rowsWithin(rows, 0, MAX_ROWS, MAX_LAYOUT_PREVIEW_BYTES);
|
|
1268
|
+
preview = compact({
|
|
1269
|
+
layouts: shown.rows,
|
|
1270
|
+
layoutsNotShown: shown.next < rows.length ? rows.length - shown.next : undefined,
|
|
1271
|
+
layoutsCursor: shown.next < rows.length
|
|
1272
|
+
? encodeCursor(ctx, {
|
|
1273
|
+
k: "layouts",
|
|
1274
|
+
p: presentationId,
|
|
1275
|
+
s: yield* Effect.promise(() => stateOf(ctx, revisionId, () => layoutFingerprint(rows))),
|
|
1276
|
+
i: shown.next,
|
|
1277
|
+
})
|
|
1278
|
+
: undefined,
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
const header = compact({
|
|
1282
|
+
presentationId: text(presentation["presentationId"]) ?? presentationId,
|
|
1283
|
+
title: label(ctx, text(presentation["title"]), MAX_TITLE_BYTES, "Drive shows the whole title"),
|
|
1284
|
+
// Only an account that may edit the deck is given one.
|
|
1285
|
+
revisionId: text(revisionId),
|
|
1286
|
+
url: editUrl(presentationId),
|
|
1287
|
+
locale: text(presentation["locale"]),
|
|
1288
|
+
canvasSize: width && height ? { width, height } : undefined,
|
|
1289
|
+
slideCount: all.length,
|
|
1290
|
+
// The one page no slide, layout, or master links to; get_page reads it.
|
|
1291
|
+
notesMasterId: text(asRecord(presentation["notesMaster"])["objectId"]),
|
|
1292
|
+
...preview,
|
|
1293
|
+
});
|
|
1294
|
+
const issue = (i) => encodeCursor(ctx, { k: "deck", p: presentationId, s: state, i });
|
|
1295
|
+
// A page ends at the slide limit or at the byte budget, whichever is
|
|
1296
|
+
// first; the budget counts the header and the longest cursor.
|
|
1297
|
+
let used = jsonBytes({ ...header, slides: [] }) + pageEnvelopeBytes({ k: "deck", p: presentationId, s: state, i: 0 });
|
|
1298
|
+
const slides = [];
|
|
1299
|
+
let next = start;
|
|
1300
|
+
while (next < all.length && slides.length < limit) {
|
|
1301
|
+
const room = RESULT_BUDGET_BYTES - used - 1;
|
|
1302
|
+
let slide = finished(yield* Effect.promise(() => projectSlide(ctx, all[next], next, deck, maxChars)));
|
|
1303
|
+
let size = jsonBytes(slide);
|
|
1304
|
+
if (size > room) {
|
|
1305
|
+
if (slides.length > 0)
|
|
1306
|
+
break;
|
|
1307
|
+
slide = yield* Effect.promise(() => fitSlide(ctx, all[next], next, deck, maxChars, room));
|
|
1308
|
+
size = jsonBytes(slide);
|
|
1309
|
+
}
|
|
1310
|
+
slides.push(slide);
|
|
1311
|
+
used += size + 1;
|
|
1312
|
+
next += 1;
|
|
1313
|
+
}
|
|
1314
|
+
return deliverable({ ...header, slides, page: nextPage(next < all.length ? issue(next) : null) }, "get_presentation");
|
|
1315
|
+
}),
|
|
1316
|
+
},
|
|
1317
|
+
{
|
|
1318
|
+
name: "get_page",
|
|
1319
|
+
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.",
|
|
1320
|
+
annotations: readOnly,
|
|
1321
|
+
input: contracts.get_page.input,
|
|
1322
|
+
output: contracts.get_page.output,
|
|
1323
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1324
|
+
const presentationId = args["presentationId"];
|
|
1325
|
+
const pageObjectId = args["pageObjectId"];
|
|
1326
|
+
const cursor = decodeCursor(args["cursor"], { k: "page", p: presentationId, g: pageObjectId });
|
|
1327
|
+
const page = asRecord(yield* client.json({
|
|
1328
|
+
method: "GET",
|
|
1329
|
+
template: "/presentations/{presentationId}/pages/{pageObjectId}",
|
|
1330
|
+
path: `${presentationPath(presentationId)}/pages/${encodeURIComponent(pageObjectId)}`,
|
|
1331
|
+
query: { fields: PAGE_FIELDS },
|
|
1332
|
+
}));
|
|
1333
|
+
const leaves = readingOrder(ctx, page["pageElements"]);
|
|
1334
|
+
const state = yield* Effect.promise(() => stateOf(ctx, page["revisionId"], () => leafFingerprint(leaves), true));
|
|
1335
|
+
assertUnchanged(cursor, state, "page");
|
|
1336
|
+
const header = compact({
|
|
1337
|
+
presentationId,
|
|
1338
|
+
pageObjectId: text(page["objectId"]) ?? pageObjectId,
|
|
1339
|
+
revisionId: text(page["revisionId"]),
|
|
1340
|
+
...describePage(ctx, page),
|
|
1341
|
+
elementCount: leaves.length,
|
|
1342
|
+
});
|
|
1343
|
+
const room = RESULT_BUDGET_BYTES -
|
|
1344
|
+
jsonBytes({ ...header, elements: [] }) -
|
|
1345
|
+
pageEnvelopeBytes({
|
|
1346
|
+
k: "page",
|
|
1347
|
+
p: presentationId,
|
|
1348
|
+
g: pageObjectId,
|
|
1349
|
+
s: state,
|
|
1350
|
+
i: 0,
|
|
1351
|
+
}) -
|
|
1352
|
+
64;
|
|
1353
|
+
const limit = typeof args["limit"] === "number" ? args["limit"] : DEFAULT_ELEMENTS;
|
|
1354
|
+
const start = { i: cursor?.i ?? 0, o: cursor?.o ?? 0 };
|
|
1355
|
+
const rows = projectedRows(ctx, leaves, start, limit, room);
|
|
1356
|
+
const nextCursor = rows.next
|
|
1357
|
+
? encodeCursor(ctx, {
|
|
1358
|
+
k: "page",
|
|
1359
|
+
p: presentationId,
|
|
1360
|
+
g: pageObjectId,
|
|
1361
|
+
s: state,
|
|
1362
|
+
i: rows.next.i,
|
|
1363
|
+
o: rows.next.o ? rows.next.o : undefined,
|
|
1364
|
+
})
|
|
1365
|
+
: null;
|
|
1366
|
+
return deliverable(compact({
|
|
1367
|
+
...header,
|
|
1368
|
+
properties: rows.properties,
|
|
1369
|
+
propertiesJson: rows.propertiesJson,
|
|
1370
|
+
elements: rows.elements,
|
|
1371
|
+
page: nextPage(nextCursor),
|
|
1372
|
+
}), "get_page");
|
|
1373
|
+
}),
|
|
1374
|
+
},
|
|
1375
|
+
{
|
|
1376
|
+
name: "list_layouts",
|
|
1377
|
+
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.",
|
|
1378
|
+
annotations: readOnly,
|
|
1379
|
+
input: contracts.list_layouts.input,
|
|
1380
|
+
output: contracts.list_layouts.output,
|
|
1381
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1382
|
+
const presentationId = args["presentationId"];
|
|
1383
|
+
const cursor = decodeCursor(args["cursor"], { k: "layouts", p: presentationId });
|
|
1384
|
+
const presentation = asRecord(yield* client.json({
|
|
1385
|
+
method: "GET",
|
|
1386
|
+
template: "/presentations/{presentationsId}",
|
|
1387
|
+
path: presentationPath(presentationId),
|
|
1388
|
+
query: { fields: LAYOUT_LIST_FIELDS },
|
|
1389
|
+
}));
|
|
1390
|
+
const rows = layoutRows(ctx, presentation);
|
|
1391
|
+
const state = yield* Effect.promise(() => stateOf(ctx, presentation["revisionId"], () => layoutFingerprint(rows)));
|
|
1392
|
+
assertUnchanged(cursor, state, "deck's layouts");
|
|
1393
|
+
const header = compact({
|
|
1394
|
+
presentationId,
|
|
1395
|
+
revisionId: text(presentation["revisionId"]),
|
|
1396
|
+
notesMasterId: text(asRecord(presentation["notesMaster"])["objectId"]),
|
|
1397
|
+
total: rows.length,
|
|
1398
|
+
});
|
|
1399
|
+
const room = RESULT_BUDGET_BYTES -
|
|
1400
|
+
jsonBytes({ ...header, layouts: [] }) -
|
|
1401
|
+
pageEnvelopeBytes({ k: "layouts", p: presentationId, s: state, i: 0 });
|
|
1402
|
+
const limit = typeof args["limit"] === "number" ? args["limit"] : DEFAULT_LAYOUTS;
|
|
1403
|
+
const shown = rowsWithin(rows, cursor?.i ?? 0, limit, room);
|
|
1404
|
+
const next = shown.next < rows.length
|
|
1405
|
+
? encodeCursor(ctx, { k: "layouts", p: presentationId, s: state, i: shown.next })
|
|
1406
|
+
: null;
|
|
1407
|
+
return deliverable({ ...header, layouts: shown.rows, page: nextPage(next) }, "list_layouts");
|
|
1408
|
+
}),
|
|
1409
|
+
},
|
|
1410
|
+
{
|
|
1411
|
+
name: "list_comments",
|
|
1412
|
+
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.",
|
|
1413
|
+
annotations: readOnly,
|
|
1414
|
+
input: contracts.list_comments.input,
|
|
1415
|
+
output: contracts.list_comments.output,
|
|
1416
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1417
|
+
const presentationId = args["presentationId"];
|
|
1418
|
+
const pageObjectId = args["pageObjectId"];
|
|
1419
|
+
const scope = pageObjectId === undefined ? {} : { g: pageObjectId };
|
|
1420
|
+
const cursor = decodeCursor(args["cursor"], { k: "comments", p: presentationId, ...scope });
|
|
1421
|
+
const source = asRecord(yield* client
|
|
1422
|
+
.json({
|
|
1423
|
+
method: "GET",
|
|
1424
|
+
template: pageObjectId === undefined
|
|
1425
|
+
? "/presentations/{presentationsId}"
|
|
1426
|
+
: "/presentations/{presentationId}/pages/{pageObjectId}",
|
|
1427
|
+
path: pageObjectId === undefined
|
|
1428
|
+
? presentationPath(presentationId)
|
|
1429
|
+
: `${presentationPath(presentationId)}/pages/${encodeURIComponent(pageObjectId)}`,
|
|
1430
|
+
query: {
|
|
1431
|
+
commentsViewMode: COMMENTS_INCLUDED,
|
|
1432
|
+
fields: pageObjectId === undefined ? COMMENT_DECK_FIELDS : COMMENT_PAGE_FIELDS,
|
|
1433
|
+
},
|
|
1434
|
+
})
|
|
1435
|
+
.pipe(Effect.catch((failure) => commentAccess(connection, failure))));
|
|
1436
|
+
// Asked for INCLUDED, Slides either includes every thread or refuses;
|
|
1437
|
+
// a reply in any other mode could be missing threads, so it is not
|
|
1438
|
+
// passed off as the list.
|
|
1439
|
+
const mode = text(source["commentsViewMode"]);
|
|
1440
|
+
if (mode !== undefined && mode !== COMMENTS_INCLUDED) {
|
|
1441
|
+
throw new ConnectorCallError("connector_call_failed", `Slides answered in comments view mode ${label(ctx, mode, MAX_NAME_BYTES, "")}, not ${COMMENTS_INCLUDED}, so its threads may be incomplete. It only read, so nothing was changed.`, { retryable: false });
|
|
1442
|
+
}
|
|
1443
|
+
const comments = redactBeforeCut(ctx, asArray(source["comments"]));
|
|
1444
|
+
const pages = pageObjectId === undefined ? commentPages(source) : [source];
|
|
1445
|
+
const state = yield* Effect.promise(() => commentsState(ctx, source["revisionId"], comments, pages));
|
|
1446
|
+
assertUnchanged(cursor, state, pageObjectId === undefined ? "deck's comments" : "page's comments");
|
|
1447
|
+
const header = compact({
|
|
1448
|
+
presentationId,
|
|
1449
|
+
pageObjectId,
|
|
1450
|
+
revisionId: text(source["revisionId"]),
|
|
1451
|
+
total: comments.length,
|
|
1452
|
+
});
|
|
1453
|
+
const room = RESULT_BUDGET_BYTES -
|
|
1454
|
+
jsonBytes({ ...header, threads: [] }) -
|
|
1455
|
+
pageEnvelopeBytes({
|
|
1456
|
+
k: "comments",
|
|
1457
|
+
p: presentationId,
|
|
1458
|
+
...scope,
|
|
1459
|
+
s: state,
|
|
1460
|
+
i: 0,
|
|
1461
|
+
j: 0,
|
|
1462
|
+
}) -
|
|
1463
|
+
64;
|
|
1464
|
+
const limit = typeof args["limit"] === "number" ? args["limit"] : DEFAULT_THREADS;
|
|
1465
|
+
let shown;
|
|
1466
|
+
const places = anchorPlaces(pages);
|
|
1467
|
+
const threads = comments.map((thread) => threadParts(ctx, thread, places));
|
|
1468
|
+
shown = threadRows(threads, { i: cursor?.i ?? 0, j: cursor?.j ?? 0, o: cursor?.o ?? 0 }, limit, room);
|
|
1469
|
+
const next = shown.next
|
|
1470
|
+
? encodeCursor(ctx, {
|
|
1471
|
+
k: "comments",
|
|
1472
|
+
p: presentationId,
|
|
1473
|
+
...scope,
|
|
1474
|
+
s: state,
|
|
1475
|
+
i: shown.next.i,
|
|
1476
|
+
j: shown.next.j ? shown.next.j : undefined,
|
|
1477
|
+
o: shown.next.o ? shown.next.o : undefined,
|
|
1478
|
+
})
|
|
1479
|
+
: null;
|
|
1480
|
+
return deliverable({ ...header, threads: shown.threads, page: nextPage(next) }, "list_comments", "pass a smaller limit, or one page's pageObjectId");
|
|
1481
|
+
}),
|
|
1482
|
+
},
|
|
1483
|
+
{
|
|
1484
|
+
name: "get_slide_thumbnail",
|
|
1485
|
+
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.",
|
|
1486
|
+
annotations: readOnly,
|
|
1487
|
+
input: contracts.get_slide_thumbnail.input,
|
|
1488
|
+
output: contracts.get_slide_thumbnail.output,
|
|
1489
|
+
run: (args) => Effect.gen(function* () {
|
|
1490
|
+
const thumbnail = asRecord(yield* client.json({
|
|
1491
|
+
method: "GET",
|
|
1492
|
+
template: "/presentations/{presentationId}/pages/{pageObjectId}/thumbnail",
|
|
1493
|
+
path: `/presentations/${encodeURIComponent(args["presentationId"])}/pages/${encodeURIComponent(args["slideObjectId"])}/thumbnail`,
|
|
1494
|
+
query: {
|
|
1495
|
+
"thumbnailProperties.mimeType": "PNG",
|
|
1496
|
+
"thumbnailProperties.thumbnailSize": args["size"],
|
|
1497
|
+
},
|
|
1498
|
+
}));
|
|
1499
|
+
const contentUrl = text(thumbnail["contentUrl"]) ?? "";
|
|
1500
|
+
// A link is whole or useless, so one too long to pass on is refused,
|
|
1501
|
+
// never cut.
|
|
1502
|
+
if (jsonBytes(contentUrl) > MAX_URL_BYTES) {
|
|
1503
|
+
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 });
|
|
1504
|
+
}
|
|
1505
|
+
return deliverable(compact({
|
|
1506
|
+
contentUrl,
|
|
1507
|
+
width: typeof thumbnail["width"] === "number" ? thumbnail["width"] : undefined,
|
|
1508
|
+
height: typeof thumbnail["height"] === "number" ? thumbnail["height"] : undefined,
|
|
1509
|
+
}), "get_slide_thumbnail", "open the slide in Slides instead");
|
|
1510
|
+
}),
|
|
1511
|
+
},
|
|
1512
|
+
{
|
|
1513
|
+
name: "create_presentation",
|
|
1514
|
+
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.",
|
|
1515
|
+
// Additive: a new file changes nothing that existed. Not read-only, so
|
|
1516
|
+
// program calls require a writable endpoint; other writes use
|
|
1517
|
+
// call_destructive_tool. The host controls approval.
|
|
1518
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1519
|
+
input: contracts.create_presentation.input,
|
|
1520
|
+
output: contracts.create_presentation.output,
|
|
1521
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1522
|
+
const presentation = asRecord(yield* client
|
|
1523
|
+
.json({
|
|
1524
|
+
method: "POST",
|
|
1525
|
+
template: "/presentations",
|
|
1526
|
+
path: "/presentations",
|
|
1527
|
+
body: { title: args["title"] },
|
|
1528
|
+
})
|
|
1529
|
+
.pipe(Effect.catch((failure) => afterUnknownCreate(connection, failure, "Search Drive for a deck with this title before creating it again; a second create makes a second deck."))));
|
|
1530
|
+
const lookInDrive = "search Drive for the deck by its title";
|
|
1531
|
+
const id = wholeId(presentation["presentationId"]);
|
|
1532
|
+
if (id === undefined) {
|
|
1533
|
+
throw appliedButUnreadable("create_presentation", "Google returned no usable id for the new deck", lookInDrive);
|
|
1534
|
+
}
|
|
1535
|
+
const revisionId = presentation["revisionId"];
|
|
1536
|
+
const slideIds = asArray(presentation["slides"]).map((slide) => asRecord(slide)["objectId"]);
|
|
1537
|
+
const shownIds = slideIds.map(wholeId).filter((value) => value !== undefined);
|
|
1538
|
+
return appliedDeliverable(compact({
|
|
1539
|
+
presentationId: id,
|
|
1540
|
+
revisionId: wholeId(revisionId),
|
|
1541
|
+
revisionIdNotShown: revisionId !== undefined && wholeId(revisionId) === undefined ? true : undefined,
|
|
1542
|
+
title: label(ctx, text(presentation["title"]), MAX_TITLE_BYTES, "Drive shows the whole title"),
|
|
1543
|
+
url: editUrl(id),
|
|
1544
|
+
slideObjectIds: shownIds,
|
|
1545
|
+
slideObjectIdsNotShown: shownIds.length < slideIds.length ? slideIds.length - shownIds.length : undefined,
|
|
1546
|
+
}), "create_presentation", lookInDrive);
|
|
1547
|
+
}),
|
|
1548
|
+
},
|
|
1549
|
+
{
|
|
1550
|
+
name: "create_slide",
|
|
1551
|
+
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.",
|
|
1552
|
+
// Additive: later slides move down one place, and nothing is lost.
|
|
1553
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1554
|
+
input: contracts.create_slide.input,
|
|
1555
|
+
output: contracts.create_slide.output,
|
|
1556
|
+
run: (args) => Effect.gen(function* () {
|
|
1557
|
+
if (args["layout"] !== undefined && args["layoutId"] !== undefined) {
|
|
1558
|
+
throw new ConnectorCallError("invalid_args", "Pass layout or layoutId, not both.");
|
|
1559
|
+
}
|
|
1560
|
+
const reference = args["layoutId"] !== undefined
|
|
1561
|
+
? { layoutId: args["layoutId"] }
|
|
1562
|
+
: args["layout"] !== undefined
|
|
1563
|
+
? { predefinedLayout: args["layout"] }
|
|
1564
|
+
: undefined;
|
|
1565
|
+
const response = yield* batchUpdate(client, args["presentationId"], [
|
|
1566
|
+
{ createSlide: compact({ insertionIndex: args["insertionIndex"], slideLayoutReference: reference }) },
|
|
1567
|
+
]).pipe(Effect.catch((failure) => afterUnknownCreate(connection, failure, "Read the deck with get_presentation and look for the new slide before adding another.")));
|
|
1568
|
+
const reread = "read the deck with get_presentation to find the new slide";
|
|
1569
|
+
const slideObjectId = wholeId(asRecord(asRecord(asArray(response["replies"])[0])["createSlide"])["objectId"]);
|
|
1570
|
+
if (slideObjectId === undefined) {
|
|
1571
|
+
throw appliedButUnreadable("create_slide", "Google returned no usable id for the new slide", reread);
|
|
1572
|
+
}
|
|
1573
|
+
return appliedDeliverable(compact({ presentationId: args["presentationId"], ...revisionFields(response), slideObjectId }), "create_slide", reread);
|
|
1574
|
+
}),
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
name: "replace_all_text",
|
|
1578
|
+
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.",
|
|
1579
|
+
// Destructive: the replaced text is gone, everywhere it matched.
|
|
1580
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1581
|
+
input: contracts.replace_all_text.input,
|
|
1582
|
+
output: contracts.replace_all_text.output,
|
|
1583
|
+
run: (args) => Effect.gen(function* () {
|
|
1584
|
+
const replacements = asArray(args["replacements"]).map(asRecord);
|
|
1585
|
+
const response = yield* batchUpdate(client, args["presentationId"], replacements.map((replacement) => ({
|
|
1586
|
+
replaceAllText: compact({
|
|
1587
|
+
containsText: { text: replacement["find"], matchCase: replacement["matchCase"] !== false },
|
|
1588
|
+
replaceText: replacement["replace"],
|
|
1589
|
+
pageObjectIds: args["slideObjectIds"],
|
|
1590
|
+
}),
|
|
1591
|
+
})), args["requiredRevisionId"]);
|
|
1592
|
+
const replies = asArray(response["replies"]);
|
|
1593
|
+
const counted = replacements.map((replacement, index) => {
|
|
1594
|
+
// Proto3 omits a zero count.
|
|
1595
|
+
const changed = asRecord(asRecord(replies[index])["replaceAllText"])["occurrencesChanged"];
|
|
1596
|
+
// Echoed for reading the counts, not in full: fifty long finds
|
|
1597
|
+
// would make a large result of text the caller already has.
|
|
1598
|
+
const find = String(replacement["find"]);
|
|
1599
|
+
return {
|
|
1600
|
+
find: find.length > MAX_ECHO_CHARS ? `${headOf(find, MAX_ECHO_CHARS)}…` : find,
|
|
1601
|
+
occurrencesChanged: typeof changed === "number" ? changed : 0,
|
|
1602
|
+
};
|
|
1603
|
+
});
|
|
1604
|
+
return appliedDeliverable(compact({
|
|
1605
|
+
presentationId: args["presentationId"],
|
|
1606
|
+
...revisionFields(response),
|
|
1607
|
+
occurrencesChanged: counted.reduce((sum, entry) => sum + entry.occurrencesChanged, 0),
|
|
1608
|
+
replacements: counted,
|
|
1609
|
+
}), "replace_all_text", "read the deck with get_presentation to see the replaced text");
|
|
1610
|
+
}),
|
|
1611
|
+
},
|
|
1612
|
+
{
|
|
1613
|
+
name: "create_comment",
|
|
1614
|
+
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.",
|
|
1615
|
+
// Additive: a new thread changes nothing that existed. Not read-only,
|
|
1616
|
+
// so program calls require a writable endpoint; other writes use
|
|
1617
|
+
// call_destructive_tool. The host controls approval.
|
|
1618
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1619
|
+
input: contracts.create_comment.input,
|
|
1620
|
+
output: contracts.create_comment.output,
|
|
1621
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1622
|
+
const content = commentText(args["content"], "content");
|
|
1623
|
+
const objectId = args["objectId"];
|
|
1624
|
+
const range = args["textRange"] === undefined ? undefined : asRecord(args["textRange"]);
|
|
1625
|
+
if (range && Number(range["endIndex"]) <= Number(range["startIndex"])) {
|
|
1626
|
+
throw new ConnectorCallError("invalid_args", "textRange.endIndex must be greater than startIndex. Nothing was sent.");
|
|
1627
|
+
}
|
|
1628
|
+
const textRange = range && {
|
|
1629
|
+
type: "FIXED_RANGE",
|
|
1630
|
+
startIndex: range["startIndex"],
|
|
1631
|
+
endIndex: range["endIndex"],
|
|
1632
|
+
};
|
|
1633
|
+
const cell = args["cell"] === undefined ? undefined : asRecord(args["cell"]);
|
|
1634
|
+
const location = cell && { rowIndex: cell["rowIndex"], columnIndex: cell["columnIndex"] };
|
|
1635
|
+
// One anchor: the object itself, text in a shape, text in a cell, or a whole cell.
|
|
1636
|
+
const anchor = location && textRange
|
|
1637
|
+
? { tableCellTextAnchor: { objectId, cellLocation: location, textRange } }
|
|
1638
|
+
: location
|
|
1639
|
+
? { tableAnchor: { objectId, tableRange: { location, rowSpan: 1, columnSpan: 1 } } }
|
|
1640
|
+
: textRange
|
|
1641
|
+
? { shapeTextAnchor: { objectId, textRange } }
|
|
1642
|
+
: { objectId };
|
|
1643
|
+
const written = yield* commentWrite(client, ctx, args["presentationId"], { insertComment: compact({ ...anchor, content, assigneeEmailAddress: args["assigneeEmail"] }) }, args["requiredRevisionId"]).pipe(Effect.catch((failure) => afterUnknownCreate(connection, failure, "List the deck's comments with list_comments and look for it before commenting again; a second insert makes a second thread.")));
|
|
1644
|
+
const reread = "list the deck's comments with list_comments to find it";
|
|
1645
|
+
const thread = asRecord(asRecord(written.reply["insertComment"])["commentThread"]);
|
|
1646
|
+
const commentId = wholeId(thread["commentId"]);
|
|
1647
|
+
if (commentId === undefined && written.saved) {
|
|
1648
|
+
throw appliedButUnreadable("create_comment", "Google returned no usable id for the new comment", reread);
|
|
1649
|
+
}
|
|
1650
|
+
const parts = threadParts(ctx, thread, new Map());
|
|
1651
|
+
const replies = asArray(thread["replies"]);
|
|
1652
|
+
return appliedDeliverable(compact({
|
|
1653
|
+
...written.header,
|
|
1654
|
+
commentId,
|
|
1655
|
+
anchorId: wholeId(thread["anchorId"]),
|
|
1656
|
+
status: parts.row["status"],
|
|
1657
|
+
quote: label(ctx, text(parts.quote), MAX_POST_BYTES, "list_comments has the whole quote"),
|
|
1658
|
+
headPost: thread["headPost"] ? writtenPost(ctx, thread["headPost"]) : undefined,
|
|
1659
|
+
// A new thread has none; any Slides sends keep their ids.
|
|
1660
|
+
replies: replies.length > 0 ? replies.map((reply) => writtenPost(ctx, reply, MAX_NAME_BYTES)) : undefined,
|
|
1661
|
+
idsNotShown: parts.row["idsNotShown"],
|
|
1662
|
+
note: written.saved ? undefined : COMMENTS_UNSAVED,
|
|
1663
|
+
}), "create_comment", reread);
|
|
1664
|
+
}),
|
|
1665
|
+
},
|
|
1666
|
+
{
|
|
1667
|
+
name: "create_comment_reply",
|
|
1668
|
+
description: "Reply to a comment thread. Adds a post and changes no existing one; to resolve, reopen, or reassign, use update_comment_thread.",
|
|
1669
|
+
// Additive: a reply is a new post, and the thread's status and
|
|
1670
|
+
// assignee stay as they were.
|
|
1671
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1672
|
+
input: contracts.create_comment_reply.input,
|
|
1673
|
+
output: contracts.create_comment_reply.output,
|
|
1674
|
+
run: (args, ctx) => postToThread(connection, client, ctx, "create_comment_reply", args, {
|
|
1675
|
+
content: commentText(args["content"], "content"),
|
|
1676
|
+
}),
|
|
1677
|
+
},
|
|
1678
|
+
{
|
|
1679
|
+
name: "update_comment_thread",
|
|
1680
|
+
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.",
|
|
1681
|
+
// Destructive: the thread's status or assignee is replaced. The post
|
|
1682
|
+
// that records it cannot be deleted afterwards.
|
|
1683
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1684
|
+
// Keep the field schema flat: discovery renders oneOf before properties,
|
|
1685
|
+
// losing these fields for required-only branches. The handler enforces
|
|
1686
|
+
// exactly one change and text on reassignment before dispatch instead.
|
|
1687
|
+
input: contracts.update_comment_thread.input,
|
|
1688
|
+
output: contracts.update_comment_thread.output,
|
|
1689
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1690
|
+
// Exactly one change per post. Slides requires text on reassignment;
|
|
1691
|
+
// only RESOLVE and REOPEN may go without.
|
|
1692
|
+
if ((args["status"] === undefined) === (args["assigneeEmail"] === undefined)) {
|
|
1693
|
+
throw new ConnectorCallError("invalid_args", "Pass exactly one of status and assigneeEmail. Nothing was sent.");
|
|
1694
|
+
}
|
|
1695
|
+
if (args["assigneeEmail"] !== undefined && args["content"] === undefined) {
|
|
1696
|
+
throw new ConnectorCallError("invalid_args", "A reassignment needs content: Slides requires text on any post that does not resolve or reopen. Nothing was sent.");
|
|
1697
|
+
}
|
|
1698
|
+
return yield* postToThread(connection, client, ctx, "update_comment_thread", args, {
|
|
1699
|
+
content: commentText(args["content"], "content"),
|
|
1700
|
+
commentAction: args["status"] === undefined ? undefined : args["status"] === "RESOLVED" ? "RESOLVE" : "REOPEN",
|
|
1701
|
+
assigneeEmail: args["assigneeEmail"],
|
|
1702
|
+
});
|
|
1703
|
+
}),
|
|
1704
|
+
},
|
|
1705
|
+
{
|
|
1706
|
+
name: "update_comment_post",
|
|
1707
|
+
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.",
|
|
1708
|
+
// Destructive: the post's previous text is overwritten.
|
|
1709
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1710
|
+
input: contracts.update_comment_post.input,
|
|
1711
|
+
output: contracts.update_comment_post.output,
|
|
1712
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1713
|
+
const content = commentText(args["content"], "content");
|
|
1714
|
+
const written = yield* commentWrite(client, ctx, args["presentationId"], {
|
|
1715
|
+
updateCommentPost: { commentId: args["commentId"], postId: args["postId"], content },
|
|
1716
|
+
});
|
|
1717
|
+
return appliedDeliverable(compact({
|
|
1718
|
+
...written.header,
|
|
1719
|
+
commentId: args["commentId"],
|
|
1720
|
+
postId: args["postId"],
|
|
1721
|
+
note: written.saved ? undefined : COMMENTS_UNSAVED,
|
|
1722
|
+
}), "update_comment_post", "read the thread with list_comments to see the post");
|
|
1723
|
+
}),
|
|
1724
|
+
},
|
|
1725
|
+
{
|
|
1726
|
+
name: "delete_comment",
|
|
1727
|
+
description: "Delete a whole comment thread, every reply with it. Only the author of its head post can, and the API cannot restore it.",
|
|
1728
|
+
// Destructive: the thread and its replies are gone.
|
|
1729
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1730
|
+
input: contracts.delete_comment.input,
|
|
1731
|
+
output: contracts.delete_comment.output,
|
|
1732
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1733
|
+
const written = yield* commentWrite(client, ctx, args["presentationId"], {
|
|
1734
|
+
deleteComment: { commentId: args["commentId"] },
|
|
1735
|
+
});
|
|
1736
|
+
return appliedDeliverable(compact({
|
|
1737
|
+
...written.header,
|
|
1738
|
+
commentId: args["commentId"],
|
|
1739
|
+
note: written.saved ? undefined : COMMENTS_UNSAVED,
|
|
1740
|
+
}), "delete_comment", "list the deck's comments with list_comments to see whether it is gone");
|
|
1741
|
+
}),
|
|
1742
|
+
},
|
|
1743
|
+
{
|
|
1744
|
+
name: "delete_comment_reply",
|
|
1745
|
+
description: "Delete one reply from a comment thread. Only its author can, and not a reply that resolved, reopened, or assigned the thread.",
|
|
1746
|
+
// Destructive: the reply is gone.
|
|
1747
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1748
|
+
input: contracts.delete_comment_reply.input,
|
|
1749
|
+
output: contracts.delete_comment_reply.output,
|
|
1750
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1751
|
+
const written = yield* commentWrite(client, ctx, args["presentationId"], {
|
|
1752
|
+
deleteCommentReply: { commentId: args["commentId"], postId: args["postId"] },
|
|
1753
|
+
});
|
|
1754
|
+
return appliedDeliverable(compact({
|
|
1755
|
+
...written.header,
|
|
1756
|
+
commentId: args["commentId"],
|
|
1757
|
+
postId: args["postId"],
|
|
1758
|
+
note: written.saved ? undefined : COMMENTS_UNSAVED,
|
|
1759
|
+
}), "delete_comment_reply", "read the thread with list_comments to see whether it is gone");
|
|
1760
|
+
}),
|
|
1761
|
+
},
|
|
1762
|
+
{
|
|
1763
|
+
name: "batch_update_presentation",
|
|
1764
|
+
description: "Send Slides batchUpdate at the read revision; insertComment and other comment kinds may share the batch but save apart. Always destructive. Returns every new id; past 256 KiB, only a direct call can page them.",
|
|
1765
|
+
// Destructive: a raw request can delete or overwrite anything in the deck.
|
|
1766
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1767
|
+
input: contracts.batch_update_presentation.input,
|
|
1768
|
+
output: contracts.batch_update_presentation.output,
|
|
1769
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1770
|
+
const requests = asArray(args["requests"]);
|
|
1771
|
+
const kinds = requests.map((request, index) => [index, Object.keys(asRecord(request))[0] ?? ""]);
|
|
1772
|
+
const unknown = kinds.filter(([, kind]) => !REQUEST_KINDS.includes(kind));
|
|
1773
|
+
if (unknown.length > 0) {
|
|
1774
|
+
throw new ConnectorCallError("invalid_args", `Not a Slides request kind: ${unknown
|
|
1775
|
+
.slice(0, 5)
|
|
1776
|
+
.map(([index, kind]) => `requests[${index}] "${kind.slice(0, 64)}"`)
|
|
1777
|
+
.join(", ")}. Each request is one of Slides' Request kinds, such as insertText, deleteObject, or createShape; nothing was sent.`);
|
|
1778
|
+
}
|
|
1779
|
+
const response = yield* batchUpdate(client, args["presentationId"], requests, args["requiredRevisionId"]);
|
|
1780
|
+
// Comment changes save apart from the rest of a batch, and Slides
|
|
1781
|
+
// reports them on their own: a batch that sent any is settled only
|
|
1782
|
+
// when Slides says ALL_SAVED, and one that is not stops being called
|
|
1783
|
+
// all or none.
|
|
1784
|
+
const saved = commentSaveFields(ctx, response, kinds.some(([, kind]) => COMMENT_KINDS.has(kind)));
|
|
1785
|
+
const header = compact({ presentationId: args["presentationId"], ...revisionFields(response), ...saved });
|
|
1786
|
+
// One reply per request, in order; most are empty, the create replies
|
|
1787
|
+
// carry the new ids. Bounded, ids first: Slides accepted the batch, so
|
|
1788
|
+
// what it created must reach the caller.
|
|
1789
|
+
const comments = kinds.some(([, kind]) => COMMENT_KINDS.has(kind));
|
|
1790
|
+
const reread = `re-read with get_presentation or get_page for deck changes${comments ? ", and list_comments for comments" : ""}`;
|
|
1791
|
+
const all = asArray(response["replies"]);
|
|
1792
|
+
const shown = boundedReplies(ctx, all, jsonBytes(header) + 1024);
|
|
1793
|
+
// Neutral about what applied: the comment state, when unconfirmed,
|
|
1794
|
+
// says that apart; these say only what the result left out.
|
|
1795
|
+
const unsaved = saved.commentUpdateState !== undefined ? COMMENTS_UNSAVED : undefined;
|
|
1796
|
+
const notes = (...entries) => {
|
|
1797
|
+
const said = entries.filter((entry) => entry !== undefined);
|
|
1798
|
+
return said.length > 0 ? `${said.join(" ")} To see the deck as it is now, ${reread}.` : undefined;
|
|
1799
|
+
};
|
|
1800
|
+
const result = compact({
|
|
1801
|
+
...header,
|
|
1802
|
+
replies: shown.replies,
|
|
1803
|
+
note: notes(unsaved, shown.replies.some((reply) => "cut" in reply)
|
|
1804
|
+
? `Large reply fields are named in truncated; every id is kept${shown.oversized ? ", and no other text, since the ids alone pass this tool's 192 KiB budget" : ""}.`
|
|
1805
|
+
: undefined),
|
|
1806
|
+
});
|
|
1807
|
+
const bytes = jsonBytes(result);
|
|
1808
|
+
if (bytes <= BRIDGE_BYTES)
|
|
1809
|
+
return result;
|
|
1810
|
+
if (bytes <= MAX_IDS_RESULT_BYTES) {
|
|
1811
|
+
return {
|
|
1812
|
+
...result,
|
|
1813
|
+
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(),
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
// Past any result: the save state and how to recover the ids stay;
|
|
1817
|
+
// the replies do not. Slides accepted the batch, so it is not to be sent again.
|
|
1818
|
+
return compact({
|
|
1819
|
+
...header,
|
|
1820
|
+
replies: [],
|
|
1821
|
+
repliesNotShown: all.length,
|
|
1822
|
+
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.`),
|
|
1823
|
+
});
|
|
1824
|
+
}),
|
|
1825
|
+
},
|
|
1826
|
+
];
|
|
1827
|
+
return declarations.map((declaration) => {
|
|
1828
|
+
const isRead = declaration.annotations.readOnlyHint === true;
|
|
1829
|
+
const isList = declaration.name.startsWith("list_");
|
|
1830
|
+
const rowNames = new Set(Object.keys(declaration.name === "list_layouts" ? contracts.LayoutRow.fields : contracts.CommentRow.fields));
|
|
1831
|
+
const names = isList
|
|
1832
|
+
? rowNames
|
|
1833
|
+
: new Set(Object.keys(declaration.output.fields));
|
|
1834
|
+
const spec = {
|
|
1835
|
+
name: declaration.name,
|
|
1836
|
+
description: declaration.description,
|
|
1837
|
+
input: isRead
|
|
1838
|
+
? Schema.Struct({
|
|
1839
|
+
...declaration.input.fields,
|
|
1840
|
+
fields: Schema.optionalKey(fieldsInput(isList
|
|
1841
|
+
? "Dot paths to keep in each item; truncation metadata is preserved."
|
|
1842
|
+
: "Dot paths to keep in the result; page and truncation metadata are preserved.")),
|
|
1843
|
+
})
|
|
1844
|
+
: declaration.input,
|
|
1845
|
+
output: declaration.output,
|
|
1846
|
+
...(declaration.writeSteps ? { writeSteps: declaration.writeSteps } : {}),
|
|
1847
|
+
run: (args) => Effect.gen(function* () {
|
|
1848
|
+
const problem = isRead
|
|
1849
|
+
? fieldsProblem(args["fields"], names, isList ? "a Slides list item" : "the result")
|
|
1850
|
+
: undefined;
|
|
1851
|
+
if (problem)
|
|
1852
|
+
return yield* Effect.fail(new Refusal({ error: problem }));
|
|
1853
|
+
const current = yield* Effect.serviceOption(Operation);
|
|
1854
|
+
if (Option.isNone(current) || !current.value)
|
|
1855
|
+
return yield* Effect.die(new Error("Slides requires a call context."));
|
|
1856
|
+
const ctx = current.value;
|
|
1857
|
+
const supplied = args["cursor"];
|
|
1858
|
+
const decoded = typeof supplied === "string" ? yield* boundCursor.decode("slides-content-v1", {}, supplied) : undefined;
|
|
1859
|
+
const result = yield* declaration.run({ ...args, ...(decoded === undefined ? {} : { cursor: decoded }) }, ctx);
|
|
1860
|
+
const normalized = normalizeResult(result, declaration.name);
|
|
1861
|
+
const selected = isList
|
|
1862
|
+
? { ...normalized, items: selectNamedFields(normalized["items"], args["fields"]) }
|
|
1863
|
+
: isRead
|
|
1864
|
+
? {
|
|
1865
|
+
...asRecord(selectNamedFields(normalized, args["fields"])),
|
|
1866
|
+
...(normalized["page"] ? { page: normalized["page"] } : {}),
|
|
1867
|
+
}
|
|
1868
|
+
: normalized;
|
|
1869
|
+
return yield* signContinuations(selected, ctx);
|
|
1870
|
+
}).pipe(Effect.catchDefect((defect) => defect instanceof ConnectorCallError ? Effect.fail(new Refusal({ error: defect })) : Effect.die(defect))),
|
|
1871
|
+
};
|
|
1872
|
+
return isRead
|
|
1873
|
+
? read(connection, spec)
|
|
1874
|
+
: write(connection, { ...spec, destructive: declaration.annotations.destructiveHint !== false });
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
/** Field selection narrows data while retaining every selected object's cut disclosure. */
|
|
1878
|
+
function selectNamedFields(value, fields) {
|
|
1879
|
+
const restore = (source, selected) => {
|
|
1880
|
+
if (Array.isArray(source) && Array.isArray(selected))
|
|
1881
|
+
return selected.map((item, index) => restore(source[index], item));
|
|
1882
|
+
if (!source || !selected || typeof source !== "object" || typeof selected !== "object")
|
|
1883
|
+
return selected;
|
|
1884
|
+
const original = asRecord(source);
|
|
1885
|
+
const out = Object.fromEntries(Object.entries(selected).map(([key, item]) => [key, restore(original[key], item)]));
|
|
1886
|
+
if (Array.isArray(original["truncated"]))
|
|
1887
|
+
out["truncated"] = original["truncated"];
|
|
1888
|
+
return out;
|
|
1889
|
+
};
|
|
1890
|
+
return restore(value, selectFields(value, fields));
|
|
1891
|
+
}
|
|
1892
|
+
/** Size reports name the cut paths; content cursors retain their independent offsets. */
|
|
1893
|
+
function normalizeResult(value, tool) {
|
|
1894
|
+
const allCuts = new Set();
|
|
1895
|
+
const normalize = (value, path = "") => {
|
|
1896
|
+
if (Array.isArray(value))
|
|
1897
|
+
return value.map((item) => normalize(item, `${path}[]`));
|
|
1898
|
+
if (!value || typeof value !== "object")
|
|
1899
|
+
return value;
|
|
1900
|
+
const out = {};
|
|
1901
|
+
const cut = [];
|
|
1902
|
+
const source = asRecord(value);
|
|
1903
|
+
for (const [key, item] of Object.entries(source)) {
|
|
1904
|
+
if (key === "truncated" && item === true) {
|
|
1905
|
+
if (source["textCursor"] || source["textLength"])
|
|
1906
|
+
cut.push("text");
|
|
1907
|
+
if (source["notesCursor"])
|
|
1908
|
+
cut.push("notes");
|
|
1909
|
+
if (!cut.length)
|
|
1910
|
+
cut.push(...Object.keys(source).filter((name) => typeof source[name] === "string" && !/Id$|Cursor$/.test(name)));
|
|
1911
|
+
}
|
|
1912
|
+
else if (key === "cut" && Array.isArray(item))
|
|
1913
|
+
cut.push(...item.map(String));
|
|
1914
|
+
else if (["elementsNotShown", "layoutsNotShown", "repliesNotShown", "revisionIdNotShown"].includes(key)) {
|
|
1915
|
+
if (item)
|
|
1916
|
+
cut.push(key === "revisionIdNotShown" ? "revisionId" : key.replace("NotShown", "[]"));
|
|
1917
|
+
}
|
|
1918
|
+
else {
|
|
1919
|
+
out[key] = normalize(item, path ? `${path}.${key}` : key);
|
|
1920
|
+
if (typeof item === "string" && /\[… \d+ more characters[; ]/.test(item))
|
|
1921
|
+
cut.push(key);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
if (cut.length) {
|
|
1925
|
+
out["truncated"] = [...new Set(cut)];
|
|
1926
|
+
for (const field of cut)
|
|
1927
|
+
allCuts.add(path ? `${path}.${field}` : field);
|
|
1928
|
+
}
|
|
1929
|
+
return out;
|
|
1930
|
+
};
|
|
1931
|
+
const result = asRecord(normalize(value));
|
|
1932
|
+
if (allCuts.size)
|
|
1933
|
+
result["truncated"] = [...allCuts];
|
|
1934
|
+
if (tool === "list_layouts" || tool === "list_comments") {
|
|
1935
|
+
const noun = tool === "list_layouts" ? "layouts" : "threads";
|
|
1936
|
+
result["items"] = result[noun];
|
|
1937
|
+
delete result[noun];
|
|
1938
|
+
if (allCuts.size)
|
|
1939
|
+
result["truncated"] = [...allCuts].map((path) => path.replace(`${noun}[]`, "items[]"));
|
|
1940
|
+
if (typeof result["total"] === "number") {
|
|
1941
|
+
result["page"] = { ...asRecord(result["page"]), total: result["total"] };
|
|
1942
|
+
delete result["total"];
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
return result;
|
|
1946
|
+
}
|
|
1947
|
+
/** Cross-tool content cursors share one signed codec, bound to connection, owner and delegated caller. */
|
|
1948
|
+
function signContinuations(value, ctx) {
|
|
1949
|
+
return Effect.gen(function* () {
|
|
1950
|
+
if (Array.isArray(value)) {
|
|
1951
|
+
const rows = [];
|
|
1952
|
+
for (const item of value)
|
|
1953
|
+
rows.push(yield* signContinuations(item, ctx));
|
|
1954
|
+
return rows;
|
|
1955
|
+
}
|
|
1956
|
+
if (!value || typeof value !== "object")
|
|
1957
|
+
return value;
|
|
1958
|
+
const out = {};
|
|
1959
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1960
|
+
if (key.endsWith("Cursor") && typeof item === "string") {
|
|
1961
|
+
out[key] = yield* boundCursor.encode("slides-content-v1", {}, item, (json) => encodeForAgent(ctx, json, { url: true, claims: true }));
|
|
1962
|
+
}
|
|
1963
|
+
else
|
|
1964
|
+
out[key] = yield* signContinuations(item, ctx);
|
|
1965
|
+
}
|
|
1966
|
+
return out;
|
|
1967
|
+
});
|
|
1968
|
+
}
|