@jini-ai/agent-plugins 0.6.16 → 0.6.19
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 +35 -1
- package/NOTICE +14 -0
- package/README.md +35 -14
- package/charts-echarts/plugin.json +39 -0
- package/charts-echarts/skills/charts-echarts/SKILL.md +110 -0
- package/charts-recharts/plugin.json +32 -0
- package/charts-recharts/skills/charts-recharts/SKILL.md +108 -0
- package/cloudflare/NOTES.md +16 -0
- package/cloudflare/form-destinations/cloudflare-d1.mjs +9 -0
- package/cloudflare/lib/api.mjs +96 -0
- package/cloudflare/lib/assets.mjs +65 -0
- package/cloudflare/lib/dns.mjs +32 -0
- package/cloudflare/lib/domain-inventory.mjs +61 -0
- package/cloudflare/lib/domain-status.mjs +48 -0
- package/cloudflare/lib/domains.mjs +95 -0
- package/cloudflare/lib/forms.mjs +254 -0
- package/cloudflare/lib/pages-upload.mjs +51 -0
- package/cloudflare/lib/target.mjs +165 -0
- package/cloudflare/lib/workers-upload.mjs +48 -0
- package/cloudflare/lib/zones.mjs +25 -0
- package/cloudflare/mcp.json +4 -0
- package/cloudflare/plugin.json +96 -0
- package/cloudflare/skills/cloudflare/SKILL.md +57 -0
- package/cloudflare/skills/cloudflare/references/domains.md +31 -0
- package/cloudflare/skills/cloudflare/references/pages.md +18 -0
- package/cloudflare/skills/cloudflare/references/permissions.md +29 -0
- package/cloudflare/skills/cloudflare/references/troubleshooting.md +20 -0
- package/cloudflare/skills/cloudflare/references/workers.md +42 -0
- package/cloudflare/targets/cloudflare-pages.mjs +12 -0
- package/cloudflare/targets/cloudflare-workers.mjs +17 -0
- package/cloudflare/tovu-consent.json +6 -0
- package/cloudflare/tovu-deploy-targets.json +630 -0
- package/cloudflare/tovu-form-destinations.json +12 -0
- package/cloudflare/worker/forms-receiver.mjs +249 -0
- package/composio/mcp.json +10 -0
- package/composio/plugin.json +68 -0
- package/composio/skills/composio/SKILL.md +85 -0
- package/dist/core/identity-namespace.d.ts +12 -0
- package/dist/core/identity-namespace.js +20 -0
- package/dist/folder-zip-node.js +29 -7
- package/dist/lifecycle/bundled-digests.js +4 -3
- package/dist/lifecycle/install.d.ts +7 -0
- package/dist/lifecycle/install.js +45 -3
- package/dist/lifecycle/manifest.d.ts +2 -2
- package/dist/lifecycle/manifest.js +5 -3
- package/dist/lifecycle/node.js +2 -0
- package/dist/lifecycle/persistent-state.js +1 -1
- package/dist/lifecycle/ports.d.ts +8 -0
- package/dist/lifecycle/seed-bundled.js +4 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +3 -2
- package/fly/deploy-configs/fly.mjs +115 -0
- package/fly/deploy-ops/fly.mjs +102 -0
- package/fly/mcp.json +4 -0
- package/fly/plugin.json +81 -0
- package/fly/scripts/render-deploy-workflow.mjs +150 -0
- package/fly/skills/fly/SKILL.md +43 -0
- package/fly/skills/fly/references/fly-deploy.template.yml +117 -0
- package/fly/skills/fly/references/fly.template.toml +81 -0
- package/fly/tovu-credential-schemes.json +11 -0
- package/fly/tovu-deploy-configs.json +21 -0
- package/fly/tovu-deploy-ops.json +14 -0
- package/github/deploy-ops/github-actions.mjs +164 -0
- package/github/deployments/github.mjs +415 -0
- package/github/mcp.json +4 -0
- package/github/plugin.json +105 -0
- package/github/skills/github/SKILL.md +249 -0
- package/github/skills/github/references/actions.md +224 -0
- package/github/skills/github/references/auth-and-tokens.md +140 -0
- package/github/skills/github/references/repo-files.md +135 -0
- package/github/skills/github/references/site-backup.md +159 -0
- package/github/skills/github/references/source-control.md +123 -0
- package/github/source-control/backup-push.mjs +242 -0
- package/github/source-control/commit-site.mjs +459 -0
- package/github/source-control/github.mjs +93 -0
- package/github/source-control/write-files.mjs +468 -0
- package/github/tovu-deploy-ops.json +13 -0
- package/github/tovu-source-control.json +117 -0
- package/github-pages/NOTES.md +19 -0
- package/github-pages/mcp.json +4 -0
- package/github-pages/plugin.json +48 -0
- package/github-pages/skills/github-pages/SKILL.md +44 -0
- package/github-pages/targets/github-pages.mjs +543 -0
- package/github-pages/tovu-deploy-targets.json +327 -0
- package/google-login/exports/google-g.svg +1 -0
- package/google-login/exports/sign-in-button.css +68 -0
- package/google-login/exports/sign-in-button.html +4 -0
- package/google-login/mcp.json +4 -0
- package/google-login/plugin.json +59 -0
- package/google-login/skills/google-login/SKILL.md +96 -0
- package/google-login/tovu-login-providers.json +21 -0
- package/google-workspace/README.md +132 -0
- package/google-workspace/form-destinations/README.md +87 -0
- package/google-workspace/form-destinations/dedupe.mjs +43 -0
- package/google-workspace/form-destinations/delivery-result.mjs +69 -0
- package/google-workspace/form-destinations/mapping.mjs +152 -0
- package/google-workspace/form-destinations/sheets.mjs +202 -0
- package/google-workspace/lib/auth.mjs +81 -0
- package/google-workspace/lib/drive-files.mjs +54 -0
- package/google-workspace/lib/form-destination-settings.mjs +72 -0
- package/google-workspace/lib/http.mjs +168 -0
- package/google-workspace/lib/picker-flow.mjs +171 -0
- package/google-workspace/lib/picker-selection.mjs +107 -0
- package/google-workspace/lib/product-state.mjs +93 -0
- package/google-workspace/lib/request-policy.mjs +163 -0
- package/google-workspace/lib/sheets-a1.mjs +79 -0
- package/google-workspace/lib/sheets-admin-csv.mjs +82 -0
- package/google-workspace/lib/sheets-admin-request-kinds.mjs +183 -0
- package/google-workspace/lib/sheets-client.mjs +286 -0
- package/google-workspace/lib/surface-actions.mjs +107 -0
- package/google-workspace/mcp.json +4 -0
- package/google-workspace/plugin.json +77 -0
- package/google-workspace/products.json +12 -0
- package/google-workspace/skills/google-workspace/SKILL.md +82 -0
- package/google-workspace/skills/google-workspace/references/connection.md +102 -0
- package/google-workspace/skills/google-workspace/references/form-delivery.md +50 -0
- package/google-workspace/skills/google-workspace/references/sheets.md +149 -0
- package/google-workspace/skills/google-workspace/references/troubleshooting.md +31 -0
- package/google-workspace/tools/account-info.mjs +18 -0
- package/google-workspace/tools/sheets/README.md +54 -0
- package/google-workspace/tools/sheets/copy-tab.mjs +23 -0
- package/google-workspace/tools/sheets/copy.mjs +23 -0
- package/google-workspace/tools/sheets/create.mjs +35 -0
- package/google-workspace/tools/sheets/find-replace.mjs +38 -0
- package/google-workspace/tools/sheets/find.mjs +52 -0
- package/google-workspace/tools/sheets/get-by-data-filter.mjs +23 -0
- package/google-workspace/tools/sheets/get.mjs +22 -0
- package/google-workspace/tools/sheets/values-append.mjs +27 -0
- package/google-workspace/tools/sheets/values-batch-clear.mjs +20 -0
- package/google-workspace/tools/sheets/values-batch-get.mjs +20 -0
- package/google-workspace/tools/sheets/values-batch-update.mjs +29 -0
- package/google-workspace/tools/sheets/values-by-data-filter.mjs +62 -0
- package/google-workspace/tools/sheets/values-clear.mjs +20 -0
- package/google-workspace/tools/sheets/values-get.mjs +21 -0
- package/google-workspace/tools/sheets/values-update.mjs +26 -0
- package/google-workspace/tools/sheets-admin/README.md +45 -0
- package/google-workspace/tools/sheets-admin/basic-filter.mjs +32 -0
- package/google-workspace/tools/sheets-admin/batch-update.mjs +25 -0
- package/google-workspace/tools/sheets-admin/developer-metadata.mjs +28 -0
- package/google-workspace/tools/sheets-admin/export-csv.mjs +67 -0
- package/google-workspace/tools/sheets-admin/filter-views.mjs +38 -0
- package/google-workspace/tools/sheets-admin/format.mjs +70 -0
- package/google-workspace/tools/sheets-admin/named-ranges.mjs +42 -0
- package/google-workspace/tools/sheets-admin/open-existing.mjs +34 -0
- package/google-workspace/tools/sheets-admin/protected-ranges-get.mjs +18 -0
- package/google-workspace/tools/sheets-admin/sort.mjs +18 -0
- package/google-workspace/tools/sheets-admin/tab-modify.mjs +36 -0
- package/google-workspace/tovu-admin-surfaces.json +63 -0
- package/google-workspace/tovu-agent-tools.json +1178 -0
- package/google-workspace/tovu-connections.json +35 -0
- package/google-workspace/tovu-form-destinations.json +10 -0
- package/google-workspace/ui/README.md +73 -0
- package/google-workspace/ui/connection-panel.handler.mjs +167 -0
- package/google-workspace/ui/connection-panel.html +17 -0
- package/google-workspace/ui/connection-panel.mjs +189 -0
- package/google-workspace/ui/connection-view.mjs +120 -0
- package/google-workspace/ui/destination-panel.handler.mjs +106 -0
- package/google-workspace/ui/destination-panel.html +17 -0
- package/google-workspace/ui/destination-panel.mjs +150 -0
- package/google-workspace/ui/dom.mjs +140 -0
- package/google-workspace/ui/panel.css +170 -0
- package/google-workspace/ui/picker-flow.mjs +87 -0
- package/google-workspace/ui/picker-view.mjs +44 -0
- package/google-workspace/ui/picker.handler.mjs +56 -0
- package/google-workspace/ui/picker.html +17 -0
- package/google-workspace/ui/picker.mjs +88 -0
- package/google-workspace/ui/surface-client.mjs +227 -0
- package/higgsfield-media/mcp.json +9 -0
- package/higgsfield-media/plugin.json +109 -0
- package/higgsfield-media/skills/higgsfield-media/SKILL.md +325 -0
- package/higgsfield-media/skills/higgsfield-media/references/failure-modes.md +116 -0
- package/higgsfield-media/skills/higgsfield-media/references/models-and-plan-gates.md +142 -0
- package/jev/mcp.json +4 -0
- package/jev/plugin.json +44 -0
- package/jev/skills/jev/SKILL.md +308 -0
- package/jev/skills/jev/references/cookbooks.md +197 -0
- package/jev/skills/jev/references/jaggedness.md +135 -0
- package/jev/skills/jev/references/javascript-sdk.md +146 -0
- package/jev/skills/jev/references/patterns.md +123 -0
- package/jev/skills/jev/references/primitives-reference.md +187 -0
- package/langfuse/plugin.json +22 -0
- package/meilisearch/mcp.json +4 -0
- package/meilisearch/plugin.json +54 -0
- package/meilisearch/providers/meilisearch.mjs +651 -0
- package/meilisearch/skills/meilisearch/SKILL.md +43 -0
- package/meilisearch/tovu-search-providers.json +10 -0
- package/netlify/NOTES.md +13 -0
- package/netlify/mcp.json +4 -0
- package/netlify/plugin.json +47 -0
- package/netlify/skills/netlify/SKILL.md +39 -0
- package/netlify/targets/netlify.mjs +384 -0
- package/netlify/tovu-consent.json +6 -0
- package/netlify/tovu-deploy-targets.json +163 -0
- package/opentelemetry/mcp.json +4 -0
- package/opentelemetry/plugin.json +52 -0
- package/opentelemetry/skills/opentelemetry/SKILL.md +39 -0
- package/package.json +161 -6
- package/posthog/mcp.json +4 -0
- package/posthog/plugin.json +46 -0
- package/posthog/skills/posthog/SKILL.md +36 -0
- package/railway/deploy-configs/railway.mjs +135 -0
- package/railway/deploy-ops/railway.mjs +167 -0
- package/railway/mcp.json +4 -0
- package/railway/plugin.json +45 -0
- package/railway/skills/railway/SKILL.md +66 -0
- package/railway/tovu-deploy-configs.json +16 -0
- package/railway/tovu-deploy-ops.json +11 -0
- package/render/deploy-configs/render.mjs +141 -0
- package/render/deploy-ops/render.mjs +152 -0
- package/render/mcp.json +4 -0
- package/render/plugin.json +47 -0
- package/render/skills/render/SKILL.md +63 -0
- package/render/tovu-deploy-configs.json +16 -0
- package/render/tovu-deploy-ops.json +11 -0
- package/resend/NOTES.md +52 -0
- package/resend/adapters/resend.mjs +161 -0
- package/resend/lib/domains.mjs +137 -0
- package/resend/lib/errors.mjs +25 -0
- package/resend/lib/feedback.mjs +118 -0
- package/resend/mcp.json +13 -0
- package/resend/plugin.json +192 -0
- package/resend/skills/resend/SKILL.md +107 -0
- package/resend/tovu-mail-adapters.json +22 -0
- package/s3-compatible/NOTES.md +11 -0
- package/s3-compatible/mcp.json +4 -0
- package/s3-compatible/object-stores/s3-compatible.mjs +287 -0
- package/s3-compatible/plugin.json +56 -0
- package/s3-compatible/skills/s3-compatible/SKILL.md +39 -0
- package/s3-compatible/targets/s3-compatible.mjs +1054 -0
- package/s3-compatible/tovu-deploy-targets.json +432 -0
- package/s3-compatible/tovu-object-stores.json +29 -0
- package/sentry/mcp.json +4 -0
- package/sentry/plugin.json +49 -0
- package/sentry/skills/sentry/SKILL.md +34 -0
- package/shopify/mcp.json +4 -0
- package/shopify/plugin.json +22 -0
- package/shopify/skills/shopify/SKILL.md +103 -0
- package/shopify/skills/shopify/references/catalog-sync.md +74 -0
- package/shopify/skills/shopify/references/domains.md +26 -0
- package/shopify/skills/shopify/references/live-storefront.md +34 -0
- package/shopify/skills/shopify/references/storefront-snippets.md +21 -0
- package/site-compliance/mcp.json +4 -0
- package/site-compliance/plugin.json +34 -0
- package/site-compliance/skills/site-compliance/SKILL.md +220 -0
- package/site-compliance/skills/site-compliance/references/ccpa-cpra.md +115 -0
- package/site-compliance/skills/site-compliance/references/gdpr-eu-uk.md +143 -0
- package/site-compliance/skills/site-compliance/references/wcag-2-2.md +129 -0
- package/site-import/mcp.json +4 -0
- package/site-import/plugin.json +41 -0
- package/site-import/skills/site-import/SKILL.md +232 -0
- package/site-import/skills/site-import/references/content-mapping.md +166 -0
- package/site-import/skills/site-import/references/discovery-and-classification.md +117 -0
- package/site-import/skills/site-import/references/theme-extraction.md +34 -0
- package/slide-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
- package/slide-design/mcp.json +4 -0
- package/slide-design/plugin.json +26 -0
- package/slide-design/skills/slide-design/SKILL.md +103 -0
- package/slide-design/skills/slide-design/references/charts-and-exhibits.md +100 -0
- package/slide-design/skills/slide-design/references/html-deck.md +175 -0
- package/slide-design/skills/slide-design/references/layout-and-style.md +131 -0
- package/slide-design/skills/slide-design/references/review.md +93 -0
- package/slide-design/skills/slide-design/references/storyline.md +118 -0
- package/stripe/plugin.json +22 -0
- package/supabase/mcp.json +26 -0
- package/supabase/plugin.json +78 -0
- package/supabase/skills/supabase/SKILL.md +169 -0
- package/supabase/skills/supabase/references/failure-modes.md +101 -0
- package/ui-ux-design/CHANGELOG.md +28 -0
- package/ui-ux-design/LICENSE +201 -0
- package/ui-ux-design/NOTICE +14 -0
- package/ui-ux-design/README.md +62 -0
- package/ui-ux-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
- package/ui-ux-design/mcp.json +4 -0
- package/ui-ux-design/plugin.json +26 -0
- package/ui-ux-design/skills/frontend-accessibility/SKILL.md +60 -0
- package/ui-ux-design/skills/gstack-design/SKILL.md +53 -0
- package/ui-ux-design/skills/gstack-design/references/consultation.md +30 -0
- package/ui-ux-design/skills/gstack-design/references/html.md +29 -0
- package/ui-ux-design/skills/gstack-design/references/review.md +28 -0
- package/ui-ux-design/skills/gstack-design/references/shotgun.md +33 -0
- package/ui-ux-design/skills/gstack-design/references/upstream-notes.md +23 -0
- package/ui-ux-design/skills/interface-design/ORIGINAL.md +16 -0
- package/ui-ux-design/skills/interface-design/SKILL.md +41 -0
- package/ui-ux-design/skills/interface-design/references/commands/audit.md +40 -0
- package/ui-ux-design/skills/interface-design/references/commands/critique.md +21 -0
- package/ui-ux-design/skills/interface-design/references/commands/extract.md +34 -0
- package/ui-ux-design/skills/interface-design/references/commands/init.md +31 -0
- package/ui-ux-design/skills/interface-design/references/commands/status.md +22 -0
- package/ui-ux-design/skills/interface-design/references/examples/system-precision.md +29 -0
- package/ui-ux-design/skills/interface-design/references/examples/system-warmth.md +29 -0
- package/ui-ux-design/skills/interface-design/references/system-template.md +57 -0
- package/ui-ux-design/skills/shadcn-ui/README.md +244 -0
- package/ui-ux-design/skills/shadcn-ui/SKILL.md +333 -0
- package/ui-ux-design/skills/shadcn-ui/examples/auth-layout.tsx +177 -0
- package/ui-ux-design/skills/shadcn-ui/examples/data-table.tsx +313 -0
- package/ui-ux-design/skills/shadcn-ui/examples/form-pattern.tsx +177 -0
- package/ui-ux-design/skills/shadcn-ui/resources/component-catalog.md +481 -0
- package/ui-ux-design/skills/shadcn-ui/resources/customization-guide.md +516 -0
- package/ui-ux-design/skills/shadcn-ui/resources/migration-guide.md +463 -0
- package/ui-ux-design/skills/shadcn-ui/resources/setup-guide.md +412 -0
- package/ui-ux-design/skills/shadcn-ui/scripts/verify-setup.sh +134 -0
- package/ui-ux-design/skills/ui-ux-design/SKILL.md +171 -0
- package/ui-ux-design/skills/ui-ux-design/references/brand-and-voice.md +78 -0
- package/ui-ux-design/skills/ui-ux-design/references/chrome-mobile-test-matrix.md +137 -0
- package/ui-ux-design/skills/ui-ux-design/references/components-and-states.md +112 -0
- package/ui-ux-design/skills/ui-ux-design/references/delight-and-motion.md +54 -0
- package/ui-ux-design/skills/ui-ux-design/references/foundations.md +145 -0
- package/ui-ux-design/skills/ui-ux-design/references/inclusive-ai-imagery.md +57 -0
- package/ui-ux-design/skills/ui-ux-design/references/kole-jain-uiux-concepts.md +104 -0
- package/ui-ux-design/skills/ui-ux-design/references/kuinetic-reference.md +184 -0
- package/ui-ux-design/skills/ui-ux-design/references/openai-frontend-skill.md +139 -0
- package/ui-ux-design/skills/ui-ux-design/references/premium-ui.md +55 -0
- package/ui-ux-design/skills/ui-ux-design/references/research-and-validation.md +88 -0
- package/ui-ux-design/skills/ui-ux-design/references/sam-crawford-premium-websites.md +103 -0
- package/ui-ux-design/skills/ui-ux-design/references/self-made-web-designer-core-skills.md +119 -0
- package/ui-ux-design/skills/ui-ux-design/references/ui-animation.md +173 -0
- package/ui-ux-design/skills/ui-ux-design/references/ui-loop.md +234 -0
- package/ui-ux-design/skills/ui-ux-design/references/visual-storytelling.md +54 -0
- package/ui-ux-design/skills/vercel-web-design-guidelines/SKILL.md +39 -0
- package/ui-ux-design/skills/web-compliance/SKILL.md +134 -0
- package/ui-ux-design/skills/web-compliance/references/audit-evidence.md +59 -0
- package/vercel/NOTES.md +23 -0
- package/vercel/mcp.json +4 -0
- package/vercel/object-stores/vercel-blob.mjs +147 -0
- package/vercel/plugin.json +47 -0
- package/vercel/skills/vercel/SKILL.md +48 -0
- package/vercel/targets/vercel.mjs +586 -0
- package/vercel/tovu-consent.json +6 -0
- package/vercel/tovu-deploy-targets.json +219 -0
- package/vercel/tovu-object-stores.json +10 -0
- package/wordpress/NOTES.md +33 -0
- package/wordpress/plugin.json +30 -0
- package/wordpress/skills/wordpress/SKILL.md +43 -0
- package/wordpress/skills/wordpress/references/free-site-redirect.md +46 -0
- package/wordpress/skills/wordpress/references/subdomain.md +66 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: google-login
|
|
3
|
+
description: Set up Google member sign-in in Tovu or Google via an app's existing Supabase Auth.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Google login
|
|
7
|
+
|
|
8
|
+
This integration is marked **soon** and has not been tested with a real Google account. Tell the person this before configuring it; do not claim it works until the real sign-in has been checked.
|
|
9
|
+
|
|
10
|
+
## Choose the destination first
|
|
11
|
+
|
|
12
|
+
Ask whether the site uses **Tovu members** or **Supabase Auth**, or check whether the supabase plugin is active at runtime. An active plugin is a clue; confirm which authentication system the target app actually uses. Never import the supabase plugin or add a Supabase runtime dependency. Its branch below is instructions only.
|
|
13
|
+
|
|
14
|
+
Tovu member login and Google Workspace have separate credentials and activation. Use this plugin for visitor sign-in; Google Workspace owns operator file access. Never replace a working client unless asked. Never ask for the Client Secret in chat, tool arguments, pasted configuration, screenshots, or logs. The person enters it in the secure card or Supabase dashboard themselves.
|
|
15
|
+
|
|
16
|
+
## Prepare the Google client
|
|
17
|
+
|
|
18
|
+
1. Open [Google Cloud Console](https://console.cloud.google.com/auth/clients) and select or create the app's project.
|
|
19
|
+
2. In Google Auth Platform, configure **Branding** (app identity and support contact), **Audience**, and **Data Access**. Choose External unless access is intentionally restricted to one Workspace organization. Request only `openid email profile`; do not add Drive, Sheets, Gmail, offline access, or refresh-token permissions.
|
|
20
|
+
3. Under **Clients → Create client**, choose **Web application**. The older navigation is **APIs & Services → Credentials → Create Credentials → OAuth client ID**.
|
|
21
|
+
4. Add the exact authorized redirect URI for the chosen branch below. HTTPS is required for production; Google permits localhost development exceptions. Scheme, host, port, path and any configured base path must match exactly. A JavaScript origin is not a replacement for a redirect URI.
|
|
22
|
+
5. Check Audience's Testing/Production status before rollout. Identity-only `openid email profile` requests have an exception to the usual testing-user restrictions. Additional permissions can change those rules. Publishing and verification are separate; consult [Google's production readiness guidance](https://developers.google.com/identity/protocols/oauth2/production-readiness/overview) for the app's current requirements.
|
|
23
|
+
|
|
24
|
+
## Tovu members
|
|
25
|
+
|
|
26
|
+
1. Enable Google Login in this site's Agent Plugins. It is bundled but starts disabled.
|
|
27
|
+
2. Read the site's **configured canonical address** and the actual bound workspace ID. Do not infer the address from request Host or forwarded headers. Compute and show the exact callback using the URL API, preserving any configured base path:
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
const callback = new URL(configuredSiteAddress);
|
|
31
|
+
callback.pathname = callback.pathname.replace(/\/$/, '')
|
|
32
|
+
+ '/api/members/v1/workspaces/' + encodeURIComponent(workspaceId) + '/oidc/google/callback';
|
|
33
|
+
callback.search = '';
|
|
34
|
+
callback.hash = '';
|
|
35
|
+
callback.href;
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For example, `http://localhost:3000/api/members/v1/workspaces/<workspaceId>/oidc/google/callback`. Replace the placeholder before showing it to the person. Copy the resulting URI into Google's **Authorized redirect URIs**. Use separate local and deployed clients when appropriate.
|
|
39
|
+
3. Open the existing secure card with `credential_save { kind: "plugin-provider", target: "google-login-oauth-client" }`. The person pastes the Client ID into **Google client ID** (`username`) and the Client Secret into the masked secret field (`token`). The existing vault seals it; the assistant never receives it. No extra callback field is needed on the card.
|
|
40
|
+
4. Visit `/signin` and try **Sign in with Google**. Success returns to `/account`. This creates or links a site member, with no administrator privileges. Tovu core owns state, browser binding, PKCE, token validation and member sessions; do not implement a second flow in this plugin.
|
|
41
|
+
5. Explain initial linking: Google-verified `@gmail.com`/`@googlemail.com` addresses and verified Google Workspace accounts with an `hd` claim auto-link directly. Other Google addresses require **one emailed code** to the member's address before the first auto-link, because Google's email verification can outlive mailbox ownership. Later sign-ins use issuer + subject, even if email changes. Disabled or trashed members cannot sign in.
|
|
42
|
+
6. Check sign-out and `/account` **Unlink Google**. Unlink requires a recent sign-in and another usable sign-in method; set a password if directed. Unlink must remain effective on the next Google sign-in. Reconnect deliberately with **Connect Google** from the signed-in `/account` page. It does not revoke Google Workspace file access.
|
|
43
|
+
|
|
44
|
+
## Google via Supabase Auth
|
|
45
|
+
|
|
46
|
+
1. Open the target project's **Supabase dashboard → Authentication → Providers → Google** and enable Google.
|
|
47
|
+
2. The person enters the **same Google Client ID and Client Secret** directly in that Supabase panel themselves, never in chat. Tovu's credential card is unnecessary unless this site also uses Tovu members.
|
|
48
|
+
3. Copy the exact **Callback URL (for OAuth)** shown on that panel. Add it to the Google OAuth client's **Authorized redirect URIs**. A hosted callback commonly looks like `https://<project-ref>.supabase.co/auth/v1/callback`; use the displayed value, including a custom domain if configured. For local Supabase, use the callback shown by that instance.
|
|
49
|
+
4. One Google Web application client can list both the Supabase callback and Tovu's callback when both systems are used. Keep both exact URIs in Google; do not substitute one for the other.
|
|
50
|
+
5. In Supabase **Authentication → URL Configuration**, configure the app's own allowed return URL. The Google-to-Supabase callback and the Supabase-to-app return URL serve different steps.
|
|
51
|
+
6. Test through the app's existing Supabase sign-in flow. These instructions do not automatically associate Supabase users with Tovu members or replace the app's session system. See [Supabase's Google setup](https://supabase.com/docs/guides/auth/social-sign-in/auth-google).
|
|
52
|
+
|
|
53
|
+
## Troubleshooting without exposing secrets
|
|
54
|
+
|
|
55
|
+
- `redirect_uri_mismatch`: compare the exact configured callback with Google's authorized list, including scheme, port and base path.
|
|
56
|
+
- Missing Tovu button: check plugin activation, the saved Client ID/Secret card and configured canonical address. Do not switch to Workspace credentials.
|
|
57
|
+
- Browser/state failure: restart from `/signin` in the same browser; do not forward an authorization URL, bypass binding, weaken validation or add issuer aliases.
|
|
58
|
+
- Google access denied: check Audience restrictions and the selected account; request only identity permissions. Never copy callback query strings, codes, state, verifier, tokens, claims or upstream error bodies into chat or logs. Report fixed outcome codes only.
|
|
59
|
+
|
|
60
|
+
## Embed the sign-in button on a public page
|
|
61
|
+
|
|
62
|
+
Use the existing `content_read.plugin` export contract for `tovu/google-login` → `sign-in-button`.
|
|
63
|
+
The plugin must be enabled and its Google client configured. `host-unavailable` means finish
|
|
64
|
+
setup first; `props-required` means the host is configured and the author must choose a variant.
|
|
65
|
+
The required `variant` is `light`, `dark`, or `neutral`; no template defaults or logic are added.
|
|
66
|
+
Use the marker owner to serialize config; do not construct a second JSON attribute escaper.
|
|
67
|
+
|
|
68
|
+
```html
|
|
69
|
+
<div data-embed-config='{"type":"plugin","plugin":"tovu/google-login","component":"sign-in-button","version":1,"props":{"variant":"light"}}'>
|
|
70
|
+
<p>Google sign-in is currently unavailable.</p>
|
|
71
|
+
</div>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Keep a useful authored fallback. Disabled/unconfigured plugins render that fallback without a
|
|
75
|
+
button. Static exports always render the fallback, while bundling the stylesheet and mark.
|
|
76
|
+
The action is a native link to the host's same-origin start route; Tab focuses it and Enter
|
|
77
|
+
starts sign-in, including with JavaScript off. Do not replace the host link or add OAuth JavaScript.
|
|
78
|
+
The Interactive editor uses a protected card; Preview and the published page render the real UI.
|
|
79
|
+
The card's availability is a page-load snapshot; reload after changing plugin/client setup.
|
|
80
|
+
|
|
81
|
+
Theme hooks are `.plugin-google-login-sign-in-button`, its `__mark` and `__label` parts,
|
|
82
|
+
and its `--light`, `--dark`, `--neutral` variant classes. Defaults live in `@layer plugin-exports`,
|
|
83
|
+
so ordinary theme CSS wins. Set inherited custom properties on the authored wrapper or a theme:
|
|
84
|
+
`--plugin-signin-bg`, `--plugin-signin-fg`, `--plugin-signin-border`, `--plugin-signin-radius`,
|
|
85
|
+
`--plugin-signin-height`, `--plugin-signin-padding`, `--plugin-signin-gap`, `--plugin-signin-font`,
|
|
86
|
+
`--plugin-signin-font-size`, `--plugin-signin-font-weight`, `--plugin-signin-line-height`,
|
|
87
|
+
`--plugin-signin-letter-spacing`, `--plugin-signin-hover-shadow`, `--plugin-signin-active-shadow`,
|
|
88
|
+
`--plugin-signin-focus-color`, `--plugin-signin-focus-width`, `--plugin-signin-focus-offset`.
|
|
89
|
+
|
|
90
|
+
Keep Google's fixed sign-in wording, approved palette, spacing, visible focus, and full-color G.
|
|
91
|
+
Do not stretch, recolor, filter or replace the mark. The mark stays on white in all variants.
|
|
92
|
+
Prefer Google Sans Medium when the host theme supplies it; the offline fallback is Arial.
|
|
93
|
+
Check the current [Google branding rules](https://developers.google.com/identity/branding-guidelines)
|
|
94
|
+
before visual acceptance. The unchanged gradient SVG is bundled from
|
|
95
|
+
[SVGL](https://github.com/pheralb/svgl/blob/main/static/library/google.svg)
|
|
96
|
+
(Git blob `8ab1351b939a780aa05645d31e5bed3fcf5c5493`); Google's trademarks remain Google's.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"providers": [
|
|
4
|
+
{
|
|
5
|
+
"id": "google",
|
|
6
|
+
"label": "Google",
|
|
7
|
+
"issuer": "https://accounts.google.com",
|
|
8
|
+
"authorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth",
|
|
9
|
+
"tokenEndpoint": "https://oauth2.googleapis.com/token",
|
|
10
|
+
"jwksUri": "https://www.googleapis.com/oauth2/v3/certs",
|
|
11
|
+
"scopes": ["openid", "email", "profile"],
|
|
12
|
+
"signingAlgorithm": "RS256",
|
|
13
|
+
"clientAuthentication": "client_secret_post",
|
|
14
|
+
"pkce": "S256",
|
|
15
|
+
"credentialLabel": "google-login/google OAuth client",
|
|
16
|
+
"credentialCardTarget": "google-login-oauth-client",
|
|
17
|
+
"authoritativeEmailDomains": ["gmail.com", "googlemail.com"],
|
|
18
|
+
"hostedDomainClaim": "hd"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Google Workspace plugin (`google-workspace`)
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
One bundled Agent Plugin for everything Google: the Google sign-in and every Google product as a
|
|
6
|
+
module. v1 ships Sheets. All Google-specific code (endpoints, scopes, request formats, response
|
|
7
|
+
parsing, revocation, later the Picker) lives here; host core and Jini core name no provider. It ships
|
|
8
|
+
as `@jini-ai/agent-plugins/google-workspace` (this folder, `packages/agent-plugins/google-workspace`).
|
|
9
|
+
A host that selects it stages the payload into its bundled-source root and seeds it **disabled**;
|
|
10
|
+
the bundled-digest ledger records its digest at seed time, which the executable trust gate checks
|
|
11
|
+
before any module loads. Host-specific chat guidance (exact tool names, admin paths, the OAuth
|
|
12
|
+
callback route) lives in the host's overlay for this plugin, not here.
|
|
13
|
+
|
|
14
|
+
## Layout
|
|
15
|
+
|
|
16
|
+
| Path | What it is |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `plugin.json`, `mcp.json` | Manifest (no MCP servers). |
|
|
19
|
+
| `tovu-connections.json` | The sign-in (the `tovu-*.json` files are the Tovu host's contribution contracts; their names and keys are frozen): Google endpoints, per-client-kind parameters, Picker start params and the `picked_file_ids` callback field, and the NAMES of the host config holding the client ids (`TOVU_GOOGLE_WORKSPACE_OAUTH_{WEB,DESKTOP}_CLIENT_{ID,SECRET}`, placeholders until the Cloud project exists). `byoClient: true` lets a workspace admin use their own OAuth client instead (saved as the custom credential `google-workspace/account OAuth client`; see `skills/google-workspace/references/connection.md`). |
|
|
20
|
+
| `products.json` | Product catalog: id, label, tool prefix (every tool named with it belongs to the product and is gated by it; a security pin enumerates them), required scopes. Sheets = `drive.file` only. |
|
|
21
|
+
| `tovu-agent-tools.json` + `tools/` | Executable tools (the host adds its own prefix to each name): `account_info`, and the Sheets tools in `tools/sheets/` (see its README). |
|
|
22
|
+
| `tovu-admin-surfaces.json` + `ui/` | Plugin-owned admin surfaces: the connection panel, Choose spreadsheet (Picker) and Form destinations, each with a server-side `handlerModule`. See `ui/README.md`. |
|
|
23
|
+
| `tovu-form-destinations.json` + `form-destinations/` | Push-only `google-sheets` destination (no `receive`): frozen column mapping, bounded submission-id dedupe, contract-shaped results. See `form-destinations/README.md`. Not yet called by the host's forms fan-out (wired in a later lot). |
|
|
24
|
+
| `lib/` | Shared code (below). |
|
|
25
|
+
| `skills/google-workspace/` | What the in-app assistant reads. |
|
|
26
|
+
| `__tests__/` | Plugin-local `node:test` suites (not shipped): `fake-google-http.mjs` (127.0.0.1 fake Google), the OAuth contract pins and the skill-content pins. |
|
|
27
|
+
|
|
28
|
+
## `lib/` public API
|
|
29
|
+
|
|
30
|
+
| Module | Exports |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `auth.mjs` | Default `{ createHooks({ http }) }`: Jini's `ApiConnectionHooks` (`resolveScopes`, `readAccount`, `revoke`). `GOOGLE_REVOCATION_ENDPOINT`, `GOOGLE_ACCOUNT_ACCESS_PAGE`. |
|
|
33
|
+
| `http.mjs` | `createGoogleClient({ http, connections }, { sleep, random }).request({ connectionRef, url, method, query, body, kind })`; `accessTokenFor`; `buildUrl`; `pathSegment` (one encoded path segment, dot segments refused). |
|
|
34
|
+
| `drive-files.mjs` | `readAccount({ client, connectionRef })` via Drive `about.get` (works under `drive.file`); `copyFile({ client, connectionRef, fileId, name }, { parentId })` via Drive `files.copy`. |
|
|
35
|
+
| `sheets-client.mjs` | The one Sheets client: `sheetsClientFor({ capabilities })` (Sheets product gate per request), `googleClientFor` (ungated: delivery), `sendSheetsRequest`, `assertSpreadsheetId` / `SPREADSHEET_ID_PATTERN` (the one id rule, also every schema's pattern), `sheetsUrl` (the one URL builder; checks the id), `getSpreadsheet`, `runBatchUpdate` (inventory check first), `valueInputOption` (RAW default), `SHEETS_LIMITS` and the bounds (`boundValueRanges`, `assertPayloadWithin`, `outputFits` / `assertOutputWithin`, `spreadsheetFields` (grid-data masks need the opt-in)), `resourceLines` (card lines), `spreadsheetLine`, `tabsOf`. |
|
|
36
|
+
| `sheets-a1.mjs` | A1 helpers, one name and contract each: `columnName`, `columnIndex`, `parseA1Range` (Google's normalized range → parts or undefined), `parseBoundsA1` (a plain `A1:B2` → bounds, else `invalid-input`), `tabRange` (tab-title quoting), `gridRangeLabel` (card label). |
|
|
37
|
+
| `product-state.mjs` | `PRODUCTS` (validated catalog), `scopesFor({ productIds })`, and the product gate: `PRODUCT_STATE_KEY` (`'products'`), `isProductEnabled({ state, productId })`, `assertProductEnabled(...)` (`product-disabled`); form delivery uses `isProductEnabled` over the read-only plugin-state reader the host passes as `optional.state`. A product is off unless the plugin's settings hold `products: { <id>: true }`. |
|
|
38
|
+
| `picker-selection.mjs` | `verifySelectedSpreadsheet({ client, connectionRef, fileId, expectedAccountId })` (plain Drive id, same account, spreadsheet MIME via Drive `files.get`, real access via Sheets `spreadsheets.get`; metadata only), `verifySpreadsheetAccess({ client, connectionRef, fileId })` (live spreadsheet per Drive, then opened by the Sheets API; shared with `sheets_open_existing`), `parsePickedFileIds({ value })` (desktop callback field → one id or null on cancel), `DESKTOP_PICKER_START_PARAMS`, `SPREADSHEET_MIME_TYPE`. |
|
|
39
|
+
| `surface-actions.mjs` | For the admin-surface handlers: `CONNECTION_ID` (`'account'`), `surfaceSheetsClient({ capabilities })` (the one Sheets client over the surface port, which names connections by id), `surfaceAccount`, `sheetsUnavailableReason`, `withSurfaceFailures` (plugin/host refusals → surface-safe failures), `surfaceRefusal`. |
|
|
40
|
+
| `picker-flow.mjs` | `createPickerFlow({}, { now, randomId })` → `bootstrap` (hosted session + short-lived token, or `system-browser` / `unavailable`), `verifyHosted`, `startSystemBrowser`, `finishSystemBrowser`; `readPickedSpreadsheets`, `rememberPickedSpreadsheet`, `PICKER_CONFIG_NAMES`, `PICKER_SESSION_TTL_MS`. |
|
|
41
|
+
| `form-destination-settings.mjs` | `readFormDestinations({ deliveryBindings })`, `saveFormDestination({ deliveryBindings, formSlug, destination }, { bindingId })`, `SHEETS_ADAPTER_ID`, `FORM_SLUG_PATTERN`, `MAX_FORM_DESTINATIONS`. |
|
|
42
|
+
| `request-policy.mjs` | `GoogleWorkspaceError`, `classifyFailure`, `parseRetryAfter`, `backoffMs`, `shouldRetryInline`. |
|
|
43
|
+
|
|
44
|
+
## Rules the plugin keeps
|
|
45
|
+
|
|
46
|
+
- Tokens come only from the host capability, right before each request; only access tokens ever
|
|
47
|
+
reach the plugin, and none is returned, logged or put in an error.
|
|
48
|
+
- Errors carry a fixed message per code, never Google's text (it echoes ranges and values).
|
|
49
|
+
- `kind: 'write'` (append, create, copy) with an unknown outcome is `uncertain-outcome` and never
|
|
50
|
+
retried; reads and idempotent writes retry with jittered backoff that honours Retry-After.
|
|
51
|
+
- Revocation is confirmed only by Google's 200 (or `invalid_token`: already revoked); a failed
|
|
52
|
+
revoke throws `provider-unreachable` or `provider-refused` for the host to record.
|
|
53
|
+
- An access token can expire in flight: a 401 gets one more token read and is retried only if the
|
|
54
|
+
token differs. An unchanged token or second 401 is `reconnect-required`. Tools and form delivery share this.
|
|
55
|
+
- The budgets declared in `tovu-connections.json` (45/min per account, 240/min per instance) are
|
|
56
|
+
charged by the host on every token read; its refusal (`rate-limited`) becomes `quota-delay` with
|
|
57
|
+
the host's `retryAfterMs` and `outcome: 'not-applied'`, and follows the same inline backoff rules
|
|
58
|
+
as Google's 429 (nothing was sent, so even an append may wait and retry).
|
|
59
|
+
- Modules import nothing outside this directory except what the host injects.
|
|
60
|
+
|
|
61
|
+
## How it fits
|
|
62
|
+
|
|
63
|
+
The host reads each contribution file and runs it on Jini's services: `tovu-connections.json` on
|
|
64
|
+
`@jini-ai/integrations/api-connections`; `tovu-agent-tools.json` on
|
|
65
|
+
`@jini-ai/agent-plugins/lifecycle/tools`; `tovu-admin-surfaces.json` on
|
|
66
|
+
`@jini-ai/agent-plugins/admin-surfaces`; `tovu-form-destinations.json` through the host's form
|
|
67
|
+
destination registry. In Tovu those readers are `features/api-connections/contribution.ts`,
|
|
68
|
+
`features/agent-plugins/executable-tools`, `features/agent-plugins/admin-surfaces` and
|
|
69
|
+
`features/form-destinations/registry.ts`.
|
|
70
|
+
|
|
71
|
+
## Extension points
|
|
72
|
+
|
|
73
|
+
A new product adds an entry to `products.json` (its scopes), its tools to `tovu-agent-tools.json`
|
|
74
|
+
under its own prefix, and its modules under `products/<id>/`, all on the same sign-in.
|
|
75
|
+
|
|
76
|
+
## Known gaps
|
|
77
|
+
|
|
78
|
+
- Duplicate submission ids found in a tab are delivered as `ok` with a "review" note in
|
|
79
|
+
`reference`, which the forms ledger drops; the contract has no reviewable success outcome, so
|
|
80
|
+
no person sees it yet (Jini contract change needed for a distinct outcome).
|
|
81
|
+
- Form-delivery retry window: product-off (`adapter-unavailable`) and quota (`rate-limited`)
|
|
82
|
+
refusals are retried within the site's forms retry policy (about 8 attempts over about a day),
|
|
83
|
+
then the job fails; authorization failures (`authorization-required`) fail at once and stay as
|
|
84
|
+
failed jobs, with no automatic re-send after a reconnect. Owner decision pending on longer holds
|
|
85
|
+
(for example a `paused` outcome resumed by turning the product on or reconnecting).
|
|
86
|
+
|
|
87
|
+
- Products are read from the plugin's settings and default to OFF; the connection panel writes them,
|
|
88
|
+
and the host hands form delivery the same settings (read-only) as `optional.state`.
|
|
89
|
+
- The Form destinations panel saves each form's choice as the form's delivery binding through the
|
|
90
|
+
host's `deliveryBindings` capability (both the destination and connection surfaces declare
|
|
91
|
+
`deliveryAdapters: ["google-sheets"]`): one binding per form slug, on this plugin's `account`
|
|
92
|
+
connection, config = spreadsheet, tab (numeric sheet id), the frozen `accountId`, plus the names the
|
|
93
|
+
panels show. The host writes it to `core.forms/deliveryBindings` as the signed-in admin.
|
|
94
|
+
- Panels (G13b) are built and unit-tested only; they need a Chrome check (hosted Picker inside the
|
|
95
|
+
sandboxed frame, desktop system-browser Picker, the account-page button). The host says whether it
|
|
96
|
+
is hosted or desktop (`context.hostKind`); a hosted site also needs both
|
|
97
|
+
`TOVU_GOOGLE_PICKER_BROWSER_KEY` and `TOVU_GOOGLE_PICKER_APP_ID` (placeholders, never real values)
|
|
98
|
+
or the Picker is `picker-not-configured`. The panels cannot open links themselves (no
|
|
99
|
+
`allow-popups`): the connection panel declares `linkOrigins: ["https://myaccount.google.com"]` and
|
|
100
|
+
asks the admin page to open the Google account-access page (`client.openLink`); the address also
|
|
101
|
+
stays visible as text.
|
|
102
|
+
- Quotas: the per-account (45/min) and per-instance (240/min) budgets declared in
|
|
103
|
+
`tovu-connections.json` are implemented by the host and charged on every token read; there is no
|
|
104
|
+
plugin-side limiter. Form delivery reads a token per Google request (`credential.getAccessToken`),
|
|
105
|
+
so it is charged per request like the tools.
|
|
106
|
+
- Hosted Picker sessions are kept one per surface (`picker-session:<surfaceId>`), not per tab or
|
|
107
|
+
admin: opening the same panel in a second tab (or by a second admin) starts a new session and
|
|
108
|
+
the first tab's pick is refused as `picker-expired`. Documented in
|
|
109
|
+
`skills/google-workspace/references/troubleshooting.md`; not changed (a pick only needs one
|
|
110
|
+
open panel).
|
|
111
|
+
|
|
112
|
+
## Sheets admin tools (`tools/sheets-admin/`, lot G12)
|
|
113
|
+
|
|
114
|
+
Eleven tools for tabs, sorting, filters, filter views, formatting, named and protected ranges,
|
|
115
|
+
developer metadata, raw batch updates, CSV export and opening a picked file; see
|
|
116
|
+
`tools/sheets-admin/README.md`. Their helpers:
|
|
117
|
+
|
|
118
|
+
| Module | Exports |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `lib/sheets-admin-request-kinds.mjs` | `REQUEST_KINDS` (the reviewed batchUpdate inventory: effect, replay class, required fields), `checkRequests`, `describeRequests`, batch bounds. |
|
|
121
|
+
| `lib/sheets-admin-csv.mjs` | `planChunks`, `encodeCsv`, export bounds. |
|
|
122
|
+
|
|
123
|
+
These tools use the plugin-side codes `invalid-input` and `limit-exceeded` (shared with the values
|
|
124
|
+
tools) and add `unsupported-request`; `sheets_open_existing` reuses the Picker's `not-a-spreadsheet`, plus an optional plugin-composed `detail`
|
|
125
|
+
on `GoogleWorkspaceError` (a request index or kind name, never Google's text or a cell value).
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
Hosted sign-in asks for consent when Jini has no renewable credential stored (`forceConsentParams`),
|
|
129
|
+
so connecting another workspace can obtain offline access. Renewable same-account reconnects keep
|
|
130
|
+
incremental grants without forcing consent. Client settings now use the full plugin namespace,
|
|
131
|
+
`TOVU_GOOGLE_WORKSPACE_OAUTH_{WEB,DESKTOP}_CLIENT_{ID,SECRET}`. Provision these settings
|
|
132
|
+
before enabling this build; the earlier abbreviated names were never configured, so no fallback is needed.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# `form-destinations/` — the Google Sheets form destination
|
|
2
|
+
|
|
3
|
+
**Purpose.** Push each accepted form submission into a Google Sheets tab as one row, at least once,
|
|
4
|
+
with best-effort dedupe. Declared in `../tovu-form-destinations.json` as `google-sheets` (push only,
|
|
5
|
+
no `receive`), part of the Sheets product (`../products.json`).
|
|
6
|
+
|
|
7
|
+
## Public API
|
|
8
|
+
|
|
9
|
+
`sheets.mjs` default export: `deliver(required, optional)`, the forms delivery contract's push
|
|
10
|
+
method (see the forms-multidest delivery contract, §2–§3).
|
|
11
|
+
|
|
12
|
+
- `required`: `{ kit, credential, form, submission, binding, idempotencyKey }` exactly as the host
|
|
13
|
+
hands a frozen job. `credential.token` is the access token the host resolved for this attempt
|
|
14
|
+
from `binding.connectionRef`; it is used only in the `authorization` header.
|
|
15
|
+
- `optional`: `{ state? }` — the host's read-only reader of this plugin's settings (the store the
|
|
16
|
+
connection panel writes). Unless it holds `products.sheets === true` (no reader counts as off),
|
|
17
|
+
delivery refuses as `adapter-unavailable`, retryable: while Sheets is off a job is retried within
|
|
18
|
+
the forms retry policy (about 8 attempts over about a day), then marked failed; no reader never enables delivery.
|
|
19
|
+
- `credential.getAccessToken()` (OAuth connections): one fresh token per Google request, so the
|
|
20
|
+
host's declared budgets count every request and the frozen account is re-checked each time.
|
|
21
|
+
- Returns a `FormDeliveryResult`; it never throws.
|
|
22
|
+
|
|
23
|
+
Binding config (non-secret, frozen with the job):
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{ "spreadsheetId": "…", "accountId": "…", "tabs": { "<formSlug>": { "sheetId": 7 } } }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`accountId` is read by the host, not this module: the OAuth connection port refuses a job whose
|
|
30
|
+
connection now holds another account (`authorization-required`), and a binding without one
|
|
31
|
+
(`configuration-invalid`).
|
|
32
|
+
|
|
33
|
+
Which column holds which field is kept in the sheet, not the binding: the first time a field is
|
|
34
|
+
written, its column gets a developer-metadata marker (`tovu_form_field` = the field id, visibility
|
|
35
|
+
`DOCUMENT`, attached to that one column). The marker decides the column from then on, so a label
|
|
36
|
+
edited in the form, a header renamed in the sheet, or columns inserted or moved before it keep the
|
|
37
|
+
field's data in one column (Google moves column metadata with the column). An untagged field is
|
|
38
|
+
matched by its label in row 1 and tagged, or gets a new column at the end (header = label, else
|
|
39
|
+
field id). Chosen over the binding because the adapter cannot write the host's binding, and over a
|
|
40
|
+
header note because people neither see nor edit metadata. Growing the grid, writing new headers
|
|
41
|
+
(as text cells) and tagging columns happen in ONE atomic `batchUpdate` before the append.
|
|
42
|
+
|
|
43
|
+
More than one row already holding the submission id (another writer, or a late append racing the
|
|
44
|
+
scan) is reported as delivered with the reference `sheet <id> rows <a>, <b> (duplicate submission
|
|
45
|
+
id: review)`; nothing is appended again. Nobody sees that note today: the forms delivery contract has no reviewable success outcome and the ledger keeps only `ok` (the
|
|
46
|
+
reference is dropped), and any `ok: false` code would fail or re-try a row that is already
|
|
47
|
+
there. A distinct outcome needs a contract change in Jini (`@jini-ai/cms/forms`).
|
|
48
|
+
|
|
49
|
+
## Files
|
|
50
|
+
|
|
51
|
+
| file | job |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `sheets.mjs` | One attempt: product check → tab by sheet id → header row + column markers → dedupe scan → one atomic column batch (grid, headers, markers) → RAW append → re-scan after a lost answer. |
|
|
54
|
+
| `mapping.mjs` | Field id ↔ marker / header ↔ column (`buildMapping`, `readMarkers`, `planColumns`); mapping errors; the RAW row (checkbox groups joined `", "`). |
|
|
55
|
+
| `dedupe.mjs` | Scans the `_tovu_submission_id` column in 5,000-row chunks; > 50,000 data rows → `destination-limit`. |
|
|
56
|
+
| `delivery-result.mjs` | The contract's result shape and the plugin-error → contract-code table. |
|
|
57
|
+
|
|
58
|
+
## How it fits
|
|
59
|
+
|
|
60
|
+
All HTTP goes through `../lib/sheets-client.mjs` → `../lib/http.mjs` (the one Google client:
|
|
61
|
+
retries, Retry-After, safe errors; quotas are the host's budgets). The host's `kit.fetch(url, init, { timeoutMs })` is
|
|
62
|
+
the transport. The forms ledger (outbox) owns attempts, backoff and terminal state; this module
|
|
63
|
+
only reports one attempt's outcome.
|
|
64
|
+
|
|
65
|
+
Not exactly-once: there are no row reservations, and a late append or another writer can race the
|
|
66
|
+
scan. Two bindings writing to the same tab share the id column, so the second sees the first's row
|
|
67
|
+
as already delivered.
|
|
68
|
+
|
|
69
|
+
## Extension points
|
|
70
|
+
|
|
71
|
+
A second destination (another product or another provider plugin) copies this shape: a
|
|
72
|
+
push-only descriptor, a `deliver` that maps its own errors to the nine contract codes, and its own
|
|
73
|
+
dedupe. Nothing here is imported by a host's core or Jini core.
|
|
74
|
+
|
|
75
|
+
## Wiring (G14b) and what is left
|
|
76
|
+
|
|
77
|
+
Wired: the host composes the OAuth connection port ahead of saved custom credentials, so a binding
|
|
78
|
+
whose `connectionRef` is this plugin's connection resolves through it (`additionalHosts` = the
|
|
79
|
+
declared `apiOrigins` after the first, as exact origins); delivery gets `optional.state` and a
|
|
80
|
+
per-request `getAccessToken()`.
|
|
81
|
+
|
|
82
|
+
Bindings (G14c): the destination panel saves each form's choice as its binding through the host's
|
|
83
|
+
admin-surface `deliveryBindings` capability (`../lib/form-destination-settings.mjs`). The binding names
|
|
84
|
+
the form by slug (the host stores the form's row id) and the `account` connection by id (the host
|
|
85
|
+
stores its own ref); an admin clicking Save on the panel is the human action. A binding whose plugin
|
|
86
|
+
is disabled is not delivered (`adapter-unavailable`, retryable: the host reads only active plugins'
|
|
87
|
+
adapters on every attempt), the same code as Sheets turned off here.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Best-effort dedupe for the Google Sheets form destination: finds the rows whose
|
|
3
|
+
* `_tovu_submission_id` cell holds a submission id, by reading that one column in bounded chunks.
|
|
4
|
+
*
|
|
5
|
+
* Exists because a retry (a lost answer, a replayed job) must not add the same submission twice,
|
|
6
|
+
* and Google's append has no idempotency key. The delivery scans before every append and again
|
|
7
|
+
* after an append whose outcome is unknown. The scan covers every row of the tab's grid, so
|
|
8
|
+
* "not found" is a complete answer; a tab with more than {@link DEDUPE_LIMITS.maxScannedRows} data
|
|
9
|
+
* rows is refused as `destination-limit` rather than appended to on an incomplete search.
|
|
10
|
+
*
|
|
11
|
+
* This is NOT exactly-once: a late append or another writer can still race the scan, and there
|
|
12
|
+
* are no row reservations. Delivery is at-least-once with best-effort dedupe (documented in
|
|
13
|
+
* `references/form-delivery.md`).
|
|
14
|
+
*/
|
|
15
|
+
import { DeliveryRefusal } from './delivery-result.mjs';
|
|
16
|
+
|
|
17
|
+
export const DEDUPE_LIMITS = Object.freeze({ maxScannedRows: 50_000, chunkRows: 5_000 });
|
|
18
|
+
|
|
19
|
+
/** Row numbers (1-based) in `[from, to]` of a chunk whose cell equals the id. @complexity O(rows). */
|
|
20
|
+
function matchesIn(values, from, submissionId) {
|
|
21
|
+
return values.flatMap((row, offset) => (String(row?.[0] ?? '').trim() === submissionId ? [from + offset] : []));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every data row (row 2 onwards) whose id cell equals `submissionId`, in sheet order.
|
|
26
|
+
* @param {{ readColumn: (input: { from: number, to: number }) => Promise<readonly (readonly unknown[])[]>, rowCount: number, submissionId: string }} required
|
|
27
|
+
* `readColumn` returns the id column's rows `from..to` (rows-major; Google omits trailing blanks).
|
|
28
|
+
* `rowCount` is the tab's grid row count, header included.
|
|
29
|
+
* @returns {Promise<number[]>}
|
|
30
|
+
* @throws {DeliveryRefusal} `destination-limit` (not retryable) when the tab has more data rows
|
|
31
|
+
* than the scan ceiling; nothing is read then.
|
|
32
|
+
* @complexity O(rowCount / chunkRows) reads, O(rowCount) cells, each read ≤ chunkRows cells.
|
|
33
|
+
*/
|
|
34
|
+
export async function findSubmissionRows(required, _optional = {}) {
|
|
35
|
+
const { readColumn, rowCount, submissionId } = required;
|
|
36
|
+
if (rowCount - 1 > DEDUPE_LIMITS.maxScannedRows) throw new DeliveryRefusal({ code: 'destination-limit', retryable: false });
|
|
37
|
+
const found = [];
|
|
38
|
+
for (let from = 2; from <= rowCount; from += DEDUPE_LIMITS.chunkRows) {
|
|
39
|
+
const to = Math.min(rowCount, from + DEDUPE_LIMITS.chunkRows - 1);
|
|
40
|
+
found.push(...matchesIn(await readColumn({ from, to }), from, submissionId));
|
|
41
|
+
}
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What the Google Sheets form destination reports back to the forms delivery ledger: the
|
|
3
|
+
* contract's `FormDeliveryResult` and the translation from the plugin's safe Google errors to the
|
|
4
|
+
* contract's nine codes.
|
|
5
|
+
*
|
|
6
|
+
* Exists because only the bare code, `retryable`, `retryAfterMs` and `outcome` ever reach the
|
|
7
|
+
* ledger (the host drops everything else, and a thrown error would become a generic retryable
|
|
8
|
+
* failure). So every failure of a delivery is turned into one of those values here, in one table,
|
|
9
|
+
* and no message, Google text, token or answer ever leaves the adapter. What the person does next
|
|
10
|
+
* for each code is documented in `skills/google-workspace/references/form-delivery.md`.
|
|
11
|
+
*/
|
|
12
|
+
import { GoogleWorkspaceError } from '../lib/request-policy.mjs';
|
|
13
|
+
|
|
14
|
+
const DELIVERY_FAILED = 'delivery-failed';
|
|
15
|
+
|
|
16
|
+
/** A refusal decided by the adapter itself (config, mapping, dedupe limit, product off). */
|
|
17
|
+
export class DeliveryRefusal extends Error {
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ code: string, retryable: boolean }} required A contract code; `message` is the code.
|
|
20
|
+
* @param {{ outcome?: 'not-applied' | 'unknown' }} [optional] Defaults to `not-applied`: every
|
|
21
|
+
* adapter refusal happens before the append is sent, except the uncertain-append verdict.
|
|
22
|
+
*/
|
|
23
|
+
constructor(required, optional = {}) {
|
|
24
|
+
super(required.code);
|
|
25
|
+
this.name = 'DeliveryRefusal';
|
|
26
|
+
this.code = required.code;
|
|
27
|
+
this.retryable = required.retryable;
|
|
28
|
+
this.outcome = optional.outcome ?? 'not-applied';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Plugin safe code → [contract code, retryable]. Authorization problems are not retried: the job
|
|
34
|
+
* fails at once and stays in the ledger as failed (it is not re-sent after a reconnect). Quota,
|
|
35
|
+
* product-off and transient failures are retried within the forms retry policy (about 8 attempts
|
|
36
|
+
* over about a day), then the job fails.
|
|
37
|
+
*/
|
|
38
|
+
const CONTRACT_CODES = new Map([
|
|
39
|
+
['not-connected', ['authorization-required', false]], ['reconnect-required', ['authorization-required', false]],
|
|
40
|
+
['account-changed', ['authorization-required', false]], ['missing-permission', ['authorization-required', false]],
|
|
41
|
+
['file-not-granted', ['resource-not-granted', false]], ['access-denied', ['resource-not-granted', false]],
|
|
42
|
+
['admin-policy', ['resource-not-granted', false]],
|
|
43
|
+
['quota-delay', ['rate-limited', true]],
|
|
44
|
+
['invalid-request', ['mapping-invalid', false]], ['invalid-input', ['configuration-invalid', false]],
|
|
45
|
+
['limit-exceeded', ['destination-limit', false]],
|
|
46
|
+
['uncertain-outcome', ['outcome-unknown', true]],
|
|
47
|
+
['product-disabled', ['adapter-unavailable', true]],
|
|
48
|
+
['conflict', [DELIVERY_FAILED, true]], ['service-unavailable', [DELIVERY_FAILED, true]], ['network-error', [DELIVERY_FAILED, true]],
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/** `{ ok: false, error }` with only the fields the contract keeps. @complexity O(1). */
|
|
52
|
+
export function failureResult(required, _optional = {}) {
|
|
53
|
+
const { code, retryable, outcome, retryAfterMs } = required;
|
|
54
|
+
return { ok: false, error: { code, retryable, ...(outcome ? { outcome } : {}), ...(retryAfterMs === undefined ? {} : { retryAfterMs }) } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The result a failed delivery reports. A plugin error keeps its outcome and Retry-After; anything
|
|
59
|
+
* unrecognised is a retryable `delivery-failed` with no outcome claimed, and its text is dropped.
|
|
60
|
+
* @param {{ error: unknown }} required
|
|
61
|
+
* @complexity O(1).
|
|
62
|
+
*/
|
|
63
|
+
export function resultForError(required, _optional = {}) {
|
|
64
|
+
const { error } = required;
|
|
65
|
+
if (error instanceof DeliveryRefusal) return failureResult(error);
|
|
66
|
+
if (!(error instanceof GoogleWorkspaceError)) return failureResult({ code: DELIVERY_FAILED, retryable: true });
|
|
67
|
+
const [code, retryable] = CONTRACT_CODES.get(error.code) ?? [DELIVERY_FAILED, false];
|
|
68
|
+
return failureResult({ code, retryable, outcome: error.outcome, retryAfterMs: error.retryAfterMs });
|
|
69
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Column mapping for the Google Sheets form destination: which column each form field is
|
|
3
|
+
* written to, which new columns a delivery adds, and the row of RAW values one submission becomes.
|
|
4
|
+
*
|
|
5
|
+
* Exists so a spreadsheet's columns never silently change meaning. Fields are identified by their
|
|
6
|
+
* stable field id (`name`); the label is only the header text. The first time the plugin writes a field
|
|
7
|
+
* to a column it tags that column with a developer-metadata marker (`tovu_form_field` = field id,
|
|
8
|
+
* attached to the column itself). From then on the marker decides the column: a label edited in
|
|
9
|
+
* the form, a header renamed in the sheet, or columns inserted or moved before it do not move the
|
|
10
|
+
* field's data (Google moves column metadata with its column). Why a marker in the sheet and not
|
|
11
|
+
* the binding: the delivery adapter cannot write the host's binding, and a marker needs no state
|
|
12
|
+
* outside the spreadsheet it describes; unlike a header note, people do not see or edit it.
|
|
13
|
+
* An untagged field is matched by its label in row 1 (and tagged), else added as a new column at
|
|
14
|
+
* the end. A tab that should have the plugin's columns but lacks them, a header found twice, a field
|
|
15
|
+
* tagged on two columns, or two entries landing on one column is a mapping error the person
|
|
16
|
+
* repairs; nothing is appended into a sheet whose columns cannot be matched. The visitor's IP is
|
|
17
|
+
* never a field, and answers that are not fields of the frozen form are never written.
|
|
18
|
+
*/
|
|
19
|
+
import { DeliveryRefusal } from './delivery-result.mjs';
|
|
20
|
+
|
|
21
|
+
// These names keep their `tovu` prefix: deliveries have already written them into people's
|
|
22
|
+
// spreadsheets, and a renamed header or marker key would no longer match those columns.
|
|
23
|
+
export const SUBMISSION_ID_HEADER = '_tovu_submission_id';
|
|
24
|
+
export const SUBMITTED_AT_HEADER = '_tovu_submitted_at';
|
|
25
|
+
/** Developer-metadata key tagging a column with the form field id it holds. */
|
|
26
|
+
export const FIELD_MARKER_KEY = 'tovu_form_field';
|
|
27
|
+
|
|
28
|
+
const mappingInvalid = () => new DeliveryRefusal({ code: 'mapping-invalid', retryable: false });
|
|
29
|
+
|
|
30
|
+
/** One answer as a RAW cell: a checkbox group is joined with ", "; scalars keep their type. @complexity O(items). */
|
|
31
|
+
export function cellValue(required, _optional = {}) {
|
|
32
|
+
const { value } = required;
|
|
33
|
+
if (value === undefined || value === null) return '';
|
|
34
|
+
if (Array.isArray(value)) return value.map((item) => String(item)).join(', ');
|
|
35
|
+
return typeof value === 'number' || typeof value === 'boolean' ? value : String(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The mapping for one job: the two plugin columns, then one entry per field of the frozen form.
|
|
40
|
+
* @param {{ form: { fields: readonly { name: string, label?: string }[] } }} required
|
|
41
|
+
* @returns {{ header: string, field?: string, source?: 'id' | 'submittedAt' }[]}
|
|
42
|
+
* @throws {DeliveryRefusal} `mapping-invalid` when two entries would share a header.
|
|
43
|
+
* @complexity O(fields).
|
|
44
|
+
*/
|
|
45
|
+
export function buildMapping(required, _optional = {}) {
|
|
46
|
+
const entries = [
|
|
47
|
+
{ header: SUBMISSION_ID_HEADER, source: 'id' },
|
|
48
|
+
{ header: SUBMITTED_AT_HEADER, source: 'submittedAt' },
|
|
49
|
+
// Trimmed like the sheet's cells are, so stray spaces never decide a match; a blank label falls back to the id.
|
|
50
|
+
...required.form.fields.map((field) => ({ field: field.name, header: String(field.label ?? '').trim() || field.name })),
|
|
51
|
+
];
|
|
52
|
+
if (new Set(entries.map((entry) => entry.header)).size !== entries.length) throw mappingInvalid();
|
|
53
|
+
return entries;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether a dimension range is exactly one column of this tab. @complexity O(1). */
|
|
57
|
+
const isOneColumnOf = (range, sheetId) => range?.sheetId === sheetId && range.dimension === 'COLUMNS' && range.endIndex - range.startIndex === 1;
|
|
58
|
+
|
|
59
|
+
/** One column marker as `{ field, index }`; anything not exactly one column of this tab is ignored. @complexity O(1). */
|
|
60
|
+
function columnMarker(metadata, sheetId) {
|
|
61
|
+
const range = metadata?.location?.dimensionRange;
|
|
62
|
+
const isMarker = metadata?.metadataKey === FIELD_MARKER_KEY && typeof metadata.metadataValue === 'string';
|
|
63
|
+
return isMarker && isOneColumnOf(range, sheetId) ? { field: metadata.metadataValue, index: range.startIndex } : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Field id → the columns tagged with it, from a `developerMetadata:search` answer.
|
|
68
|
+
* @param {{ matches: readonly unknown[], sheetId: number }} required `matchedDeveloperMetadata`.
|
|
69
|
+
* @returns {Map<string, number[]>}
|
|
70
|
+
* @complexity O(matches).
|
|
71
|
+
*/
|
|
72
|
+
export function readMarkers(required, _optional = {}) {
|
|
73
|
+
const markers = new Map();
|
|
74
|
+
for (const match of required.matches) {
|
|
75
|
+
const marker = columnMarker(match?.developerMetadata, required.sheetId);
|
|
76
|
+
if (marker) markers.set(marker.field, [...(markers.get(marker.field) ?? []), marker.index]);
|
|
77
|
+
}
|
|
78
|
+
return markers;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const headerText = (cell) => String(cell ?? '').trim();
|
|
82
|
+
|
|
83
|
+
/** Untagged columns of row 1 whose header is `header`. @complexity O(width). */
|
|
84
|
+
function headerPositions(plan, header) {
|
|
85
|
+
return plan.headerRow.flatMap((cell, index) => (headerText(cell) === header && !plan.tagged.has(index) ? [index] : []));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A new column at the end, headed `header`. @complexity O(1). */
|
|
89
|
+
function addColumn(plan, header) {
|
|
90
|
+
const index = plan.width;
|
|
91
|
+
plan.width += 1;
|
|
92
|
+
plan.newHeaders.push({ index, header });
|
|
93
|
+
return index;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A plugin column (`_tovu_*` header): found once by its fixed header, or new on a fresh tab. @complexity O(width). */
|
|
97
|
+
function tovuColumn(plan, entry) {
|
|
98
|
+
const positions = headerPositions(plan, entry.header);
|
|
99
|
+
if (positions.length === 1) return positions[0];
|
|
100
|
+
if (positions.length > 1 || !plan.fresh) throw mappingInvalid();
|
|
101
|
+
return addColumn(plan, entry.header);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A field's column: its marker, else its header found once, else a new column; untagged ones get tagged. @complexity O(width). */
|
|
105
|
+
function fieldColumn(plan, entry, taggedAt) {
|
|
106
|
+
if (taggedAt.length > 1) throw mappingInvalid();
|
|
107
|
+
if (taggedAt.length === 1) return taggedAt[0];
|
|
108
|
+
const positions = headerPositions(plan, entry.header);
|
|
109
|
+
if (positions.length > 1) throw mappingInvalid();
|
|
110
|
+
const index = positions.length === 1 ? positions[0] : addColumn(plan, entry.header);
|
|
111
|
+
plan.untagged.push({ field: entry.field, index });
|
|
112
|
+
return index;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Where each mapping entry is written. An empty first row is a fresh tab. The plugin's two columns are
|
|
117
|
+
* found by their fixed headers (refused when missing from a tab that is not fresh); each field by
|
|
118
|
+
* its marker, else by its label (see the file comment).
|
|
119
|
+
* @param {{ mapping: readonly { header: string, field?: string, source?: string }[], headerRow: readonly unknown[], markers: ReadonlyMap<string, readonly number[]> }} required
|
|
120
|
+
* @returns {{ columns: number[], newHeaders: { index: number, header: string }[], untagged: { field: string, index: number }[], width: number }}
|
|
121
|
+
* `newHeaders` are contiguous at the end; `untagged` are the field columns to tag.
|
|
122
|
+
* @throws {DeliveryRefusal} `mapping-invalid` for a duplicated, missing or conflicting column.
|
|
123
|
+
* @complexity O(entries * width).
|
|
124
|
+
*/
|
|
125
|
+
export function planColumns(required, _optional = {}) {
|
|
126
|
+
const { mapping, markers } = required;
|
|
127
|
+
const fresh = required.headerRow.every((cell) => headerText(cell) === '');
|
|
128
|
+
const tagged = new Set([...markers.values()].flat());
|
|
129
|
+
const headerRow = fresh ? [] : required.headerRow;
|
|
130
|
+
const plan = { fresh, tagged, headerRow, width: Math.max(headerRow.length, ...[...tagged].map((index) => index + 1)), newHeaders: [], untagged: [] };
|
|
131
|
+
const columns = mapping.map((entry) => (entry.source ? tovuColumn(plan, entry) : fieldColumn(plan, entry, markers.get(entry.field) ?? [])));
|
|
132
|
+
if (new Set(columns).size !== columns.length) throw mappingInvalid();
|
|
133
|
+
return { columns, newHeaders: plan.newHeaders, untagged: plan.untagged, width: plan.width };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The appended row: each mapped value at its column, blanks elsewhere (columns of removed fields
|
|
138
|
+
* or someone else's columns stay empty for this row).
|
|
139
|
+
* @param {{ mapping: readonly { field?: string, source?: 'id' | 'submittedAt' }[], columns: readonly number[], width: number, submission: { id: string, submittedAt: string, answers: Readonly<Record<string, unknown>> } }} required
|
|
140
|
+
* @complexity O(width + entries).
|
|
141
|
+
*/
|
|
142
|
+
export function buildRow(required, _optional = {}) {
|
|
143
|
+
const { mapping, columns, submission } = required;
|
|
144
|
+
const row = Array.from({ length: required.width }, () => '');
|
|
145
|
+
const valueFor = (entry) => {
|
|
146
|
+
if (entry.source) return submission[entry.source];
|
|
147
|
+
// Own keys only: a field id such as `constructor` must not read the prototype.
|
|
148
|
+
return cellValue({ value: Object.hasOwn(submission.answers, entry.field) ? submission.answers[entry.field] : undefined });
|
|
149
|
+
};
|
|
150
|
+
mapping.forEach((entry, index) => { row[columns[index]] = valueFor(entry); });
|
|
151
|
+
return row;
|
|
152
|
+
}
|