@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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file CSV export planning and encoding for the `export_csv` tool: split one tab (or an A1 range of
|
|
3
|
+
* it) into bounded values reads, and encode the rows as RFC 4180 CSV.
|
|
4
|
+
*
|
|
5
|
+
* Exists because Google's own CSV export covers only a spreadsheet's first tab and an arbitrary tab
|
|
6
|
+
* would need an undocumented `gid` URL; bounded `values.batchGet` reads plus local encoding work for
|
|
7
|
+
* any tab. The size is bounded BEFORE the first read (from the tab's grid size or the range), and an
|
|
8
|
+
* export over a bound is an error, never a silently partial file. Text cells that start like a
|
|
9
|
+
* formula are prefixed with `'` so opening the file never runs a visitor's formula.
|
|
10
|
+
*/
|
|
11
|
+
import { GoogleWorkspaceError } from './request-policy.mjs';
|
|
12
|
+
import { columnName, tabRange } from './sheets-a1.mjs';
|
|
13
|
+
|
|
14
|
+
export const MAX_EXPORT_ROWS = 20_000;
|
|
15
|
+
export const MAX_EXPORT_CELLS = 100_000;
|
|
16
|
+
export const MAX_CSV_BYTES = 4 * 1024 * 1024;
|
|
17
|
+
/** Cells per read: keeps each answer far below the host's 1 MiB response bound. */
|
|
18
|
+
const CHUNK_CELLS = 10_000;
|
|
19
|
+
|
|
20
|
+
const tooLarge = (detail) => new GoogleWorkspaceError({ code: 'limit-exceeded' }, { detail });
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The reads covering `area` (zero-based, inclusive), in row order.
|
|
24
|
+
* @param {{ title: string, area: { firstRow: number, lastRow: number, firstColumn: number, lastColumn: number } }} required
|
|
25
|
+
* @returns {{ range: string, rowCount: number }[]}
|
|
26
|
+
* @throws {GoogleWorkspaceError} `limit-exceeded` when the area is over the row or cell bound.
|
|
27
|
+
* @complexity O(chunks), at most MAX_EXPORT_CELLS / CHUNK_CELLS.
|
|
28
|
+
*/
|
|
29
|
+
export function planChunks(required, _optional = {}) {
|
|
30
|
+
const { firstRow, lastRow, firstColumn, lastColumn } = required.area;
|
|
31
|
+
const rows = lastRow - firstRow + 1;
|
|
32
|
+
const columns = lastColumn - firstColumn + 1;
|
|
33
|
+
if (rows > MAX_EXPORT_ROWS) throw tooLarge(`at most ${MAX_EXPORT_ROWS} rows; give a range`);
|
|
34
|
+
if (rows * columns > MAX_EXPORT_CELLS) throw tooLarge(`at most ${MAX_EXPORT_CELLS} cells; give a smaller range`);
|
|
35
|
+
const rowsPerChunk = Math.max(1, Math.floor(CHUNK_CELLS / columns));
|
|
36
|
+
const [from, to] = [columnName({ index: firstColumn }), columnName({ index: lastColumn })];
|
|
37
|
+
const chunks = [];
|
|
38
|
+
for (let start = firstRow; start <= lastRow; start += rowsPerChunk) {
|
|
39
|
+
const end = Math.min(lastRow, start + rowsPerChunk - 1);
|
|
40
|
+
chunks.push({ range: tabRange({ title: required.title, cells: `${from}${start + 1}:${to}${end + 1}` }), rowCount: end - start + 1 });
|
|
41
|
+
}
|
|
42
|
+
return chunks;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Text a spreadsheet app would run as a formula when it opens the CSV (OWASP "CSV injection"):
|
|
47
|
+
* a leading `=`, `+`, `-`, `@`, tab or carriage return. Form answers are stored RAW, so a visitor's
|
|
48
|
+
* `=HYPERLINK(...)` comes back from Google as plain text and would otherwise become live.
|
|
49
|
+
*/
|
|
50
|
+
const FORMULA_TRIGGER = /^[=+\-@\t\r]/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One CSV field: text that a spreadsheet app would evaluate gets a leading `'` (shown as text, not
|
|
54
|
+
* run), then the field is quoted when it holds a comma, quote or line break. Only strings are
|
|
55
|
+
* prefixed: a number Google returns as a number (`UNFORMATTED_VALUE`) stays exact, while a
|
|
56
|
+
* formatted value such as `-5` or `+1 555` is text and keeps the `'` (the fidelity trade-off,
|
|
57
|
+
* documented in `references/sheets.md`).
|
|
58
|
+
* @complexity O(length).
|
|
59
|
+
*/
|
|
60
|
+
function csvField(value) {
|
|
61
|
+
const raw = value === undefined || value === null ? '' : String(value);
|
|
62
|
+
const text = typeof value === 'string' && FORMULA_TRIGGER.test(value) ? `'${raw}` : raw;
|
|
63
|
+
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* RFC 4180 CSV (CRLF line ends) of `rows`, padded to the widest row, trailing empty rows dropped.
|
|
68
|
+
* @param {{ rows: readonly (readonly unknown[])[] }} required
|
|
69
|
+
* @returns {{ csv: string, rows: number, columns: number, bytes: number }}
|
|
70
|
+
* @throws {GoogleWorkspaceError} `limit-exceeded` over {@link MAX_CSV_BYTES}.
|
|
71
|
+
* @complexity O(cells).
|
|
72
|
+
*/
|
|
73
|
+
export function encodeCsv(required, _optional = {}) {
|
|
74
|
+
let used = required.rows.length;
|
|
75
|
+
while (used > 0 && required.rows[used - 1].length === 0) used -= 1;
|
|
76
|
+
const kept = required.rows.slice(0, used);
|
|
77
|
+
const columns = Math.max(0, ...kept.map((row) => row.length));
|
|
78
|
+
const csv = kept.map((row) => Array.from({ length: columns }, (_, index) => csvField(row[index])).join(',')).join('\r\n');
|
|
79
|
+
const bytes = Buffer.byteLength(csv);
|
|
80
|
+
if (bytes > MAX_CSV_BYTES) throw tooLarge('the CSV would be over 4 MiB; give a smaller range');
|
|
81
|
+
return { csv, rows: used, columns, bytes };
|
|
82
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The reviewed inventory of Sheets `spreadsheets.batchUpdate` request kinds the Google
|
|
3
|
+
* Workspace tools may send, and the one check every batch passes before it reaches Google (the raw
|
|
4
|
+
* `batch_update` tool and every named wrapper: tab, sort, filter, format, named ranges).
|
|
5
|
+
*
|
|
6
|
+
* Exists because a raw batch tool is the broad-coverage escape hatch, so it must fail closed: only
|
|
7
|
+
* kinds listed here are sent; an unknown, misspelt or preview kind is refused before any request.
|
|
8
|
+
* Each listed kind carries:
|
|
9
|
+
* - `effect`: `destructive` when it deletes something, overwrites or moves cell values, or changes
|
|
10
|
+
* protection; otherwise `write`. The confirmation card names every kind with its effect.
|
|
11
|
+
* - `replay`: `unsafe` when sending the same request twice can change the sheet twice (adds,
|
|
12
|
+
* inserts, appends, deletes by index, moves, find/replace, randomize). A batch holding one is a
|
|
13
|
+
* `write` for the HTTP client, so an unknown outcome is `uncertain-outcome` and never replayed.
|
|
14
|
+
* The class judges a request ALONE. Google applies a batch's requests in order, so two safe kinds
|
|
15
|
+
* can compose into an unsafe sequence (copy A1 to B1, then set A1: a replay copies the new A1 into
|
|
16
|
+
* B1). No sequence is reviewed as safe, so a batch of more than one request is always a `write`.
|
|
17
|
+
* - `requires`: the fields Google needs; an inner array means "at least one of". `fields` (the field
|
|
18
|
+
* mask) must be explicit: `*` would reset every property the request does not set.
|
|
19
|
+
*
|
|
20
|
+
* Reviewed against the Request union of Sheets API v4
|
|
21
|
+
* (https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets/request).
|
|
22
|
+
* Deliberately NOT listed (refused as unsupported): the data-source kinds (`addDataSource`,
|
|
23
|
+
* `updateDataSource`, `deleteDataSource`, `refreshDataSource`, `cancelDataSourceRefresh`). They need
|
|
24
|
+
* Connected Sheets and a BigQuery permission that this sign-in never requests (drive.file only);
|
|
25
|
+
* offering them would only produce permission failures. Adding a kind means reviewing it against the
|
|
26
|
+
* reference and adding one row here.
|
|
27
|
+
*/
|
|
28
|
+
import { GoogleWorkspaceError } from './request-policy.mjs';
|
|
29
|
+
|
|
30
|
+
export const MAX_BATCH_REQUESTS = 100;
|
|
31
|
+
export const MAX_BATCH_BYTES = 1024 * 1024;
|
|
32
|
+
/** The confirmation card shows at most 50 lines: one for the spreadsheet, one per kind. */
|
|
33
|
+
export const MAX_DISTINCT_KINDS = 48;
|
|
34
|
+
|
|
35
|
+
const W = 'write';
|
|
36
|
+
const D = 'destructive';
|
|
37
|
+
const SAFE = 'idempotent';
|
|
38
|
+
const UNSAFE = 'unsafe';
|
|
39
|
+
|
|
40
|
+
/** [kind, effect, replay, requires] */
|
|
41
|
+
const ROWS = [
|
|
42
|
+
['updateSpreadsheetProperties', W, SAFE, ['properties', 'fields']],
|
|
43
|
+
// Shrinking gridProperties.rowCount/columnCount deletes cells.
|
|
44
|
+
['updateSheetProperties', D, SAFE, ['properties', 'fields']],
|
|
45
|
+
['updateDimensionProperties', W, SAFE, ['properties', 'fields', ['range', 'dataSourceSheetRange']]],
|
|
46
|
+
['updateNamedRange', W, SAFE, ['namedRange', 'fields']],
|
|
47
|
+
['repeatCell', D, SAFE, ['range', 'cell', 'fields']],
|
|
48
|
+
['addNamedRange', W, UNSAFE, ['namedRange']],
|
|
49
|
+
['deleteNamedRange', D, SAFE, ['namedRangeId']],
|
|
50
|
+
['addSheet', W, UNSAFE, []],
|
|
51
|
+
['deleteSheet', D, SAFE, ['sheetId']],
|
|
52
|
+
['autoFill', D, SAFE, [['range', 'sourceAndDestination']]],
|
|
53
|
+
['cutPaste', D, UNSAFE, ['source', 'destination']],
|
|
54
|
+
['copyPaste', D, SAFE, ['source', 'destination']],
|
|
55
|
+
['mergeCells', D, SAFE, ['range', 'mergeType']],
|
|
56
|
+
['unmergeCells', W, SAFE, ['range']],
|
|
57
|
+
['updateBorders', W, SAFE, ['range']],
|
|
58
|
+
['updateCells', D, SAFE, ['rows', 'fields', ['start', 'range']]],
|
|
59
|
+
['addFilterView', W, UNSAFE, ['filter']],
|
|
60
|
+
['appendCells', W, UNSAFE, ['sheetId', 'rows', 'fields']],
|
|
61
|
+
['clearBasicFilter', W, SAFE, ['sheetId']],
|
|
62
|
+
['deleteDimension', D, UNSAFE, ['range']],
|
|
63
|
+
['deleteEmbeddedObject', D, SAFE, ['objectId']],
|
|
64
|
+
['deleteFilterView', D, SAFE, ['filterId']],
|
|
65
|
+
['duplicateFilterView', W, UNSAFE, ['filterId']],
|
|
66
|
+
['duplicateSheet', W, UNSAFE, ['sourceSheetId']],
|
|
67
|
+
['findReplace', D, UNSAFE, ['find', ['range', 'sheetId', 'allSheets']]],
|
|
68
|
+
['insertDimension', W, UNSAFE, ['range']],
|
|
69
|
+
['insertRange', W, UNSAFE, ['range', 'shiftDimension']],
|
|
70
|
+
['moveDimension', W, UNSAFE, ['source', 'destinationIndex']],
|
|
71
|
+
['updateEmbeddedObjectPosition', W, SAFE, ['objectId', 'newPosition', 'fields']],
|
|
72
|
+
['pasteData', D, SAFE, ['coordinate', 'data']],
|
|
73
|
+
['textToColumns', D, UNSAFE, ['source']],
|
|
74
|
+
['updateFilterView', W, SAFE, ['filter', 'fields']],
|
|
75
|
+
['deleteRange', D, UNSAFE, ['range', 'shiftDimension']],
|
|
76
|
+
['appendDimension', W, UNSAFE, ['sheetId', 'dimension', 'length']],
|
|
77
|
+
['addConditionalFormatRule', W, UNSAFE, ['rule', 'index']],
|
|
78
|
+
['updateConditionalFormatRule', W, UNSAFE, ['index', 'sheetId', ['rule', 'newIndex']]],
|
|
79
|
+
['deleteConditionalFormatRule', D, UNSAFE, ['index', 'sheetId']],
|
|
80
|
+
['sortRange', D, SAFE, ['range', 'sortSpecs']],
|
|
81
|
+
['setDataValidation', W, SAFE, ['range']],
|
|
82
|
+
// A basic filter's sortSpecs physically reorder the rows, like sortRange.
|
|
83
|
+
['setBasicFilter', D, SAFE, ['filter']],
|
|
84
|
+
['addProtectedRange', D, UNSAFE, ['protectedRange']],
|
|
85
|
+
['updateProtectedRange', D, SAFE, ['protectedRange', 'fields']],
|
|
86
|
+
['deleteProtectedRange', D, SAFE, ['protectedRangeId']],
|
|
87
|
+
['autoResizeDimensions', W, SAFE, [['dimensions', 'dataSourceSheetDimensions']]],
|
|
88
|
+
['addChart', W, UNSAFE, ['chart']],
|
|
89
|
+
['updateChartSpec', W, SAFE, ['chartId', 'spec']],
|
|
90
|
+
['updateBanding', W, SAFE, ['bandedRange', 'fields']],
|
|
91
|
+
['addBanding', W, UNSAFE, ['bandedRange']],
|
|
92
|
+
['deleteBanding', D, SAFE, ['bandedRangeId']],
|
|
93
|
+
['createDeveloperMetadata', W, UNSAFE, ['developerMetadata']],
|
|
94
|
+
['updateDeveloperMetadata', W, SAFE, ['dataFilters', 'developerMetadata', 'fields']],
|
|
95
|
+
['deleteDeveloperMetadata', D, SAFE, ['dataFilter']],
|
|
96
|
+
['randomizeRange', D, UNSAFE, ['range']],
|
|
97
|
+
['addDimensionGroup', W, UNSAFE, ['range']],
|
|
98
|
+
['deleteDimensionGroup', D, UNSAFE, ['range']],
|
|
99
|
+
['updateDimensionGroup', W, SAFE, ['dimensionGroup', 'fields']],
|
|
100
|
+
['trimWhitespace', D, SAFE, ['range']],
|
|
101
|
+
['deleteDuplicates', D, SAFE, ['range']],
|
|
102
|
+
['updateEmbeddedObjectBorder', W, SAFE, ['objectId', 'border', 'fields']],
|
|
103
|
+
['addSlicer', W, UNSAFE, ['slicer']],
|
|
104
|
+
['updateSlicerSpec', W, SAFE, ['slicerId', 'spec', 'fields']],
|
|
105
|
+
['addTable', W, UNSAFE, ['table']],
|
|
106
|
+
['updateTable', W, SAFE, ['table', 'fields']],
|
|
107
|
+
['deleteTable', D, SAFE, ['tableId']],
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/** kind → `{ effect, replay, requires }`, frozen. */
|
|
111
|
+
export const REQUEST_KINDS = Object.freeze(Object.fromEntries(ROWS.map(([kind, effect, replay, requires]) => [kind, Object.freeze({ effect, replay, requires })])));
|
|
112
|
+
|
|
113
|
+
const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
114
|
+
const refuse = (code, detail) => new GoogleWorkspaceError({ code }, { detail });
|
|
115
|
+
|
|
116
|
+
/** Whether one requirement (a field, or "one of" these fields) is present. @complexity O(1). */
|
|
117
|
+
function satisfied(body, requirement) {
|
|
118
|
+
return [requirement].flat().some((field) => body[field] !== undefined && body[field] !== null);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What is wrong with a listed kind's body, or undefined. @complexity O(f). */
|
|
122
|
+
function bodyProblem(entry, body) {
|
|
123
|
+
const missing = entry.requires.find((requirement) => !satisfied(body, requirement));
|
|
124
|
+
if (missing) return `needs ${[missing].flat().join(' or ')}`;
|
|
125
|
+
const explicitMask = typeof body.fields === 'string' && body.fields.trim() !== '' && !body.fields.includes('*');
|
|
126
|
+
return body.fields === undefined || explicitMask ? undefined : 'needs an explicit field mask, not *';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The listed kind of one request, or the refusal that stops the whole batch.
|
|
131
|
+
* @complexity O(f) fields of the kind's requirements.
|
|
132
|
+
*/
|
|
133
|
+
function checkOne(request, index) {
|
|
134
|
+
const keys = isPlainObject(request) ? Object.keys(request) : [];
|
|
135
|
+
if (keys.length !== 1) return { error: refuse('invalid-input', `request ${index + 1} must name exactly one request kind`) };
|
|
136
|
+
const kind = keys[0];
|
|
137
|
+
// Own-property lookup only, so `constructor` or `__proto__` can never pass as a kind.
|
|
138
|
+
const entry = Object.hasOwn(REQUEST_KINDS, kind) ? REQUEST_KINDS[kind] : undefined;
|
|
139
|
+
if (!entry) return { error: refuse('unsupported-request', `request ${index + 1}`) };
|
|
140
|
+
const body = request[kind];
|
|
141
|
+
if (!isPlainObject(body)) return { error: refuse('invalid-input', `request ${index + 1} (${kind}) must be an object`) };
|
|
142
|
+
const problem = bodyProblem(entry, body);
|
|
143
|
+
return problem ? { error: refuse('invalid-input', `request ${index + 1} (${kind}) ${problem}`) } : { kind, entry };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Refuses an empty, oversized or too varied batch. @complexity O(serialized size). */
|
|
147
|
+
function checkBounds(requests) {
|
|
148
|
+
if (!Array.isArray(requests) || requests.length === 0) throw refuse('invalid-input', 'the batch has no requests');
|
|
149
|
+
if (requests.length > MAX_BATCH_REQUESTS) throw refuse('limit-exceeded', `at most ${MAX_BATCH_REQUESTS} requests per batch`);
|
|
150
|
+
if (Buffer.byteLength(JSON.stringify(requests)) > MAX_BATCH_BYTES) throw refuse('limit-exceeded', 'at most 1 MiB of requests per batch');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Checks a whole batch against the inventory. Nothing is sent unless every request passes.
|
|
155
|
+
* @param {{ requests: unknown }} required
|
|
156
|
+
* @returns {{ kind: 'write' | 'idempotent-write', kinds: Map<string, { effect: string, count: number }> }}
|
|
157
|
+
* `kind` is what the HTTP client needs to know about replays; `kinds` is in first-seen order.
|
|
158
|
+
* @throws {GoogleWorkspaceError} `invalid-input`, `unsupported-request` or `limit-exceeded`.
|
|
159
|
+
* @complexity O(n) requests plus their serialized size.
|
|
160
|
+
*/
|
|
161
|
+
export function checkRequests(required, _optional = {}) {
|
|
162
|
+
checkBounds(required.requests);
|
|
163
|
+
const kinds = new Map();
|
|
164
|
+
let unsafe = false;
|
|
165
|
+
required.requests.forEach((request, index) => {
|
|
166
|
+
const checked = checkOne(request, index);
|
|
167
|
+
if (checked.error) throw checked.error;
|
|
168
|
+
unsafe ||= checked.entry.replay === UNSAFE;
|
|
169
|
+
kinds.set(checked.kind, { effect: checked.entry.effect, count: (kinds.get(checked.kind)?.count ?? 0) + 1 });
|
|
170
|
+
});
|
|
171
|
+
if (kinds.size > MAX_DISTINCT_KINDS) throw refuse('limit-exceeded', `at most ${MAX_DISTINCT_KINDS} different request kinds per batch`);
|
|
172
|
+
return { kind: unsafe || required.requests.length > 1 ? 'write' : 'idempotent-write', kinds };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One confirmation line per request kind in the batch: `deleteDimension x2 (destructive)`.
|
|
177
|
+
* @param {{ requests: unknown }} required
|
|
178
|
+
* @complexity O(n).
|
|
179
|
+
*/
|
|
180
|
+
export function describeRequests(required, _optional = {}) {
|
|
181
|
+
const { kinds } = checkRequests(required);
|
|
182
|
+
return [...kinds].map(([kind, { effect, count }]) => `${kind} x${count} (${effect})`);
|
|
183
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The one Sheets client of the Google Workspace plugin: every Sheets tool (values, search,
|
|
3
|
+
* tabs, filters, formatting, batch updates, export) and the form destination build their endpoint,
|
|
4
|
+
* check and encode ids and ranges, apply the RAW input default and enforce the §D bounds here, then
|
|
5
|
+
* send through the one Google client (`http.mjs`), which owns tokens, retries and safe errors
|
|
6
|
+
* (quotas are the host's, charged on each token read). A1 helpers live in `sheets-a1.mjs`.
|
|
7
|
+
*
|
|
8
|
+
* Exists so the rules that keep a call inside what the person asked for live in one place:
|
|
9
|
+
* - a spreadsheet id must look like a Google file id ({@link assertSpreadsheetId}) and, like a
|
|
10
|
+
* range, is ONE encoded path segment (`.`/`..` refused), so caller text can never change which
|
|
11
|
+
* endpoint is called;
|
|
12
|
+
* - every batch, raw or built by a named tool, passes the reviewed request-kind inventory first
|
|
13
|
+
* ({@link runBatchUpdate});
|
|
14
|
+
* - values are written `RAW` unless the caller explicitly asks for `USER_ENTERED`, so text a
|
|
15
|
+
* visitor typed never becomes a formula;
|
|
16
|
+
* - reads return at most 500 rows, 5,000 cells and 64 KiB, with an explicit continuation range
|
|
17
|
+
* instead of a silent partial; mutation payloads stay under 1 MiB;
|
|
18
|
+
* - every request a Sheets tool sends first checks that the Sheets product is on
|
|
19
|
+
* ({@link sheetsClientFor}), so turning Sheets off blocks every Sheets tool at once.
|
|
20
|
+
*/
|
|
21
|
+
import { createGoogleClient, pathSegment } from './http.mjs';
|
|
22
|
+
import { assertProductEnabled } from './product-state.mjs';
|
|
23
|
+
import { GoogleWorkspaceError } from './request-policy.mjs';
|
|
24
|
+
import { parseA1Range } from './sheets-a1.mjs';
|
|
25
|
+
import { MAX_BATCH_BYTES, MAX_BATCH_REQUESTS, checkRequests } from './sheets-admin-request-kinds.mjs';
|
|
26
|
+
|
|
27
|
+
export const SHEETS_API = 'https://sheets.googleapis.com/v4/spreadsheets';
|
|
28
|
+
/** The `products.json` id every Sheets tool belongs to (its tools are named `sheets_*`). */
|
|
29
|
+
export const SHEETS_PRODUCT_ID = 'sheets';
|
|
30
|
+
export const DEFAULT_VALUE_INPUT_OPTION = 'RAW';
|
|
31
|
+
|
|
32
|
+
/** §D starting bounds. Batch reads share ONE output budget across all their ranges. */
|
|
33
|
+
export const SHEETS_LIMITS = Object.freeze({
|
|
34
|
+
maxRows: 500, maxCells: 5_000, maxOutputBytes: 64 * 1024, maxRanges: 20,
|
|
35
|
+
// One mutation bound for values writes and batch updates alike.
|
|
36
|
+
maxMutationEntries: MAX_BATCH_REQUESTS, maxPayloadBytes: MAX_BATCH_BYTES,
|
|
37
|
+
// find reads more than it returns: it scans up to this many cells and returns at most maxMatches.
|
|
38
|
+
maxScannedCells: 50_000, maxMatches: 100, maxExcerptChars: 120,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Default spreadsheet metadata mask: no grid data, only what chat needs to name tabs. */
|
|
42
|
+
const SHEET_PROPERTIES = 'properties(sheetId,title,index,sheetType,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount))';
|
|
43
|
+
const SPREADSHEET_PREFIX = 'spreadsheetId,spreadsheetUrl,properties(title,locale,timeZone)';
|
|
44
|
+
export const SPREADSHEET_FIELDS = `${SPREADSHEET_PREFIX},sheets(${SHEET_PROPERTIES})`;
|
|
45
|
+
const GRID_DATA_FIELDS = `${SPREADSHEET_PREFIX},sheets(${SHEET_PROPERTIES},data(startRow,startColumn,rowData(values(formattedValue))))`;
|
|
46
|
+
|
|
47
|
+
const byteLength = (value) => Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8');
|
|
48
|
+
|
|
49
|
+
/** Google file ids are URL-safe base64 characters; the input schemas use the same pattern. */
|
|
50
|
+
export const SPREADSHEET_ID_PATTERN = /^[A-Za-z0-9_-]{10,200}$/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The id, when it is a plausible Google file id; refused before it reaches any URL otherwise (the
|
|
54
|
+
* value is never echoed).
|
|
55
|
+
* @param {{ id: unknown }} required
|
|
56
|
+
* @param {{ code?: 'invalid-input' | 'invalid-selection' }} [optional] The Picker refuses with
|
|
57
|
+
* `invalid-selection` (re-pick the file); tools with `invalid-input` (the default).
|
|
58
|
+
* @throws {GoogleWorkspaceError}
|
|
59
|
+
* @complexity O(n) characters.
|
|
60
|
+
*/
|
|
61
|
+
export function assertSpreadsheetId(required, optional = {}) {
|
|
62
|
+
if (typeof required.id === 'string' && SPREADSHEET_ID_PATTERN.test(required.id)) return required.id;
|
|
63
|
+
const code = optional.code ?? 'invalid-input';
|
|
64
|
+
throw new GoogleWorkspaceError({ code }, code === 'invalid-input' ? { detail: 'spreadsheetId' } : {});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `<SHEETS_API>/<spreadsheetId>[/<segment>…][<method>]`, the id checked first. `method`
|
|
69
|
+
* (`:append`, `:batchUpdate`, …) is always a constant from plugin code, never caller text.
|
|
70
|
+
* @param {{ spreadsheetId: string }} required
|
|
71
|
+
* @param {{ segments?: readonly string[], method?: string }} [optional]
|
|
72
|
+
* @throws {GoogleWorkspaceError} `invalid-input` for an id or segment that cannot be a path segment.
|
|
73
|
+
* @complexity O(n) characters.
|
|
74
|
+
*/
|
|
75
|
+
export function sheetsUrl(required, optional = {}) {
|
|
76
|
+
const segments = [assertSpreadsheetId({ id: required.spreadsheetId }), ...(optional.segments ?? [])].map((value) => pathSegment({ value }));
|
|
77
|
+
return `${SHEETS_API}/${segments.join('/')}${optional.method ?? ''}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Sends one Sheets request for a tool call through the shared Google client.
|
|
82
|
+
* @param {{ call: { input: { connectionRef: string }, capabilities: { http: object, connections: object, state?: object }, signal?: AbortSignal }, request: { url: string, method?: string, query?: object, body?: unknown, kind: 'read' | 'idempotent-write' | 'write' } }} required
|
|
83
|
+
* `call` is the tool's `invoke` argument as received.
|
|
84
|
+
* @returns {Promise<any>} Google's parsed JSON body.
|
|
85
|
+
* @throws {GoogleWorkspaceError} Every failure, already mapped to a safe code.
|
|
86
|
+
* @complexity O(attempts) (see `createGoogleClient`).
|
|
87
|
+
*/
|
|
88
|
+
export function sendSheetsRequest(required, _optional = {}) {
|
|
89
|
+
const { input, capabilities, signal } = required.call;
|
|
90
|
+
return sheetsClientFor({ capabilities }).request({ connectionRef: input.connectionRef, ...required.request }, signal ? { signal } : {});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The shared Google client over one call's host capabilities. @complexity O(1). */
|
|
94
|
+
export function googleClientFor(required, _optional = {}) {
|
|
95
|
+
const { capabilities } = required;
|
|
96
|
+
return createGoogleClient({ http: capabilities.http, connections: capabilities.connections });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The Google client a Sheets tool uses: each request first checks that the Sheets product is on in
|
|
101
|
+
* the plugin's settings (`capabilities.state`), then goes through the shared client. Checked per
|
|
102
|
+
* request, so turning Sheets off also stops a multi-request call (an export) part way, before the
|
|
103
|
+
* next request is sent.
|
|
104
|
+
* @param {{ capabilities: { http: object, connections: object, state?: { get(input: { key: string }): Promise<unknown> } } }} required
|
|
105
|
+
* @throws {GoogleWorkspaceError} `product-disabled` from `request` while Sheets is off.
|
|
106
|
+
* @complexity O(1) to build; each request adds one state read.
|
|
107
|
+
*/
|
|
108
|
+
export function sheetsClientFor(required, _optional = {}) {
|
|
109
|
+
const { capabilities } = required;
|
|
110
|
+
const client = googleClientFor({ capabilities });
|
|
111
|
+
return {
|
|
112
|
+
async request(request, options = {}) {
|
|
113
|
+
await assertProductEnabled({ state: capabilities.state, productId: SHEETS_PRODUCT_ID });
|
|
114
|
+
return client.request(request, options);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A mask that reaches cell data: `*`, or a `data` field (`sheets.data`, `sheets(data)`), not `dataSources`. */
|
|
120
|
+
const GRID_DATA_MASK = /\*|\bdata\b/;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The spreadsheet field mask: the caller's, else the default (plus cell values when grid data is
|
|
124
|
+
* asked for). Google ignores `includeGridData` once a mask is set, so a caller mask that reaches
|
|
125
|
+
* cell data counts as asking for grid data: it needs `includeGridData` (which `sheets_get` only
|
|
126
|
+
* allows with explicit ranges; `get_by_data_filter` always names its data filters).
|
|
127
|
+
* @throws {GoogleWorkspaceError} `invalid-input` for a grid-data mask without `includeGridData`.
|
|
128
|
+
* @complexity O(n) mask length.
|
|
129
|
+
*/
|
|
130
|
+
export function spreadsheetFields(required, _optional = {}) {
|
|
131
|
+
if (required.fields !== undefined && GRID_DATA_MASK.test(required.fields) && !required.includeGridData) {
|
|
132
|
+
throw new GoogleWorkspaceError({ code: 'invalid-input' }, { detail: 'a fields mask with * or data needs includeGridData and ranges' });
|
|
133
|
+
}
|
|
134
|
+
if (required.fields !== undefined) return required.fields;
|
|
135
|
+
return required.includeGridData ? GRID_DATA_FIELDS : SPREADSHEET_FIELDS;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const MAX_RESOURCE_LINES = 50;
|
|
139
|
+
const MAX_RESOURCE_CHARS = 300;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Lines for the confirmation card, inside the projection's bounds (50 lines of 300 characters):
|
|
143
|
+
* a long line is shortened and extra lines are summarized as a count, so nothing is hidden silently.
|
|
144
|
+
* @complexity O(lines).
|
|
145
|
+
*/
|
|
146
|
+
export function resourceLines(required, _optional = {}) {
|
|
147
|
+
const fit = (line) => (line.length > MAX_RESOURCE_CHARS ? `${line.slice(0, MAX_RESOURCE_CHARS - 1)}…` : line);
|
|
148
|
+
const { lines } = required;
|
|
149
|
+
if (lines.length <= MAX_RESOURCE_LINES) return lines.map(fit);
|
|
150
|
+
return [...lines.slice(0, MAX_RESOURCE_LINES - 1).map(fit), `… and ${lines.length - MAX_RESOURCE_LINES + 1} more`];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The value input option to send: `RAW` unless the caller explicitly chose `USER_ENTERED`. @complexity O(1). */
|
|
154
|
+
export function valueInputOption(required, _optional = {}) {
|
|
155
|
+
return required.option === 'USER_ENTERED' ? 'USER_ENTERED' : DEFAULT_VALUE_INPUT_OPTION;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Read options every values read sends: rows-major, plus the caller's render choices. @complexity O(1). */
|
|
159
|
+
export function readOptions(required, _optional = {}) {
|
|
160
|
+
const { valueRenderOption, dateTimeRenderOption } = required.input;
|
|
161
|
+
return { majorDimension: 'ROWS', ...(valueRenderOption ? { valueRenderOption } : {}), ...(dateTimeRenderOption ? { dateTimeRenderOption } : {}) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Total cells in rows of values. @complexity O(rows). */
|
|
165
|
+
export function cellCount(required, _optional = {}) {
|
|
166
|
+
return required.values.reduce((total, row) => total + row.length, 0);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Refuses a mutation whose serialized body exceeds the payload bound, before anything is sent.
|
|
171
|
+
* @throws {GoogleWorkspaceError} `limit-exceeded`.
|
|
172
|
+
* @complexity O(n) serialized size.
|
|
173
|
+
*/
|
|
174
|
+
export function assertPayloadWithin(required, _optional = {}) {
|
|
175
|
+
if (byteLength(required.value) > SHEETS_LIMITS.maxPayloadBytes) throw new GoogleWorkspaceError({ code: 'limit-exceeded' });
|
|
176
|
+
return required.value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether a result fits the tool-output bound, less `headroomBytes` (room a caller keeps for the
|
|
181
|
+
* rest of its answer).
|
|
182
|
+
* @complexity O(n) serialized size.
|
|
183
|
+
*/
|
|
184
|
+
export function outputFits(required, optional = {}) {
|
|
185
|
+
return byteLength(required.value) <= SHEETS_LIMITS.maxOutputBytes - (optional.headroomBytes ?? 0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Returns a result only when it fits the tool-output bound; a larger result is an error, never a
|
|
190
|
+
* silently cut one (used where no range continuation exists: metadata, data-filter and admin reads).
|
|
191
|
+
* @throws {GoogleWorkspaceError} `limit-exceeded`.
|
|
192
|
+
* @complexity O(n) serialized size.
|
|
193
|
+
*/
|
|
194
|
+
export function assertOutputWithin(required, _optional = {}) {
|
|
195
|
+
if (!outputFits(required)) throw new GoogleWorkspaceError({ code: 'limit-exceeded' }, { detail: 'answer over 64 KiB; narrow it to one tab or fewer items' });
|
|
196
|
+
return required.value;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const signalOption = (signal) => (signal ? { signal } : {});
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* `spreadsheets.get` with a field mask (grid data is never requested).
|
|
203
|
+
* @param {{ client: object, connectionRef: string, spreadsheetId: string, fields: string }} required
|
|
204
|
+
* @param {{ signal?: AbortSignal }} [optional]
|
|
205
|
+
* @complexity O(1) requests.
|
|
206
|
+
*/
|
|
207
|
+
export function getSpreadsheet(required, optional = {}) {
|
|
208
|
+
return required.client.request({
|
|
209
|
+
connectionRef: required.connectionRef, url: sheetsUrl({ spreadsheetId: required.spreadsheetId }), kind: 'read', query: { fields: required.fields },
|
|
210
|
+
}, signalOption(optional.signal));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* `spreadsheets.batchUpdate` after the inventory check; a batch holding any non-replayable kind is a
|
|
215
|
+
* `write`, so an unknown outcome is reported as uncertain and never replayed.
|
|
216
|
+
* @param {{ client: object, connectionRef: string, spreadsheetId: string, requests: readonly object[] }} required
|
|
217
|
+
* @param {{ signal?: AbortSignal }} [optional]
|
|
218
|
+
* @returns {Promise<{ replies: unknown[] }>}
|
|
219
|
+
* @complexity O(n) requests to check, one HTTP request.
|
|
220
|
+
*/
|
|
221
|
+
export async function runBatchUpdate(required, optional = {}) {
|
|
222
|
+
const { kind } = checkRequests({ requests: required.requests });
|
|
223
|
+
const body = await required.client.request({
|
|
224
|
+
connectionRef: required.connectionRef, url: sheetsUrl({ spreadsheetId: required.spreadsheetId }, { method: ':batchUpdate' }),
|
|
225
|
+
method: 'POST', kind, body: { requests: required.requests },
|
|
226
|
+
}, signalOption(optional.signal));
|
|
227
|
+
return { replies: Array.isArray(body?.replies) ? body.replies : [] };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The first line of every admin tool's disclosure. @complexity O(1). */
|
|
231
|
+
export function spreadsheetLine(required, _optional = {}) {
|
|
232
|
+
return `spreadsheet ${assertSpreadsheetId({ id: required.spreadsheetId })}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The tabs of a `sheets(properties(...),<part>)` answer, optionally only one tab.
|
|
237
|
+
* @complexity O(t) tabs.
|
|
238
|
+
*/
|
|
239
|
+
export function tabsOf(required, _optional = {}) {
|
|
240
|
+
const sheets = Array.isArray(required.body?.sheets) ? required.body.sheets : [];
|
|
241
|
+
return required.sheetId === undefined ? sheets : sheets.filter((sheet) => sheet?.properties?.sheetId === required.sheetId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The range that continues `range` after its first `rows` rows, same columns.
|
|
246
|
+
* @throws {GoogleWorkspaceError} `limit-exceeded` when Google's range cannot be continued, so the
|
|
247
|
+
* caller errors instead of returning an undisclosed partial read.
|
|
248
|
+
* @complexity O(n) characters.
|
|
249
|
+
*/
|
|
250
|
+
export function continuationRange(required, _optional = {}) {
|
|
251
|
+
const parts = parseA1Range({ range: required.range });
|
|
252
|
+
if (!parts) throw new GoogleWorkspaceError({ code: 'limit-exceeded' });
|
|
253
|
+
const start = `${parts.sheet}!${parts.startColumn}${parts.startRow + required.rows}`;
|
|
254
|
+
return parts.endRow === undefined ? start : `${start}:${parts.endColumn}${parts.endRow}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Whether one more row fits the shared output budget. @complexity O(1). */
|
|
258
|
+
function rowFits(totals, row, rowBytes) {
|
|
259
|
+
return totals.rows < SHEETS_LIMITS.maxRows && totals.cells + row.length <= SHEETS_LIMITS.maxCells && totals.bytes + rowBytes <= SHEETS_LIMITS.maxOutputBytes;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Cuts value ranges to ONE shared output budget (rows, cells, bytes) in order. A cut range gets
|
|
264
|
+
* `continuation`: the exact range to read next.
|
|
265
|
+
* @param {{ valueRanges: readonly { range: string, values?: unknown[][] }[] }} required
|
|
266
|
+
* @returns {{ range: string, values: unknown[][], continuation?: string }[]}
|
|
267
|
+
* @throws {GoogleWorkspaceError} `limit-exceeded` when not even the first row fits (no progress possible).
|
|
268
|
+
* @complexity O(cells).
|
|
269
|
+
*/
|
|
270
|
+
export function boundValueRanges(required, _optional = {}) {
|
|
271
|
+
const totals = { rows: 0, cells: 0, bytes: 0 };
|
|
272
|
+
const bounded = required.valueRanges.map((valueRange) => {
|
|
273
|
+
const values = valueRange.values ?? [];
|
|
274
|
+
let kept = 0;
|
|
275
|
+
for (const row of values) {
|
|
276
|
+
const rowBytes = byteLength(row);
|
|
277
|
+
if (!rowFits(totals, row, rowBytes)) break;
|
|
278
|
+
Object.assign(totals, { rows: totals.rows + 1, cells: totals.cells + row.length, bytes: totals.bytes + rowBytes });
|
|
279
|
+
kept += 1;
|
|
280
|
+
}
|
|
281
|
+
if (kept === values.length) return { range: valueRange.range, values };
|
|
282
|
+
return { range: valueRange.range, values: values.slice(0, kept), continuation: continuationRange({ range: valueRange.range, rows: kept }) };
|
|
283
|
+
});
|
|
284
|
+
if (totals.rows === 0 && bounded.some((entry) => entry.continuation)) throw new GoogleWorkspaceError({ code: 'limit-exceeded' });
|
|
285
|
+
return bounded;
|
|
286
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Server-side pieces every Google Workspace admin-surface handler shares: the plugin's one
|
|
3
|
+
* connection id, a Google client over the surface capability port, the "is Sheets usable right now"
|
|
4
|
+
* check, and the translation of plugin and host refusals into surface-safe failures.
|
|
5
|
+
*
|
|
6
|
+
* Exists because the three panels (connection, Picker, form destination) run in the host's
|
|
7
|
+
* admin-surface handler seam, whose port differs from the tool port in one way that matters here:
|
|
8
|
+
* connections are named by the surface's declared connection id, not by a connection ref. Keeping
|
|
9
|
+
* that adaptation and the failure mapping in one place means every panel sends the same safe codes
|
|
10
|
+
* and fixed messages (never Google's text, a token, or a file's contents) to the frame.
|
|
11
|
+
*/
|
|
12
|
+
import { readAccount } from './drive-files.mjs';
|
|
13
|
+
import { isProductEnabled } from './product-state.mjs';
|
|
14
|
+
import { GoogleWorkspaceError } from './request-policy.mjs';
|
|
15
|
+
import { SHEETS_PRODUCT_ID, sheetsClientFor } from './sheets-client.mjs';
|
|
16
|
+
|
|
17
|
+
/** The plugin's one sign-in: `tovu-connections.json`'s id, declared by every surface that uses it. */
|
|
18
|
+
export const CONNECTION_ID = 'account';
|
|
19
|
+
|
|
20
|
+
const FAILURE_CODE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
21
|
+
|
|
22
|
+
/** Panel-only refusals (nothing was sent to Google), with the fixed sentence the frame shows. */
|
|
23
|
+
const SURFACE_FAILURES = Object.freeze({
|
|
24
|
+
'picker-expired': 'The file picker session ended. Close this panel, open it again, and choose the spreadsheet again.',
|
|
25
|
+
'invalid-destination': 'The form name or tab is not valid. Use the form\'s short name (letters, digits, - or _) and choose a tab from the list.',
|
|
26
|
+
'tab-not-found': 'That tab is not in the spreadsheet any more. Reload the tabs and choose one again.',
|
|
27
|
+
'confirm-external-rows': 'Confirm that removing this destination later leaves the rows already written in the spreadsheet, then save again.',
|
|
28
|
+
'too-many-destinations': 'This plugin keeps at most 50 form destinations. Remove one you no longer use, then save again.',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A surface-safe failure for a panel-only refusal.
|
|
33
|
+
* @param {{ fail: (input: { code: string, message: string }) => Error, code: keyof typeof SURFACE_FAILURES }} required
|
|
34
|
+
* @returns {Error}
|
|
35
|
+
* @complexity O(1).
|
|
36
|
+
*/
|
|
37
|
+
export function surfaceRefusal(required, _optional = {}) {
|
|
38
|
+
return required.fail({ code: required.code, message: SURFACE_FAILURES[required.code] });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The plugin's one Sheets client (product gate on every request) over the surface port: the client
|
|
43
|
+
* asks for a `connectionRef`, and inside a surface handler that reference is the declared connection
|
|
44
|
+
* id, which the host resolves for this plugin and workspace only.
|
|
45
|
+
* @param {{ capabilities: { http: object, state: object, connections: { getAccessToken(input: { connectionId: string }): Promise<string> } } }} required
|
|
46
|
+
* @returns {{ request(input: object, options?: object): Promise<unknown> }}
|
|
47
|
+
* @complexity O(1).
|
|
48
|
+
*/
|
|
49
|
+
export function surfaceSheetsClient(required, _optional = {}) {
|
|
50
|
+
const { http, state, connections } = required.capabilities;
|
|
51
|
+
return sheetsClientFor({
|
|
52
|
+
capabilities: { http, state, connections: { getAccessToken: ({ connectionRef }) => connections.getAccessToken({ connectionId: connectionRef }) } },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The signed-in Google account (`{ accountId, displayName }`), read through the surface client. @complexity O(1): one request. */
|
|
57
|
+
export function surfaceAccount(required, _optional = {}) {
|
|
58
|
+
return readAccount({ client: surfaceSheetsClient(required), connectionRef: CONNECTION_ID });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Status → why Sheets cannot be used yet, as a safe code. @complexity O(s) granted scope sets. */
|
|
62
|
+
function connectionRefusal(status) {
|
|
63
|
+
if (status.status === 'reconnect-required') return 'reconnect-required';
|
|
64
|
+
if (status.status !== 'connected') return 'not-connected';
|
|
65
|
+
return status.grantedScopeSetIds.includes(SHEETS_PRODUCT_ID) ? null : 'missing-permission';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Why the Picker (or anything else Sheets-backed on a panel) cannot run now, or null: the
|
|
70
|
+
* connection must be connected with the Sheets permission granted, and Sheets must be turned on.
|
|
71
|
+
* @param {{ capabilities: { connections: { status(input: { connectionId: string }): Promise<{ status: string, grantedScopeSetIds: readonly string[] }> }, state: { get(input: { key: string }): Promise<unknown> } } }} required
|
|
72
|
+
* @returns {Promise<'not-connected' | 'reconnect-required' | 'missing-permission' | 'product-disabled' | null>}
|
|
73
|
+
* @complexity O(1): one status read and one state read.
|
|
74
|
+
*/
|
|
75
|
+
export async function sheetsUnavailableReason(required, _optional = {}) {
|
|
76
|
+
const { connections, state } = required.capabilities;
|
|
77
|
+
const refusal = connectionRefusal(await connections.status({ connectionId: CONNECTION_ID }));
|
|
78
|
+
if (refusal !== null) return refusal;
|
|
79
|
+
return (await isProductEnabled({ state, productId: SHEETS_PRODUCT_ID })) ? null : 'product-disabled';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Turns a refusal into what the frame may see: a plugin `GoogleWorkspaceError` keeps its code, fixed
|
|
84
|
+
* message and retryable flag; a host refusal with a plain lowercase code keeps its code and message
|
|
85
|
+
* (host messages are written for people and never carry secrets); anything else is rethrown so the
|
|
86
|
+
* host's dispatcher answers with its generic failure and logs the cause.
|
|
87
|
+
* @complexity O(1).
|
|
88
|
+
*/
|
|
89
|
+
function surfaceFailure(error, fail) {
|
|
90
|
+
if (error instanceof GoogleWorkspaceError) return fail({ code: error.code, message: error.message }, { retryable: error.retryable });
|
|
91
|
+
const hostRefusal = typeof error?.code === 'string' && FAILURE_CODE.test(error.code) && typeof error.message === 'string';
|
|
92
|
+
return hostRefusal ? fail({ code: error.code, message: error.message }, { retryable: error.retryable === true }) : error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Runs one handler body with {@link surfaceFailure} applied to whatever it throws.
|
|
97
|
+
* @param {{ fail: Function, run: () => Promise<unknown> }} required `fail` is the handler call's own.
|
|
98
|
+
* @returns {Promise<unknown>} The body's result.
|
|
99
|
+
* @complexity O(1) plus the body.
|
|
100
|
+
*/
|
|
101
|
+
export async function withSurfaceFailures(required, _optional = {}) {
|
|
102
|
+
try {
|
|
103
|
+
return await required.run();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw surfaceFailure(error, required.fail);
|
|
106
|
+
}
|
|
107
|
+
}
|